diff --git a/.bazelrc b/.bazelrc index e1a0e95..cabbca2 100644 --- a/.bazelrc +++ b/.bazelrc @@ -22,6 +22,17 @@ build --incompatible_default_to_explicit_init_py build --cxxopt=-std=c++20 build --host_cxxopt=-std=c++20 +# ============================================================================= +# Python Stable ABI (nanobind) +# ============================================================================= +# Build the nanobind extension against CPython's stable ABI (abi3), floored at +# 3.12. A single `cp312-abi3` extension then runs on every CPython >= 3.12, so +# dev/test builds here exercise the exact artifact shipped in the main wheel. +# Each wheel target re-pins this via its Starlark transition, so the artifact is +# correct even in a host workspace whose .bazelrc lacks this flag, and so +# //python:fastslide_wheel_cp311 can opt back out for the 3.11 build. +build --@nanobind_bazel//:py-limited-api=cp312 + # ============================================================================= # Release Config (opt + strip) # ============================================================================= diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 394860d..86f9ea3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -351,25 +351,45 @@ jobs: # setup args from pyproject.toml, so every dependency (aifocore/simpletiff # via git wraps, codecs via WrapDB) is built from source and linked # statically into the self-contained _fastslide.pyd. No DLLs to bundle. - - name: Build wheels (${{ matrix.platform }}) + # Two cibuildwheel invocations, because the stable-ABI and version-specific + # wheels need different Meson setup args and cibuildwheel applies + # config-settings uniformly across one run. Both write to the same + # output-dir. `--vsenv` forces MSVC: the windows-2022/windows-11-arm images + # ship MinGW GCC (C:\mingw64\bin) on PATH and Meson's auto-detection picks + # that `cc` over MSVC, producing a GCC build (which also hits the + # MinGW-only jpeglib.h/ FILE error). It makes Meson activate the + # Visual Studio environment even when another compiler is already on PATH, + # matching the MSVC toolchain used by the Java native build. + - name: Build cp311 wheel (${{ matrix.platform }}) if: steps.gate.outputs.run == 'true' && matrix.method == 'meson' uses: pypa/cibuildwheel@v3.2.0 with: package-dir: . output-dir: artifacts/wheels env: - # cp310 has no official Windows ARM64 CPython; cibuildwheel skips it - # there automatically, so the same build set is safe for both arches. - CIBW_BUILD: "cp310-* cp311-* cp312-* cp313-* cp314-*" + # 3.11 predates nanobind's stable-ABI floor, so this is a conventional + # version-specific build. `python.allow_limited_api` defaults to false + # in meson.build, which is what keeps meson-python from trying to tag + # the result abi3 (it errors out rather than mislabel the wheel). + CIBW_BUILD: "cp311-*" CIBW_ARCHS_WINDOWS: ${{ matrix.cibw_arch }} CIBW_BUILD_VERBOSITY: "1" - # Force MSVC. The windows-2022/windows-11-arm images ship MinGW GCC - # (C:\mingw64\bin) on PATH, and Meson's auto-detection picks that `cc` - # over MSVC, producing a GCC build (which also hits the MinGW-only - # jpeglib.h/ FILE error). `--vsenv` makes Meson activate the - # Visual Studio environment even when another compiler is already on - # PATH, matching the MSVC toolchain used by the Java native build. CIBW_CONFIG_SETTINGS_WINDOWS: "setup-args=--vsenv" + - name: Build cp312-abi3 wheel (${{ matrix.platform }}) + if: steps.gate.outputs.run == 'true' && matrix.method == 'meson' + uses: pypa/cibuildwheel@v3.2.0 + with: + package-dir: . + output-dir: artifacts/wheels + env: + # Opting in to the limited API here is what makes meson-python stamp + # the wheel `cp312-abi3` instead of `cp312-cp312`; the pyproject.toml + # `limited-api = true` key alone is inert while the Meson option is + # off. The result installs on every CPython >= 3.12. + CIBW_BUILD: "cp312-*" + CIBW_ARCHS_WINDOWS: ${{ matrix.cibw_arch }} + CIBW_BUILD_VERBOSITY: "1" + CIBW_CONFIG_SETTINGS_WINDOWS: "setup-args=--vsenv setup-args=-Dpython.allow_limited_api=true" - name: Upload wheels if: steps.gate.outputs.run == 'true' uses: actions/upload-artifact@v6 @@ -586,8 +606,8 @@ jobs: # shell: bash # run: | # set -euo pipefail - # uv python install 3.11 - # uv venv --python 3.11 .venv + # uv python install 3.12 + # uv venv --python 3.12 .venv # uv pip install --python .venv/bin/python --no-binary fastslide dist/*.tar.gz # .venv/bin/python tools/smoke_test_python.py smoke-wheels: @@ -597,7 +617,10 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.10", "3.11", "3.12", "3.13", "3.14"] + # Two wheels per platform: a cp311 one, and a cp312-abi3 one imported on + # every CPython >= 3.12 to prove the stable-ABI tag really does load + # across versions. uv resolves each interpreter to its matching wheel. + python: ["3.11", "3.12", "3.13", "3.14"] target: - platform: linux_x86_64 runner: ubuntu-24.04 @@ -614,12 +637,6 @@ jobs: runner: windows-2022 - platform: windows_arm64 runner: windows-11-arm - # No official CPython 3.10 for Windows ARM64, so no cp310 win_arm64 wheel - # is built; drop that smoke combination. - exclude: - - python: "3.10" - target: - platform: windows_arm64 steps: - name: Checkout uses: actions/checkout@v5 @@ -650,11 +667,14 @@ jobs: # Build a venv on the right interpreter (x86_64 CPython for the Rosetta # leg), install the local wheel under test (uv picks the dist wheel whose # tags match the venv's Python/arch), and run the import + open-sample - # smoke. --find-links dist makes the local wheel the source for fastslide, - # while the index supplies its runtime dep (numpy>=1.20), which is NOT - # vendored in dist. Do NOT add --no-index here: that made the install fail - # to resolve numpy on a cold uv cache (it only "worked" elsewhere by - # accident, off a warm cache that already had numpy). + # smoke. + # + # numpy (the only runtime dep, and NOT vendored in dist) is installed from + # the index in a separate step so that fastslide itself can be installed + # with --no-index. That pin matters: pip/uv prefer an exact cpXY tag over + # an abi3 one, so with the index enabled a same-version wheel already on + # PyPI would outrank the local cp312-abi3 wheel on 3.13/3.14 and the job + # would silently smoke-test the published artifact instead of this build. - name: Import + open-sample smoke (${{ matrix.target.platform }} py${{ matrix.python }}) if: steps.gate.outputs.run == 'true' shell: bash @@ -671,7 +691,8 @@ jobs: else py=".venv/bin/python" fi - uv pip install --python "$py" --find-links dist fastslide + uv pip install --python "$py" numpy + uv pip install --python "$py" --no-index --find-links dist fastslide "$py" tools/smoke_test_python.py publish-pypi: name: Publish wheels + sdist to PyPI diff --git a/MODULE.bazel b/MODULE.bazel index 9f4eeb3..20e33a0 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -7,7 +7,7 @@ local_path_override. module( name = "fastslide", - version = "0.8.0", + version = "0.8.1", ) bazel_dep(name = "rules_cc", version = "0.2.17") @@ -140,29 +140,29 @@ bazel_dep(name = "aspect_rules_js", version = "2.9.2", dev_dependency = True) bazel_dep(name = "buildifier_prebuilt", version = "8.0.3", dev_dependency = True) bazel_dep(name = "toolchains_llvm", version = "1.7.0", dev_dependency = True) +# The Python bindings ship two wheels: a stable-ABI (abi3) wheel built against +# 3.12 that runs on every CPython >= 3.12, plus a version-specific cp311 wheel +# for the one release still predating the stable-ABI floor. 3.12 is the default +# toolchain; 3.11 is registered only so the cp311 wheel's transition can resolve. python = use_extension("@rules_python//python/extensions:python.bzl", "python") python.toolchain( is_default = True, - python_version = "3.11", + python_version = "3.12", ) -python.toolchain(python_version = "3.9") -python.toolchain(python_version = "3.10") -python.toolchain(python_version = "3.12") -python.toolchain(python_version = "3.13") -python.toolchain(python_version = "3.14") +python.toolchain(python_version = "3.11") pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip") pip.parse( enable_implicit_namespace_pkgs = True, hub_name = "fastslide_pip", - python_version = "3.11", + python_version = "3.12", requirements_darwin = "//:requirements_darwin.txt", requirements_lock = "//:requirements_linux.txt", ) pip.parse( enable_implicit_namespace_pkgs = True, hub_name = "fastslide_docs_pip", - python_version = "3.11", + python_version = "3.12", requirements_darwin = "//docs:requirements_darwin.txt", requirements_lock = "//docs:requirements_linux.txt", ) diff --git a/README.md b/README.md index 4f94cec..eddf12c 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,15 @@ uv build self-contained: all codecs are statically linked into the `_fastslide` extension, so the resulting wheel has no native runtime dependencies. +A source build targets the interpreter running it, producing a `cp3XX` wheel. +That is the right default locally, where a stable-ABI build buys nothing. To +reproduce the redistributable `cp312-abi3` wheel instead, opt in explicitly (on +CPython >= 3.12; 3.11 cannot use the limited API): + +```bash +uv build --wheel -Csetup-args=-Dpython.allow_limited_api=true +``` + #### Option 3: Build from source with Bazel FastSlide is a [Bazel module](https://bazel.build/external/module). Builds are @@ -125,27 +134,35 @@ bazelisk test //... ##### Building Python wheels with Bazel -Wheels are platform-specific because they bundle the native C++ extension. Each -Python version has its own Bazel target: `//python:fastslide_wheel_cp310` through -`//python:fastslide_wheel_cp314` (Python 3.10–3.14). Unlike the Meson path, -Bazel can also cross-compile wheels for other platforms. +Wheels are platform-specific because they bundle the native C++ extension. Two +are built per platform: + +| Target | Tag | Installable on | +| -------------------------------- | ------------ | --------------- | +| `//python:fastslide_wheel` | `cp312-abi3` | CPython >= 3.12 | +| `//python:fastslide_wheel_cp311` | `cp311` | CPython 3.11 | + +The first targets CPython's stable ABI, so one build serves every CPython from +3.12 onwards. 3.11 predates that floor and therefore needs a conventional +version-specific build. `//python:all_wheels` builds both. Unlike the Meson +path, Bazel can also cross-compile wheels for other platforms. **Current platform** — build on the host OS/arch without cross-compilation: ```bash -# Example: Python 3.11 wheel for the machine you are on. -bazelisk build //python:fastslide_wheel_cp311 +# Both wheels for the machine you are on. +bazelisk build //python:all_wheels ``` -The `.whl` file appears under `bazel-bin/python/`. +The `.whl` files appear under `bazel-bin/python/`. **Cross-compilation** — build wheels for other platforms using the Zig-backed hermetic toolchains (`--config=hermetic` in `.bazelrc`): ```bash -# Example: Linux x86_64 wheel for Python 3.11, e.g. from macOS. +# Example: Linux x86_64 wheels, e.g. from macOS. bazelisk build --config=hermetic --platforms=//platforms:linux_x86_64 \ - //python:fastslide_wheel_cp311 + //python:all_wheels ``` Supported platform keys: `linux_x86_64`, `linux_arm64`, `darwin_x86_64`, @@ -153,14 +170,14 @@ Supported platform keys: `linux_x86_64`, `linux_arm64`, `darwin_x86_64`, architecture from macOS, the native toolchain is used instead of hermetic Zig. **Batch builds** — `tools/build_wheels.py` drives Bazel for multiple platforms -and Python versions and copies wheels into `artifacts/wheels/`: +and copies wheels into `artifacts/wheels/`: ```bash -# All supported platforms and Python versions. +# All supported platforms. python tools/build_wheels.py -# Subset, e.g. one platform and one Python tag. -python tools/build_wheels.py --platform linux_x86_64 --python cp311 +# Subset, e.g. one platform. +python tools/build_wheels.py --platform linux_x86_64 # Continue after individual failures. python tools/build_wheels.py --keep-going diff --git a/docs/Doxyfile b/docs/Doxyfile index 49a58cd..b33f8ca 100644 --- a/docs/Doxyfile +++ b/docs/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = FastSlide # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 0.8.0 +PROJECT_NUMBER = 0.8.1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewers a # quick idea about the purpose of the project. Keep the description short. diff --git a/docs/source/conf.py b/docs/source/conf.py index 036ff9e..e43a537 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -11,7 +11,7 @@ html_title = "FastSlide Documentation" copyright = "2025, AI for Oncology" author = "AI for Oncology" -release = "0.8.0" +release = "0.8.1" version = "0.2" # -- General configuration --------------------------------------------------- diff --git a/docs/source/guides/packages_and_releases.rst b/docs/source/guides/packages_and_releases.rst index dce92f6..0b30521 100644 --- a/docs/source/guides/packages_and_releases.rst +++ b/docs/source/guides/packages_and_releases.rst @@ -75,9 +75,12 @@ so no native path configuration is needed. Python wheels ------------- -The Python package is distributed as platform wheels (one per -platform/CPython version, cp310--cp314) built with Bazel and **published to -PyPI**, so consumers just:: +The Python package is distributed as two wheels per platform. The first is +tagged ``cp312-abi3`` and built against CPython's stable ABI, so it runs +unchanged on every CPython >= 3.12; the second is tagged ``cp311``, a +conventional version-specific build for 3.11, which predates the stable-ABI +floor. Wheels are built with Bazel and **published to PyPI**, so consumers +just:: pip install fastslide @@ -87,6 +90,25 @@ built for ``linux``/``darwin`` (x86_64 + aarch64) and ``windows`` (x86_64); matching the Java side). Build them locally with ``tools/build_wheels.py`` (see below). +How the stable ABI is turned on differs per build system, and on the Meson side +it takes **two** cooperating switches: + +- **Bazel** (``linux``/``darwin``): ``//python:fastslide_wheel`` pins + ``@nanobind_bazel//:py-limited-api=cp312`` through its Starlark transition, + while ``//python:fastslide_wheel_cp311`` pins it to ``unset``. +- **Meson** (``windows``): ``meson.build``'s ``limited_api`` kwarg controls how + the *extension* is compiled, but meson-python reads the *wheel tag* from the + separate ``[tool.meson-python] limited-api`` key in ``pyproject.toml``. Set + one without the other and you get a limited-API binary shipped under a + ``cp3XX-cp3XX`` tag, installable only on the exact version that built it. + Because that key is static, the Meson build option + ``python.allow_limited_api`` (which meson-python honours as a one-way + disable) defaults to ``false``, so ordinary source builds stay + version-specific and work on any supported interpreter. Only the release + job's 3.12 leg passes ``-Dpython.allow_limited_api=true``, which is what + actually yields the ``cp312-abi3`` wheel. 3.11 never opts in: nanobind + supports the limited API from 3.12 onwards only. + .. _java-local-release: Building and testing locally (no GitHub) @@ -130,7 +152,7 @@ a faithful preview of the real release. To build wheels locally (optionally narrowing the platform/Python matrix):: - python3 tools/build_wheels.py --platform darwin_aarch64 --python cp311 + python3 tools/build_wheels.py --platform darwin_aarch64 # -> artifacts/wheels/*.whl ``publish_java_artifacts.py`` also attaches any wheels in ``artifacts/wheels`` @@ -180,12 +202,12 @@ single aggregate **GitHub Release**: smx["darwin x86_64 (Rosetta) / aarch64"] swx["windows_x86_64 / windows-2022"] end - subgraph bw [build-wheels: cp310-cp314 per platform] + subgraph bw [build-wheels: cp312-abi3 + cp311 wheels per platform] wl["linux x86_64/arm64"] wm["darwin x86_64/aarch64"] ww["windows_x86_64"] end - subgraph sw [smoke-wheels: import + open sample, every platform x cp310-cp314] + subgraph sw [smoke-wheels: import + open sample, every platform x cp311-cp314] swl["linux x86_64/arm64"] swm["darwin x86_64 (Rosetta) / aarch64"] sww["windows_x86_64 / windows-2022"] @@ -217,8 +239,11 @@ Why the split: - **Smoke tests always run on the real target runner** (including native Windows). The Java smoke needs only a JDK + the JARs; the wheel smoke (``smoke-wheels``) installs the built wheel into a fresh ``uv`` venv on each - platform x CPython (3.10--3.14), then ``import fastslide`` and opens the - bundled sample (``tools/smoke_test_python.py``). No Bazel -- so both validate + platform x CPython (3.11--3.14), then ``import fastslide`` and opens the + bundled sample (``tools/smoke_test_python.py``). ``uv`` resolves 3.11 to the + cp311 wheel and everything above it to the abi3 one, so importing on every + version proves the stable-ABI tag actually loads. No Bazel -- so both + validate the exact artifact a consumer would load. PyPI/TestPyPI publishing ``needs:`` the wheel smoke, so broken wheels never reach an index. diff --git a/meson.build b/meson.build index f60b8fc..28c13b7 100644 --- a/meson.build +++ b/meson.build @@ -1,5 +1,5 @@ project('fastslide', 'c', 'cpp', - version : '0.8.0', + version : '0.8.1', license : 'Apache-2.0', meson_version : '>=1.3', default_options : [ @@ -11,6 +11,10 @@ project('fastslide', 'c', 'cpp', # with e.g. `meson setup builddir --buildtype=debug` for an unoptimized build. 'buildtype=release', 'b_ndebug=if-release', + # Stable-ABI (abi3) builds are opt-in; see the Python extension section + # below. Off by default so that a plain source build targets the running + # interpreter and works on every supported version. + 'python.allow_limited_api=false', ]) fs = import('fs') @@ -268,6 +272,37 @@ if get_option('build_python') py = import('python').find_installation(pure : false) nanobind_dep = dependency('nanobind') + # Build against CPython's stable ABI (abi3): `limited_api` defines + # Py_LIMITED_API, tags the module `_fastslide.abi3.so`/`.pyd`, and on Windows + # links python3.lib, so one cp312-abi3 wheel serves every CPython >= 3.12. + # ('' is Meson's "no limited API" default.) + # + # Two conditions must hold. `python.allow_limited_api` is opt-in (see + # default_options above): a stable-ABI extension only pays off in a + # redistributable wheel, so the release job turns it on and plain source + # builds leave it off and target the running interpreter. And the floor is + # 3.12, because nanobind only supports the limited API from that version -- + # 3.11 always gets a version-specific extension, whatever the option says. + if get_option('python.allow_limited_api') and py.language_version().version_compare('>=3.12') + limited_api = '3.12' + else + limited_api = '' + endif + + # The nanobind WrapDB dep pulls py.dependency() in transitively, which on + # Windows drags python3XX.lib onto the link line and defeats the limited API + # (Meson's /NODEFAULTLIB only suppresses the pragma-injected default). Keep + # nanobind's sources/includes/compile args, drop its transitive link args; + # nanobind adds none of its own on Windows. Pass py.dependency() explicitly so + # Meson finds it as a direct dep and swaps in the limited-API copy. Only needed + # for a limited-API build; otherwise linking python3XX.lib is correct. + if host_machine.system() == 'windows' and limited_api != '' + nanobind_ext_dep = nanobind_dep.partial_dependency( + compile_args : true, includes : true, sources : true) + else + nanobind_ext_dep = nanobind_dep + endif + py.extension_module('_fastslide', [ 'src/python/fastslide.cpp', @@ -275,8 +310,9 @@ if get_option('build_python') 'src/python/reader.cpp', ], include_directories : inc, - dependencies : [nanobind_dep, fastslide_dep], + dependencies : [py.dependency(), nanobind_ext_dep, fastslide_dep], cpp_args : fastslide_cpp_args, + limited_api : limited_api, install : true, subdir : 'fastslide') diff --git a/package/BUILD.bazel b/package/BUILD.bazel index 0ca37a0..b27047a 100644 --- a/package/BUILD.bazel +++ b/package/BUILD.bazel @@ -17,7 +17,7 @@ load(":defs.bzl", "fastslide_dev_deb", "fastslide_runtime_deb") package(default_visibility = ["//visibility:public"]) -FASTSLIDE_VERSION = "0.8.0" +FASTSLIDE_VERSION = "0.8.1" exports_files([ "versions.json", diff --git a/package/versions.json b/package/versions.json index 7ea51bd..dc0646c 100644 --- a/package/versions.json +++ b/package/versions.json @@ -1,10 +1,10 @@ { - "versions": [ - { - "display": "FastSlide", - "id": "fastslide", - "type": "primary", - "version": "0.8.0" - } - ] + "versions": [ + { + "display": "FastSlide", + "id": "fastslide", + "type": "primary", + "version": "0.8.1" + } + ] } diff --git a/pyproject.toml b/pyproject.toml index d2b5ef8..6e1deb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,22 @@ [build-system] -requires = ["meson-python>=0.15.0", "ninja", "nanobind>=2.0.0"] +# meson-python >= 0.17 for robust limited-API (abi3) wheel handling (it refuses +# limited-API + free-threaded builds rather than emitting a broken wheel). +requires = ["meson-python>=0.17.0", "ninja", "nanobind>=2.0.0"] build-backend = "mesonpy" [project] name = "fastslide" -version = "0.8.0" +version = "0.8.1" description = "High-performance whole slide image reader for digital pathology" readme = "README.md" authors = [ {name = "Jonas Teuwen", email = "j.teuwen@nki.nl"} ] license = {text = "Apache-2.0"} -requires-python = ">=3.10" +# From 3.12 the extension is built against CPython's stable ABI (abi3), so a +# single cp312-abi3 wheel serves every CPython >= 3.12. 3.11 predates that floor +# and is served by a separate version-specific cp311 wheel. +requires-python = ">=3.11" classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Science/Research", @@ -19,7 +24,6 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", "Programming Language :: C++", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -43,6 +47,21 @@ Documentation = "https://docs.aifo.dev/fastslide/" Repository = "https://github.com/NKI-AI/fastslide" Issues = "https://github.com/NKI-AI/fastslide/issues" +[tool.meson-python] +# Master switch for stable-ABI (abi3) wheel tagging. Building the extension +# against the limited API is not enough on its own: meson-python reads the wheel +# ABI tag from THIS key, so without it a limited-API build still ships as +# `cp3XX-cp3XX` and pip refuses to install it on any later Python. +# +# It is deliberately paired with `python.allow_limited_api=false` in +# meson.build's default_options, which is the only per-build override +# meson-python honours (it can disable, never enable). Defaulting to disabled +# means an unflagged source build is version-specific and always succeeds -- +# including on 3.11, which nanobind cannot build against the limited API at all. +# Only the release job's 3.12 leg opts back in with +# `-Dpython.allow_limited_api=true`, which is what produces the cp312-abi3 wheel. +limited-api = true + [tool.meson-python.args] setup = [ "--wrap-mode=forcefallback", diff --git a/python/BUILD.bazel b/python/BUILD.bazel index e3cfc79..f9b74e6 100644 --- a/python/BUILD.bazel +++ b/python/BUILD.bazel @@ -2,18 +2,22 @@ This BUILD file defines: - py_library: The FastSlide Python package with native extension -- versioned_py_wheel: Platform-specific wheels for distribution (Python 3.10-3.13) +- versioned_py_wheel: two platform-specific wheels per platform: + * `fastslide_wheel` -> cp312-abi3, built against CPython's stable ABI, + installable on every CPython >= 3.12. + * `fastslide_wheel_cp311` -> cp311, a version-specific build for the one + release predating the stable-ABI floor (3.12). -To build a wheel for a specific Python version and platform: +To build both wheels for a platform: bazelisk build --platforms=//platforms:darwin_aarch64 \\ - //python:fastslide_wheel_cp311 + //python:all_wheels -To build all wheels, use the provided script: - python aifo/fastslide/tools/build_artifacts.py --bazel bazelisk +To build wheels for all platforms, use the provided script: + python aifo/fastslide/tools/build_wheels.py --bazel bazelisk -The wheels are platform-specific because they include native C++ extensions. +The wheels are platform-specific because they include a native C++ extension. Each versioned_py_wheel target uses a Starlark transition to compile the -extension against the correct Python headers for its target version. +extension against the right Python toolchain and ABI level. Built wheels are collected into: aifo/fastslide/artifacts/wheels/ @@ -21,7 +25,7 @@ Built wheels are collected into: load("@aspect_rules_py//py:defs.bzl", "py_binary", "py_library") load("@rules_multirun//:defs.bzl", "multirun") -load("//tools:versioned_py_wheel.bzl", "multi_version_py_wheel") +load("//tools:versioned_py_wheel.bzl", "versioned_py_wheel") # Platform-specific compatibility - supports Linux, macOS, and Windows COMPATIBLE_TARGETS = select({ @@ -43,7 +47,7 @@ WHEEL_PLATFORM = select({ "//conditions:default": "any", }) -FASTSLIDE_VERSION = "0.8.0" +FASTSLIDE_VERSION = "0.8.1" # ============================================================================== # Copy extension binary # ============================================================================== @@ -51,6 +55,19 @@ FASTSLIDE_VERSION = "0.8.0" # applies a Starlark transition that selects the correct Python toolchain, so # the extension is rebuilt with the correct Python headers for each version. +# nanobind_extension emits the same binary under three names (`.so`, +# `.abi3.so`, `.pyd`); which one belongs in the wheel depends on the ABI the +# extension was actually compiled for. Both variants are declared here and the +# py_library below picks one via the same config settings nanobind itself uses, +# so each wheel's transitioned configuration gets the correctly named file: +# `.abi3.so` for the stable-ABI build, plain `.so` for the cp311 build. +genrule( + name = "copy_extension_abi3_so", + srcs = ["//src/python:_fastslide.abi3.so"], + outs = ["fastslide/_fastslide.abi3.so"], + cmd = "cp $< $@", +) + genrule( name = "copy_extension_so", srcs = ["//src/python:_fastslide.so"], @@ -78,12 +95,19 @@ genrule( cmd = "cp $< $@", ) +# The extension file belonging in the wheel, keyed on target OS and on whether a +# stable-ABI build was requested. Mirrors nanobind's own `extension_name()` +# select, whose three conditions are mutually exclusive. Each wheel target's +# transition sets `py-limited-api`, so this resolves per wheel. +EXTENSION_FILE = select({ + "@platforms//os:windows": [":copy_extension_pyd"], + "@nanobind_bazel//:stable-abi-unix": [":copy_extension_abi3_so"], + "@nanobind_bazel//:unstable-abi-unix": [":copy_extension_so"], +}) + py_library( name = "fastslide", - srcs = glob(["fastslide/**/*.py"]) + [":copy_extension_pyi"] + select({ - "@platforms//os:windows": [":copy_extension_pyd"], - "//conditions:default": [":copy_extension_so"], - }), + srcs = glob(["fastslide/**/*.py"]) + [":copy_extension_pyi"] + EXTENSION_FILE, imports = ["."], visibility = ["//visibility:public"], ) @@ -93,39 +117,71 @@ alias( name = "copy_extension", actual = select({ "@platforms//os:windows": ":copy_extension_pyd", - "//conditions:default": ":copy_extension_so", + "@nanobind_bazel//:stable-abi-unix": ":copy_extension_abi3_so", + "@nanobind_bazel//:unstable-abi-unix": ":copy_extension_so", }), ) # ============================================================================== -# Build wheels for multiple Python versions +# Wheels # ============================================================================== -# multi_version_py_wheel applies a Starlark configuration transition per -# Python version, ensuring native extensions are compiled against the matching -# Python headers. - -PYTHON_VERSIONS = { - "3.10": "cp310", - "3.11": "cp311", - "3.12": "cp312", - "3.13": "cp313", - "3.14": "cp314", -} - -multi_version_py_wheel( +# versioned_py_wheel applies a Starlark configuration transition that pins both +# the Python toolchain and nanobind's `py-limited-api` flag, so each target +# rebuilds the native extension against the right headers and ABI level. +# +# `fastslide_wheel` is the stable-ABI artifact: built once against the CPython +# 3.12 headers, stamped `cp312-abi3-`, and installable on every +# CPython >= 3.12. `fastslide_wheel_cp311` is a conventional version-specific +# build for 3.11, which predates the stable-ABI floor and so cannot be served by +# the abi3 wheel. + +versioned_py_wheel( + name = "fastslide_wheel", + abi = "abi3", author = "Jonas Teuwen", author_email = "j.teuwen@nki.nl", - default_version = "3.11", distribution = "fastslide", homepage = "https://github.com/NKI-AI/fastslide", license = "Apache-2.0", - name_prefix = "fastslide_wheel", platform = WHEEL_PLATFORM, - python_requires = ">=3.10", - python_versions = PYTHON_VERSIONS, + py_limited_api = "cp312", + python_requires = ">=3.12", + python_tag = "cp312", + python_version = "3.12", strip_path_prefixes = ["python/"], summary = "High-performance, thread-safe digital pathology slide reader", target_compatible_with = COMPATIBLE_TARGETS, version = FASTSLIDE_VERSION, + visibility = ["//visibility:public"], deps = [":fastslide"], ) + +versioned_py_wheel( + name = "fastslide_wheel_cp311", + abi = "cp311", + author = "Jonas Teuwen", + author_email = "j.teuwen@nki.nl", + distribution = "fastslide", + homepage = "https://github.com/NKI-AI/fastslide", + license = "Apache-2.0", + platform = WHEEL_PLATFORM, + py_limited_api = "unset", + python_requires = ">=3.11,<3.12", + python_tag = "cp311", + python_version = "3.11", + strip_path_prefixes = ["python/"], + summary = "High-performance, thread-safe digital pathology slide reader", + target_compatible_with = COMPATIBLE_TARGETS, + version = FASTSLIDE_VERSION, + visibility = ["//visibility:public"], + deps = [":fastslide"], +) + +filegroup( + name = "all_wheels", + srcs = [ + ":fastslide_wheel", + ":fastslide_wheel_cp311", + ], + visibility = ["//visibility:public"], +) diff --git a/python/fastslide/__init__.py b/python/fastslide/__init__.py index 5e78da2..cd6f5b5 100644 --- a/python/fastslide/__init__.py +++ b/python/fastslide/__init__.py @@ -11,7 +11,7 @@ >>> print(slide.dimensions) """ -__version__ = "0.8.0" +__version__ = "0.8.1" from fastslide._fastslide import * from fastslide.xyz_pyramid import XYZPyramid diff --git a/rust/fastslide-sys/src/lib.rs b/rust/fastslide-sys/src/lib.rs index b289838..c00204d 100644 --- a/rust/fastslide-sys/src/lib.rs +++ b/rust/fastslide-sys/src/lib.rs @@ -465,12 +465,8 @@ unsafe extern "C" { reader: *mut FastSlideSlideReader, capacity_bytes: usize, ) -> c_int; - pub fn fastslide_slide_reader_use_global_cache( - reader: *mut FastSlideSlideReader, - ) -> c_int; - pub fn fastslide_slide_reader_is_cache_enabled( - reader: *const FastSlideSlideReader, - ) -> c_int; + pub fn fastslide_slide_reader_use_global_cache(reader: *mut FastSlideSlideReader) -> c_int; + pub fn fastslide_slide_reader_is_cache_enabled(reader: *const FastSlideSlideReader) -> c_int; pub fn fastslide_slide_reader_clear_cache(reader: *mut FastSlideSlideReader); pub fn fastslide_slide_reader_get_cache_stats( reader: *const FastSlideSlideReader, diff --git a/rust/fastslide/src/lib.rs b/rust/fastslide/src/lib.rs index 7bae851..e9a5689 100644 --- a/rust/fastslide/src/lib.rs +++ b/rust/fastslide/src/lib.rs @@ -60,8 +60,8 @@ pub use metadata::{ }; pub use reader::{CacheStats, ColorSpace, OpenOptions, RenderingIntent, SlideReader}; pub use registry::{ - c_api_version, clear_global_cache, global_cache_stats, is_supported, - set_global_cache_capacity, supported_extensions, version, + c_api_version, clear_global_cache, global_cache_stats, is_supported, set_global_cache_capacity, + supported_extensions, version, }; pub use slide_image::SlideImage; @@ -144,8 +144,8 @@ mod tests { return; }; - let reader = SlideReader::open_with_cache(&path, 256 << 20) - .expect("open_with_cache should succeed"); + let reader = + SlideReader::open_with_cache(&path, 256 << 20).expect("open_with_cache should succeed"); assert!(reader.is_cache_enabled()); let region = RegionSpec::new( diff --git a/src/c/registry.cpp b/src/c/registry.cpp index d1948b9..7922835 100644 --- a/src/c/registry.cpp +++ b/src/c/registry.cpp @@ -341,5 +341,5 @@ int fastslide_is_supported(const char* file_path) { // Version information const char* fastslide_get_version(void) { - return "0.8.0"; + return "0.8.1"; } diff --git a/src/python/fastslide.cpp b/src/python/fastslide.cpp index 99c0ace..fcde757 100644 --- a/src/python/fastslide.cpp +++ b/src/python/fastslide.cpp @@ -701,5 +701,5 @@ NB_MODULE(_fastslide, m) { "Check if file format is supported", nb::arg("filename")); // Version and constants - m.attr("__version__") = "0.8.0"; + m.attr("__version__") = "0.8.1"; } diff --git a/tools/artifacts/cli.py b/tools/artifacts/cli.py index 12c28e5..04713b5 100644 --- a/tools/artifacts/cli.py +++ b/tools/artifacts/cli.py @@ -5,7 +5,7 @@ import argparse from . import jars, wheels -from .specs import PLATFORMS, PY_TAG_TO_VERSION +from .specs import PLATFORMS def main() -> None: @@ -22,13 +22,6 @@ def main() -> None: choices=sorted(PLATFORMS.keys()), help="Platform(s) to build. Defaults to all supported platforms.", ) - parser.add_argument( - "--python", - action="append", - dest="python_tags", - choices=sorted(PY_TAG_TO_VERSION.keys()), - help="Python tag(s) to build (e.g. cp311). Defaults to all supported tags.", - ) parser.add_argument( "--keep-going", action="store_true", @@ -54,7 +47,6 @@ def main() -> None: args = parser.parse_args() platforms = args.platforms or list(PLATFORMS.keys()) - python_tags = args.python_tags or list(PY_TAG_TO_VERSION.keys()) exit_code = 0 @@ -62,7 +54,6 @@ def main() -> None: wheels_rc = wheels.build_wheels( bazel_cmd=args.bazel, platforms=platforms, - python_tags=python_tags, keep_going=args.keep_going, extra_bazel_args=args.bazel_args, ) diff --git a/tools/artifacts/specs.py b/tools/artifacts/specs.py index bffd1c7..ffbb35a 100644 --- a/tools/artifacts/specs.py +++ b/tools/artifacts/specs.py @@ -60,10 +60,18 @@ class PlatformSpec: } -PY_TAG_TO_VERSION: dict[str, str] = { - "cp310": "3.10", - "cp311": "3.11", - "cp312": "3.12", - "cp313": "3.13", - "cp314": "3.14", -} +@dataclass(frozen=True) +class WheelSpec: + """One wheel flavour: a Bazel target and the PEP 425 tag it stamps.""" + + target: str + tag: str + + +# Two wheels are built per platform: a stable-ABI (abi3) one floored at CPython +# 3.12, installable on every CPython >= 3.12, and a version-specific one for +# 3.11, which predates the stable-ABI floor. +WHEELS: tuple[WheelSpec, ...] = ( + WheelSpec(target="//python:fastslide_wheel", tag="cp312-abi3"), + WheelSpec(target="//python:fastslide_wheel_cp311", tag="cp311"), +) diff --git a/tools/artifacts/wheels.py b/tools/artifacts/wheels.py index 5876eb6..d615d61 100644 --- a/tools/artifacts/wheels.py +++ b/tools/artifacts/wheels.py @@ -7,7 +7,7 @@ import subprocess from . import common -from .specs import PLATFORMS, PY_TAG_TO_VERSION, PlatformSpec +from .specs import PLATFORMS, WHEELS, PlatformSpec ARTIFACT_DIR = common.WORKSPACE_ROOT / "artifacts" / "wheels" @@ -37,7 +37,6 @@ def build_wheels( *, bazel_cmd: str, platforms: list[str], - python_tags: list[str], keep_going: bool, extra_bazel_args: list[str], ) -> int: @@ -62,34 +61,23 @@ def build_wheels( extra_bazel_args=extra_bazel_args, ) - for py_tag in python_tags: - if py_tag not in PY_TAG_TO_VERSION: - raise ValueError( - f"Unsupported python tag '{py_tag}'. Supported: {', '.join(sorted(PY_TAG_TO_VERSION))}" - ) + for wheel in WHEELS: + label = f"{platform_key} {wheel.tag}" + print(f"\n▶︎ Building {wheel.tag} wheel for {platform_key} with {bazel_cmd}") - # Build all Python versions for this platform in a single Bazel - # invocation so Bazel can parallelise the four transitioned configs. - targets = [f"//python:fastslide_wheel_{t}" for t in python_tags] - tags_label = ", ".join(python_tags) - print(f"\n▶︎ Building {tags_label} wheels for {platform_key} with {bazel_cmd}") - - try: - common.run([bazel_cmd, "build", *bazel_flags, *targets], env=env) - except subprocess.CalledProcessError: - for t in python_tags: - failures.append(f"{platform_key}:{t}") - print(f"❌ Build failed for {platform_key}") - if not keep_going: - return 1 - continue + try: + common.run([bazel_cmd, "build", *bazel_flags, wheel.target], env=env) + except subprocess.CalledProcessError: + failures.append(label) + print(f"❌ Build failed for {label}") + if not keep_going: + return 1 + continue - for py_tag in python_tags: - target = f"//python:fastslide_wheel_{py_tag}" try: files = common.cquery_target_files( bazel_cmd=bazel_cmd, - target=target, + target=wheel.target, bazel_flags=bazel_flags, env=env, ) @@ -98,13 +86,13 @@ def build_wheels( if f.suffix != ".whl": continue dst = common.copy_to_dir(f, ARTIFACT_DIR, mode=0o644) - print(f" ✔ {py_tag} -> {dst}") + print(f" ✔ {label} -> {dst}") copied_any = True if not copied_any: - raise FileNotFoundError(f"No .whl outputs found for {target} (got {len(files)} files)") + raise FileNotFoundError(f"No .whl outputs found for {wheel.target} (got {len(files)} files)") except Exception as e: - failures.append(f"{platform_key}:{py_tag}") - print(f" ❌ Error collecting {py_tag} wheel for {platform_key}: {e}") + failures.append(label) + print(f" ❌ Error collecting wheel for {label}: {e}") if not keep_going: return 1 diff --git a/tools/build_wheels.py b/tools/build_wheels.py index e83760f..a36ddb1 100644 --- a/tools/build_wheels.py +++ b/tools/build_wheels.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 -"""Build FastSlide wheels for multiple platforms and Python versions.""" +"""Build the FastSlide cp312-abi3 and cp311 wheels for one or more platforms.""" from __future__ import annotations import argparse from artifacts import wheels -from artifacts.specs import PLATFORMS, PY_TAG_TO_VERSION +from artifacts.specs import PLATFORMS def main() -> None: @@ -23,13 +23,6 @@ def main() -> None: choices=sorted(PLATFORMS.keys()), help="Platform(s) to build. Defaults to all supported platforms.", ) - parser.add_argument( - "--python", - action="append", - dest="python_tags", - choices=sorted(PY_TAG_TO_VERSION.keys()), - help="Python tag(s) to build (e.g. cp311). Defaults to all supported tags.", - ) parser.add_argument( "--keep-going", action="store_true", @@ -45,13 +38,11 @@ def main() -> None: args = parser.parse_args() platforms = args.platforms or list(PLATFORMS.keys()) - python_tags = args.python_tags or list(PY_TAG_TO_VERSION.keys()) raise SystemExit( wheels.build_wheels( bazel_cmd=args.bazel, platforms=platforms, - python_tags=python_tags, keep_going=args.keep_going, extra_bazel_args=args.bazel_args, ) diff --git a/tools/versioned_py_wheel.bzl b/tools/versioned_py_wheel.bzl index a5a50fb..74907d8 100644 --- a/tools/versioned_py_wheel.bzl +++ b/tools/versioned_py_wheel.bzl @@ -1,26 +1,27 @@ -"""Versioned py_wheel rule with Python version transitions. +"""Versioned py_wheel rule with a Python version + stable-ABI transition. The standard `py_wheel` rule only stamps the wheel tag (e.g. cp312) but does NOT transition its dependency graph to actually compile native extensions -against the corresponding Python headers. This module provides +against the corresponding Python toolchain. This module provides `versioned_py_wheel`, a macro that wraps `py_wheel` with a Starlark -configuration transition so that the entire dep tree — including cc_binary -targets from pybind_extension / cython_rules — is built against the correct -Python toolchain. +configuration transition so that the entire dep tree — including the +nanobind_extension cc_binary — is built against the correct Python toolchain +AND against the requested stable-ABI (abi3) level. + +Pinning `@nanobind_bazel//:py-limited-api` in the transition (rather than only +in `.bazelrc`) makes the wheel correct even when fastslide is a non-root +dependency in a host workspace whose `.bazelrc` does not set the flag. Usage in a BUILD file: - load("//tools:versioned_py_wheel.bzl", "multi_version_py_wheel") + load("//tools:versioned_py_wheel.bzl", "versioned_py_wheel") - multi_version_py_wheel( - name_prefix = "my_wheel", - python_versions = { - "3.10": "cp310", - "3.11": "cp311", - "3.12": "cp312", - "3.13": "cp313", - "3.14": "cp314", - }, + versioned_py_wheel( + name = "my_wheel", + python_version = "3.12", + python_tag = "cp312", + py_limited_api = "cp312", + abi = "abi3", distribution = "my_package", deps = [":my_lib"], ... @@ -30,12 +31,18 @@ Usage in a BUILD file: load("@rules_python//python:packaging.bzl", "py_wheel") def _python_version_transition_impl(_settings, attr): - return {"@rules_python//python/config_settings:python_version": attr.python_version} + return { + "@rules_python//python/config_settings:python_version": attr.python_version, + "@nanobind_bazel//:py-limited-api": attr.py_limited_api, + } _python_version_transition = transition( implementation = _python_version_transition_impl, inputs = [], - outputs = ["@rules_python//python/config_settings:python_version"], + outputs = [ + "@rules_python//python/config_settings:python_version", + "@nanobind_bazel//:py-limited-api", + ], ) def _versioned_py_wheel_rule_impl(ctx): @@ -47,22 +54,34 @@ _versioned_py_wheel_rule = rule( attrs = { "wheel": attr.label(mandatory = True, cfg = _python_version_transition), "python_version": attr.string(mandatory = True), + "py_limited_api": attr.string(mandatory = True), "_allowlist_function_transition": attr.label( default = "@bazel_tools//tools/allowlists/function_transition_allowlist", ), }, ) -def versioned_py_wheel(*, name, python_version, python_tag, visibility = None, **wheel_kwargs): - """Creates a py_wheel whose entire dep graph is built for the given Python version. +def versioned_py_wheel( + *, + name, + python_version, + python_tag, + py_limited_api, + visibility = None, + **wheel_kwargs): + """Creates a py_wheel whose dep graph is built for a Python version + stable ABI. All keyword arguments except ``name``, ``python_version``, ``python_tag``, - and ``visibility`` are forwarded verbatim to ``py_wheel``. + ``py_limited_api``, and ``visibility`` are forwarded verbatim to ``py_wheel``. Args: name: Target name for the versioned wheel. - python_version: The major.minor Python version (e.g. "3.12"). + python_version: The major.minor Python version to build against + (e.g. "3.12"). python_tag: The wheel python tag (e.g. "cp312"). + py_limited_api: The `@nanobind_bazel//:py-limited-api` value to pin for + the extension build: "cp312"/"cp313"/"cp314" for a stable-ABI build, + or "unset" for a version-specific one. visibility: Bazel visibility for the outer target. **wheel_kwargs: Arguments forwarded to py_wheel. """ @@ -79,60 +98,6 @@ def versioned_py_wheel(*, name, python_version, python_tag, visibility = None, * name = name, wheel = ":" + inner_name, python_version = python_version, + py_limited_api = py_limited_api, visibility = visibility, ) - -def multi_version_py_wheel(*, name_prefix, python_versions, default_version = "3.11", visibility = None, **wheel_kwargs): - """Creates versioned_py_wheel targets for multiple Python versions at once. - - This macro generates one ``versioned_py_wheel`` target per entry in - *python_versions*, plus an alias ```` pointing at the - *default_version* wheel, plus ``all_wheels`` / per-version filegroups. - - Because it lives in a ``.bzl`` file, it can accept ``**wheel_kwargs`` - (which BUILD files cannot). - - Args: - name_prefix: Prefix for target names (e.g. "dlup_wheel" → - "dlup_wheel_cp312"). - python_versions: Dict mapping ``"major.minor"`` → ``"cpXYZ"`` tag, - e.g. ``{"3.10": "cp310", "3.11": "cp311", ...}``. - default_version: Which version the ```` alias points to. - visibility: Bazel visibility for the generated targets. - **wheel_kwargs: All remaining keyword arguments are forwarded to every - ``py_wheel`` instantiation (distribution, deps, platform, …). - """ - wheel_targets = [] - default_tag = None - - for version, tag in python_versions.items(): - target_name = "{}_{}".format(name_prefix, tag) - versioned_py_wheel( - name = target_name, - python_version = version, - python_tag = tag, - visibility = visibility, - **wheel_kwargs - ) - wheel_targets.append(target_name) - if version == default_version: - default_tag = tag - - native.alias( - name = name_prefix, - actual = ":{}_{}".format(name_prefix, default_tag or python_versions.values()[0]), - visibility = visibility, - ) - - native.filegroup( - name = "all_wheels", - srcs = [":{}".format(t) for t in wheel_targets], - visibility = visibility, - ) - - for version, tag in python_versions.items(): - native.filegroup( - name = "wheels_python{}".format(version.replace(".", "")), - srcs = [":{}_{}".format(name_prefix, tag)], - visibility = visibility, - )