From d52770fd417824278a2b3840bc07ff4e2ff7a1df Mon Sep 17 00:00:00 2001 From: Ludovic Henry Date: Mon, 5 Oct 2026 19:00:17 +0000 Subject: [PATCH 1/2] Add pillow-jxl-plugin 1.3.8 riscv64 wheel build pillow-jxl-plugin wraps libjxl (Google's JPEG XL reference implementation) via the jpegxl-rs/jpegxl-sys Rust crates, built through maturin's `vendored` cargo feature, which drives libjxl's own CMake build from source (libjxl, highway, brotli and skcms all bundled by jpegxl-src). highway's RVV target requires `__riscv_v_intrinsic`, which is only defined when compiling with a `v`-carrying -march; without that flag highway's baseline collapses to its portable EMU128/SCALAR target, and skcms gates its own SIMD path on `!defined(__x86_64__)`, so both fall back to their generic C++ paths automatically on riscv64 with no RVV code compiled at all - avoiding the whole class of RVV-dispatch runtime SIGILL traps seen in other ports. libjxl's own CMakeLists.txt already branches on CMAKE_SYSTEM_PROCESSOR MATCHES "riscv". Two patches: make test/test_plugin.py's OpenEXR/pyexiv2-gated tests pytest.importorskip (pyexiv2 has no riscv64 wheel on our registry at all, and OpenEXR has none for cp310/cp311), and widen pyproject.toml's license-files glob so the vendored libjxl/highway/brotli/skcms licence files staged by the workflow land in the wheel's dist-info/licenses. Matrix mirrors upstream's own per-interpreter release matrix (cp310-cp314; no abi3), minus pp311 (no riscv64 PyPy). musllinux is dropped: Pillow (a runtime dependency) has no musllinux_riscv64 wheel on our registry yet. --- .github/workflows/build-pillow-jxl-plugin.yml | 149 ++++++++++++++++++ docs/packages/pillow-jxl-plugin.yaml | 5 + ...ake-OpenEXR-and-pyexiv2-imports-lazy.patch | 67 ++++++++ ...license-files-to-cover-vendored-libr.patch | 35 ++++ 4 files changed, 256 insertions(+) create mode 100644 .github/workflows/build-pillow-jxl-plugin.yml create mode 100644 docs/packages/pillow-jxl-plugin.yaml create mode 100644 patches/pillow-jxl-plugin/1.3.8/0001-test-make-OpenEXR-and-pyexiv2-imports-lazy.patch create mode 100644 patches/pillow-jxl-plugin/1.3.8/0002-pyproject-widen-license-files-to-cover-vendored-libr.patch diff --git a/.github/workflows/build-pillow-jxl-plugin.yml b/.github/workflows/build-pillow-jxl-plugin.yml new file mode 100644 index 00000000000..c1b3da8e86d --- /dev/null +++ b/.github/workflows/build-pillow-jxl-plugin.yml @@ -0,0 +1,149 @@ +# SPDX-FileCopyrightText: 2026 The RISE Project +# SPDX-License-Identifier: MIT +--- +# This workflow is based on the `linux` job of +# https://github.com/Isotr0py/pillow-jpegxl-plugin/blob/v1.3.8/.github/workflows/release.yml +name: Build pillow-jxl-plugin wheels (riscv64) + +on: + workflow_dispatch: + inputs: + version: + description: 'Version glob to (re)build; empty builds every version of docs/packages/pillow-jxl-plugin.yaml not released yet' + required: false + default: '' + pull_request: + branches: [main] + paths: + - '.github/workflows/build-pillow-jxl-plugin.yml' + - 'docs/packages/pillow-jxl-plugin.yaml' + push: + branches: [main] + paths: + - '.github/workflows/build-pillow-jxl-plugin.yml' + - 'docs/packages/pillow-jxl-plugin.yaml' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read # to fetch code (actions/checkout) + +env: + MANYLINUX_RISCV64_IMAGE: quay.io/pypa/manylinux_2_39_riscv64 + +jobs: + setup: + uses: $/.github/workflows/_setup.yml + with: + package: pillow-jxl-plugin + version: ${{ inputs.version }} + + build_wheels: + needs: [setup] + if: needs.setup.outputs.versions != '[]' + name: Build pillow-jxl-plugin ${{ matrix.version }} ${{ matrix.python }}-manylinux_riscv64 + runs-on: ubuntu-24.04-riscv + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + version: ${{ fromJSON(needs.setup.outputs.versions) }} + # Cargo.toml has `pyo3 = { version="0.29.0", features=["extension-module"] }` + # with no abi3 feature, matching upstream's own per-interpreter release + # matrix (MATURIN_INTERPRETERS: "3.10 3.11 3.12 3.13 3.14 pypy3.11"). + # pp311 is dropped: PyPy has no riscv64 port. + python: ["cp310", "cp311", "cp312", "cp313", "cp314"] + + env: + PILLOW_JXL_PLUGIN_VERSION: ${{ matrix.version }} + + steps: + - name: Checkout pillow-jxl-plugin v${{ env.PILLOW_JXL_PLUGIN_VERSION }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: Isotr0py/pillow-jpegxl-plugin + ref: v${{ env.PILLOW_JXL_PLUGIN_VERSION }} + persist-credentials: false + + - name: Checkout python-wheels + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: python-wheels + persist-credentials: false + + - name: Patch pillow-jxl-plugin source + run: git apply python-wheels/patches/pillow-jxl-plugin/${{ env.PILLOW_JXL_PLUGIN_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 }} + # [tool.maturin] already has `features = [..., "vendored"]`, which + # builds libjxl/highway/brotli from source via jpegxl-src's CMake + # invocation - no MATURIN_PEP517_ARGS needed. `cargo metadata + # --features vendored` unpacks jpegxl-src (an optional dependency + # gated by that feature) so its bundled licences can be staged here + # for the widened `license-files` glob to pick up. + CIBW_BEFORE_ALL_LINUX: >- + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && + export PATH="$PATH:$HOME/.cargo/bin" && + cargo metadata --manifest-path {package}/Cargo.toml --features vendored --format-version=1 >/dev/null && + JXL_SRC="$(find "$HOME/.cargo/registry/src" -maxdepth 2 -type d -name 'jpegxl-src-*' | head -1)" && + cp "$JXL_SRC/libjxl/LICENSE" {package}/LICENSE.libjxl && + cp "$JXL_SRC/libjxl/PATENTS" {package}/PATENTS.libjxl && + cp "$JXL_SRC/libjxl/third_party/highway/LICENSE" {package}/LICENSE.highway && + cp "$JXL_SRC/libjxl/third_party/brotli/LICENSE" {package}/LICENSE.brotli && + cp "$JXL_SRC/libjxl/third_party/skcms/LICENSE" {package}/LICENSE.skcms + CIBW_ENVIRONMENT_LINUX: >- + PATH="$PATH:$HOME/.cargo/bin" + PIP_EXTRA_INDEX_URL=https://pypi.riseproject.dev/simple/ + CIBW_TEST_REQUIRES: pytest numpy + CIBW_TEST_SOURCES: test + # test/test_plugin.py's OpenEXR/pyexiv2-gated tests are patched to + # pytest.importorskip: pyexiv2 has no riscv64 wheel on our registry + # at all, and OpenEXR has none for cp310/cp311. + CIBW_TEST_COMMAND: >- + python -c "import pillow_jxl; from pillow_jxl import JpegXLImagePlugin" && + python -m pytest test/test_plugin.py -v + + - name: Check the wheel contents + run: | + python3 - wheelhouse/*.whl <<'EOF' + import sys, zipfile + for whl in sys.argv[1:]: + names = zipfile.ZipFile(whl).namelist() + assert any(n.startswith("pillow_jxl/pillow_jxl") and n.endswith(".so") for n in names), whl + licenses = sorted(n.rsplit("/", 1)[1] for n in names if ".dist-info/licenses/" in n) + print(whl, "licenses:", licenses) + for expect in ("LICENSE.libjxl", "PATENTS.libjxl", "LICENSE.highway", "LICENSE.brotli", "LICENSE.skcms"): + assert expect in licenses, (whl, expect, licenses) + print(whl, "ok") + EOF + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pillow-jxl-plugin-${{ env.PILLOW_JXL_PLUGIN_VERSION }}-${{ matrix.python }}-manylinux_riscv64 + path: wheelhouse/*.whl + if-no-files-found: error + + publish: + name: Publish pillow-jxl-plugin ${{ matrix.version }} + needs: [setup, build_wheels] + if: needs.setup.outputs.versions != '[]' + strategy: + fail-fast: false + matrix: + version: ${{ fromJSON(needs.setup.outputs.versions) }} + permissions: + contents: write + pull-requests: write + uses: $/.github/workflows/_publish-wheel.yml + secrets: + app-private-key: ${{ secrets.RISEPROJECT_APP_PRIVATE_KEY }} + with: + artifact-pattern: pillow-jxl-plugin-${{ matrix.version }}-*-manylinux_riscv64 diff --git a/docs/packages/pillow-jxl-plugin.yaml b/docs/packages/pillow-jxl-plugin.yaml new file mode 100644 index 00000000000..dfe0eae3bfc --- /dev/null +++ b/docs/packages/pillow-jxl-plugin.yaml @@ -0,0 +1,5 @@ +package-name: pillow-jxl-plugin +source-code: https://github.com/Isotr0py/pillow-jpegxl-plugin +license: GPL-3.0-or-later +versions: +- version: 1.3.8 diff --git a/patches/pillow-jxl-plugin/1.3.8/0001-test-make-OpenEXR-and-pyexiv2-imports-lazy.patch b/patches/pillow-jxl-plugin/1.3.8/0001-test-make-OpenEXR-and-pyexiv2-imports-lazy.patch new file mode 100644 index 00000000000..391b5f5010a --- /dev/null +++ b/patches/pillow-jxl-plugin/1.3.8/0001-test-make-OpenEXR-and-pyexiv2-imports-lazy.patch @@ -0,0 +1,67 @@ +From 59744a7bd9210d6c7099d5bbbbecdeb0e21914a6 Mon Sep 17 00:00:00 2001 +From: Ludovic Henry +Date: Mon, 5 Oct 2026 00:00:00 +0000 +Subject: [PATCH] test: make OpenEXR and pyexiv2 imports lazy + +OpenEXR (test_decode_F16) and pyexiv2 (the metadata-encode tests) are +unconditional module-level imports, so the whole test file fails to +collect wherever either package is absent. On riscv64, pyexiv2 has no +wheel on pypi.riseproject.dev at all, and OpenEXR has none for the +cp310/cp311 interpreters this port tests, so collection failed before +a single test ran. Move both imports into the functions that use them +and use pytest.importorskip, so unrelated tests still collect and run +and only the dependency-gated ones are skipped. + +Upstream-Status: Inappropriate [riscv64 test environment has no pyexiv2 wheel at all, and no OpenEXR wheel for some of the interpreters this port tests] +--- + test/test_plugin.py | 6 ++++-- + 1 file changed, 4 insertions(+), 2 deletions(-) + +diff --git a/test/test_plugin.py b/test/test_plugin.py +index 38594ad..b65ad98 100644 +--- a/test/test_plugin.py ++++ b/test/test_plugin.py +@@ -2,8 +2,6 @@ import os + import tempfile + + import numpy as np +-import OpenEXR +-import pyexiv2 + import pytest + from PIL import Image + +@@ -50,6 +48,7 @@ def test_decode_F(): + + + def test_decode_F16(): ++ OpenEXR = pytest.importorskip("OpenEXR") + img_jxl = Image.open("test/images/random_image_f16.jxl") + assert img_jxl.mode == "RGB" + +@@ -116,6 +115,7 @@ def test_metadata_decode_from_direct_decoding(): + + + def test_metadata_encode_from_jpg(): ++ pyexiv2 = pytest.importorskip("pyexiv2") + # Load a JPEG image + ref_img_path = "test/images/metadata/1x1_exif_xmp.jpg" + temp = tempfile.mktemp(suffix=".jxl") +@@ -130,6 +130,7 @@ def test_metadata_encode_from_jpg(): + + + def test_metadata_encode_from_raw_exif(): ++ pyexiv2 = pytest.importorskip("pyexiv2") + with open("test/images/metadata/sample.exif", "rb") as f: + ref_exif = f.read() + img_ori = Image.open("test/images/sample.png") +@@ -142,6 +143,7 @@ def test_metadata_encode_from_raw_exif(): + + + def test_metadata_encode_from_pil_exif(): ++ pyexiv2 = pytest.importorskip("pyexiv2") + exif_img_path = "test/images/metadata/1x1_exif_xmp.jpg" + dummy_img = Image.open("test/images/sample.png") + exif_img = Image.open(exif_img_path) +-- +2.43.0 + diff --git a/patches/pillow-jxl-plugin/1.3.8/0002-pyproject-widen-license-files-to-cover-vendored-libr.patch b/patches/pillow-jxl-plugin/1.3.8/0002-pyproject-widen-license-files-to-cover-vendored-libr.patch new file mode 100644 index 00000000000..5d3a6659037 --- /dev/null +++ b/patches/pillow-jxl-plugin/1.3.8/0002-pyproject-widen-license-files-to-cover-vendored-libr.patch @@ -0,0 +1,35 @@ +From f15842a37a57fbb3d7abe8cdd0c21ebe9625bf44 Mon Sep 17 00:00:00 2001 +From: Ludovic Henry +Date: Mon, 5 Oct 2026 00:00:00 +0000 +Subject: [PATCH] pyproject: widen license-files to cover vendored libraries + +license-files = ["LICENSE"] is an explicit PEP 639 list with no glob +default behind it, so dropping a LICENSE. file at the project root +for a vendored C/C++ library compiled into the extension is silently +ignored. The vendored feature statically links libjxl (BSD-3-Clause, +with its own PATENTS grant), highway (Apache-2.0 / BSD-3-Clause dual +licence), brotli (MIT) and skcms (BSD-3-Clause), yet only the plugin's +own GPL-3.0-or-later LICENSE lands in dist-info/licenses. Widen the +glob so the build can stage those files under matching names. + +Upstream-Status: Inappropriate [this build vendors libjxl/highway/brotli/skcms from source via the vendored cargo feature; upstream's own release wheels either dynamically link a system libjxl or statically link it without staging the dependencies' licence files either, so this is specific to how this port assembles the wheel] +--- + pyproject.toml | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/pyproject.toml b/pyproject.toml +index c9393dd..21e6580 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -11,7 +11,7 @@ authors = [ + description = "Pillow plugin for JPEG-XL, using Rust for bindings." + readme = "README.md" + license = "GPL-3.0-or-later" +-license-files = ["LICENSE"] ++license-files = ["LICENSE", "LICENSE.*", "PATENTS.*"] + classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", +-- +2.43.0 + From e004f0137e7e948d8d5f2f19d6504c997d40a13a Mon Sep 17 00:00:00 2001 From: Ludovic Henry Date: Mon, 5 Oct 2026 21:08:31 +0000 Subject: [PATCH 2/2] pillow-jxl-plugin: force the prebuilt Pillow wheel for cp310/cp311 tests Our registry only carries Pillow up to 11.3.0 for cp310 and 12.2.0 for cp311 (Pillow 12.0 dropped cp310, 12.3.0 dropped cp311). Without PIP_ONLY_BINARY=pillow, pip's resolver prefers the newest Pillow sdist visible across both indexes over our older riscv64 wheel, and that sdist then fails to build for lack of libjpeg headers in the bare test venv. --- .github/workflows/build-pillow-jxl-plugin.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/build-pillow-jxl-plugin.yml b/.github/workflows/build-pillow-jxl-plugin.yml index c1b3da8e86d..065ec2541d4 100644 --- a/.github/workflows/build-pillow-jxl-plugin.yml +++ b/.github/workflows/build-pillow-jxl-plugin.yml @@ -99,9 +99,16 @@ jobs: cp "$JXL_SRC/libjxl/third_party/highway/LICENSE" {package}/LICENSE.highway && cp "$JXL_SRC/libjxl/third_party/brotli/LICENSE" {package}/LICENSE.brotli && cp "$JXL_SRC/libjxl/third_party/skcms/LICENSE" {package}/LICENSE.skcms + # PIP_ONLY_BINARY=pillow: cp310/cp311 only have an 11.3.0/12.2.0 + # riscv64 Pillow wheel on our registry (Pillow 12.0+ dropped + # cp310, 12.3.0 dropped cp311); without this, pip's resolver + # prefers the newest Pillow sdist visible on the combined + # index over the older prebuilt wheel, and that sdist then + # fails to build for lack of libjpeg headers in the test venv. CIBW_ENVIRONMENT_LINUX: >- PATH="$PATH:$HOME/.cargo/bin" PIP_EXTRA_INDEX_URL=https://pypi.riseproject.dev/simple/ + PIP_ONLY_BINARY=pillow CIBW_TEST_REQUIRES: pytest numpy CIBW_TEST_SOURCES: test # test/test_plugin.py's OpenEXR/pyexiv2-gated tests are patched to