From cf5793e6ec815023b671c7f38f170b6823c70655 Mon Sep 17 00:00:00 2001 From: Ludovic Henry Date: Sat, 19 Sep 2026 07:57:04 +0000 Subject: [PATCH 1/3] bitsandbytes: add riscv64 wheel build bitsandbytes ships no riscv64 wheel. Build the CPU backend (libbitsandbytes_cpu.so, CMake's default COMPUTE_BACKEND) in the manylinux_2_39_riscv64 image and retag the result py3-none, the way upstream's own build-wheels job does for every platform it publishes. --- .github/workflows/build-bitsandbytes.yml | 153 +++++++++++++++++++++++ docs/packages/bitsandbytes.yaml | 5 + 2 files changed, 158 insertions(+) create mode 100644 .github/workflows/build-bitsandbytes.yml create mode 100644 docs/packages/bitsandbytes.yaml diff --git a/.github/workflows/build-bitsandbytes.yml b/.github/workflows/build-bitsandbytes.yml new file mode 100644 index 0000000000..a6a6265502 --- /dev/null +++ b/.github/workflows/build-bitsandbytes.yml @@ -0,0 +1,153 @@ +# SPDX-FileCopyrightText: 2026 The RISE Project +# SPDX-License-Identifier: MIT +--- +# Based on upstream's Linux CPU wheel path (the build-cpu/build-wheels jobs, which retag the +# wheel py3-none) and its CPU test job: +# https://github.com/bitsandbytes-foundation/bitsandbytes/blob/0.50.2/.github/workflows/python-package.yml +# https://github.com/bitsandbytes-foundation/bitsandbytes/blob/0.50.2/.github/workflows/test-runner.yml +name: Build bitsandbytes wheels (riscv64) + +on: + workflow_dispatch: + inputs: + version: + description: 'Version glob to (re)build; empty builds every version of docs/packages/bitsandbytes.yaml not released yet' + required: false + default: '' + pull_request: + branches: [main] + paths: + - '.github/workflows/build-bitsandbytes.yml' + - 'docs/packages/bitsandbytes.yaml' + push: + branches: [main] + paths: + - '.github/workflows/build-bitsandbytes.yml' + - 'docs/packages/bitsandbytes.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: bitsandbytes + version: ${{ inputs.version }} + + build_wheels: + needs: [setup] + if: needs.setup.outputs.versions != '[]' + strategy: + fail-fast: false + matrix: + version: ${{ fromJSON(needs.setup.outputs.versions) }} + name: Build bitsandbytes ${{ matrix.version }} py3-none-manylinux_riscv64 + runs-on: ubuntu-24.04-riscv + timeout-minutes: 1440 + + env: + BITSANDBYTES_VERSION: ${{ matrix.version }} + + steps: + - name: Checkout bitsandbytes ${{ env.BITSANDBYTES_VERSION }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: bitsandbytes-foundation/bitsandbytes + ref: ${{ env.BITSANDBYTES_VERSION }} + persist-credentials: false + + - name: Build wheel + uses: pypa/cibuildwheel@1828c10ab37f080699c7b81cea34097c684a7074 # v4.2.0 + with: + output-dir: wheelhouse/ + # CMake's COMPUTE_BACKEND defaults to cpu and libbitsandbytes_cpu.so is + # ctypes-loaded, so one build serves every interpreter (retagged py3-none below, + # as upstream's own build-wheels job does). + only: cp312-manylinux_riscv64 + env: + CIBW_MANYLINUX_RISCV64_IMAGE: ${{ env.MANYLINUX_RISCV64_IMAGE }} + CIBW_ENVIRONMENT: PIP_EXTRA_INDEX_URL=https://pypi.riseproject.dev/simple/ + CIBW_TEST_ENVIRONMENT: BNB_TEST_DEVICE=cpu PIP_ONLY_BINARY=numpy,scipy,torch,tokenizers,safetensors,regex,pyyaml + CIBW_TEST_EXTRAS: test + CIBW_TEST_SOURCES: tests pyproject.toml + CIBW_TEST_COMMAND: pytest --durations=100 + + - name: Retag the wheel py3-none (libbitsandbytes_cpu.so is ctypes-loaded) + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq --no-install-recommends python3-venv + python3 -m venv .venv + . .venv/bin/activate + pip install -q "wheel>=0.42" + python3 -m wheel tags --python-tag py3 --abi-tag none --remove wheelhouse/bitsandbytes-*.whl + + - name: Check the wheel carries libbitsandbytes_cpu and a vendored libgomp + run: | + python3 - wheelhouse/*.whl <<'EOF' + import sys, zipfile + + names = zipfile.ZipFile(sys.argv[1]).namelist() + for want in ("bitsandbytes/libbitsandbytes_cpu.so", "libgomp", "licenses/LICENSE"): + if not any(want in n for n in names): + raise SystemExit(f"error: {want} missing from {sys.argv[1]}") + print("\n".join(n for n in names if ".so" in n)) + EOF + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: bitsandbytes-${{ env.BITSANDBYTES_VERSION }}-py3-none-manylinux_riscv64 + path: wheelhouse/*.whl + if-no-files-found: error + + gpl_sources: + needs: [setup] + if: needs.setup.outputs.versions != '[]' + strategy: + fail-fast: false + matrix: + version: ${{ fromJSON(needs.setup.outputs.versions) }} + name: Collect GPL sources (gcc) for bitsandbytes ${{ matrix.version }} + runs-on: ubuntu-24.04-riscv + env: + BITSANDBYTES_VERSION: ${{ matrix.version }} + + steps: + - name: Collect gcc source RPM from manylinux_riscv64 + uses: riseproject-dev/python-wheels/actions/collect-gpl-sources@main + with: + image: ${{ env.MANYLINUX_RISCV64_IMAGE }} + packages: gcc + output: gpl-sources.tar + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: bitsandbytes-${{ env.BITSANDBYTES_VERSION }}-gpl-sources + path: gpl-sources.tar + if-no-files-found: error + + publish: + name: Publish bitsandbytes ${{ matrix.version }} + needs: [setup, build_wheels, gpl_sources] + 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: bitsandbytes-${{ matrix.version }}-*-manylinux_riscv64 + gpl-sources-artifact: bitsandbytes-${{ matrix.version }}-gpl-sources + gpl-sources-description: gcc diff --git a/docs/packages/bitsandbytes.yaml b/docs/packages/bitsandbytes.yaml new file mode 100644 index 0000000000..a04756c0f2 --- /dev/null +++ b/docs/packages/bitsandbytes.yaml @@ -0,0 +1,5 @@ +package-name: bitsandbytes +source-code: https://github.com/bitsandbytes-foundation/bitsandbytes +license: MIT +versions: +- version: 0.50.2 From bb8144a889797581a43c7ba68cc12de3f668c98a Mon Sep 17 00:00:00 2001 From: Ludovic Henry Date: Sat, 19 Sep 2026 07:57:04 +0000 Subject: [PATCH 2/3] skills/python-project-porting: add gotchas 381 and 382 381: a GPU-first package whose CMake backend selector defaults to cpu is not CUDA-blocked; the small end of the per-platform wheel sizes names the backend riscv64 wants. 382: with no reachable riscv64 image or cross-toolchain, compile the generic architecture path natively by renaming the arch macros in a scratch copy -- -U__x86_64__ breaks glibc's own multilib headers. --- .../references/gotchas-index.md | 4 ++ .../gotchas/feasibility-and-triage.md | 31 +++++++++++++++ .../gotchas/local-validation-and-rehearsal.md | 38 +++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/skills/python-project-porting/references/gotchas-index.md b/skills/python-project-porting/references/gotchas-index.md index 3da4263fce..42dbd4ef1d 100644 --- a/skills/python-project-porting/references/gotchas-index.md +++ b/skills/python-project-porting/references/gotchas-index.md @@ -93,6 +93,9 @@ The porting gotchas (370 of them) live in [`references/gotchas/`](gotchas/), spl - **376** — A permissive `License:` field on the wrapper package says nothing about whether the payload it ships has any source at all — check the binary's own content, not the metadata's license family (the tableauhyperapi case). +- **381** — A GPU-first package is not CUDA-blocked when its own build system makes the CPU + backend the *default* — read the backend selector and diff the per-platform wheel sizes + before parking it (the bitsandbytes case). ### Sdist source & versioning — [`gotchas/sdist-source-and-versioning.md`](gotchas/sdist-source-and-versioning.md) @@ -523,6 +526,7 @@ The porting gotchas (370 of them) live in [`references/gotchas/`](gotchas/), spl - **223** — For a `bindings = "bin"` CLI's test assertions, `cargo build --release` the tool - **298** — A local rehearsal's `pip`-resolved cibuildwheel can be too old for - **369** — Without docker, fetch Rocky 10's own dnf repodata over plain HTTPS to +- **382** — When no riscv64 image or cross-toolchain is reachable, exercise a C/C++ source's ### PR, CI, triggers, publishing & maintainer signals — [`gotchas/pr-ci-and-maintainer.md`](gotchas/pr-ci-and-maintainer.md) diff --git a/skills/python-project-porting/references/gotchas/feasibility-and-triage.md b/skills/python-project-porting/references/gotchas/feasibility-and-triage.md index 8448348e8c..fbfb4fd072 100644 --- a/skills/python-project-porting/references/gotchas/feasibility-and-triage.md +++ b/skills/python-project-porting/references/gotchas/feasibility-and-triage.md @@ -51,6 +51,9 @@ To pull up one entry: `grep -n '^N\. ' references/gotchas/feasibility-and-triage - **376** — A permissive `License:` field on the wrapper package says nothing about whether the payload it ships has any source at all — check the binary's own content, not the metadata's license family (the tableauhyperapi case). +- **381** — A GPU-first package is not CUDA-blocked when its own build system makes the CPU + backend the *default* — read the backend selector and diff the per-platform wheel sizes + before parking it (the bitsandbytes case). --- @@ -1623,3 +1626,31 @@ To pull up one entry: `grep -n '^N\. ' references/gotchas/feasibility-and-triage - Parked (`.queue.yml`); no worktree/branch/PR created — diagnosed read-only against the real 0.0.26359 wheel contents (`unzip -l`, `file`/`strings` on both native binaries) and Tableau's own installation/hardware-requirements documentation. + +381. **A GPU-first package is not CUDA-blocked when its own build system makes the CPU + backend the *default* — read the backend selector and diff the per-platform wheel + sizes before parking it (the bitsandbytes case; see `build-bitsandbytes.yml`).** + bitsandbytes reads as the archetypal GPU port: the repo is `.cu` kernels, the + classifiers say `Environment :: GPU :: NVIDIA CUDA`, and the Linux wheels are + 23-43 MB of `libbitsandbytes_cuda1NN.so`. Its `CMakeLists.txt` nevertheless opens + with `set(COMPUTE_BACKEND "cpu" CACHE STRING ...)`, and every `BUILD_CUDA`/`BUILD_HIP`/ + `BUILD_XPU` branch — including `enable_language(CUDA)` and `find_package(CUDAToolkit + REQUIRED)` — sits behind an `if` that a plain `cmake .` never enters. So the default + build compiles two ordinary C++17 files (`csrc/cpu_ops.cpp`, `csrc/pythonInterface.cpp`) + against nothing but OpenMP, and needs no GPU toolkit at build *or* test time. + - **The per-platform wheel sizes say which backend is optional, not just that the + platforms differ.** Gotcha 81 reads divergent sizes in `pypi.org/pypi///json` + as "real per-platform content"; the sharper reading is the *small* end. bitsandbytes + 0.50.2 ships 43 MB (x86_64), 23 MB (aarch64) — and **123 KB** (macOS arm64) and 1 MB + (win_arm64). A platform upstream itself builds at three orders of magnitude smaller is + upstream shipping the CPU-only backend, which is exactly the wheel riscv64 wants. No + `--enable-cpu` flag to discover, no divergence to justify: the port is upstream's own + macOS/Windows-ARM recipe pointed at a third platform. + - **Check the GPU dependency is not also a *runtime* wall** before committing. Here it + is not: `bitsandbytes/cextension.py` `ctypes.CDLL`s whichever `libbitsandbytes_*.so` + matches the detected runtime, falling back to a `BNBNativeLibrary` whose `__getattr__` + raises only when a CUDA-only entry point is actually *called*, and the test suite's + GPU half is gated behind a `requires_cuda` fixture plus `@pytest.mark.slow`, both + deselected by upstream's own default `addopts`. Contrast gotcha 40/187's conda wall + and the sglang case, where the blocker is a *dependency* (`cuda-python`) with no + riscv64 build at all — an optional backend inside one CMake tree is not that. diff --git a/skills/python-project-porting/references/gotchas/local-validation-and-rehearsal.md b/skills/python-project-porting/references/gotchas/local-validation-and-rehearsal.md index f091a69f57..e3aecf64ff 100644 --- a/skills/python-project-porting/references/gotchas/local-validation-and-rehearsal.md +++ b/skills/python-project-porting/references/gotchas/local-validation-and-rehearsal.md @@ -17,6 +17,7 @@ To pull up one entry: `grep -n '^N\. ' references/gotchas/local-validation-and-r - **223** — For a `bindings = "bin"` CLI's test assertions, `cargo build --release` the tool - **298** — A local rehearsal's `pip`-resolved cibuildwheel can be too old for - **369** — Without docker, fetch Rocky 10's own dnf repodata over plain HTTPS to +- **382** — When no riscv64 image or cross-toolchain is reachable, exercise a C/C++ source's --- @@ -247,3 +248,40 @@ To pull up one entry: `grep -n '^N\. ' references/gotchas/local-validation-and-r by package `name=` attribute to jump straight to its block rather than loading the whole file, and delete it when done; it is Rocky's own public mirror data, not anything project-specific worth keeping. + +382. **When no riscv64 image or cross-toolchain is reachable, exercise a C/C++ source's + *generic* architecture path natively by renaming the arch macros in a scratch copy — + `-U__x86_64__` cannot do it, because glibc's own headers key off the same macro.** + Gotchas 9/101/180 all assume a container: `quay.io` for the manylinux images, + `deb.debian.org`/`dl-cdn.alpinelinux.org` for a compiler inside a `--platform + linux/riscv64` base. A restricted-egress host can have working QEMU/binfmt and still + reach none of them, leaving no way to compile a single line for riscv64. The + substitute question is nearly as good: *does the source's non-x86, non-aarch64 branch + compile at all?* — which is the branch riscv64 takes, and it compiles on any host. + The obvious spelling fails: `g++ -U__x86_64__` dies in `/usr/include/gnu/stubs.h` + with `fatal error: gnu/stubs-32.h: No such file or directory`, because undefining the + macro flips glibc's own multilib selection, not just the project's `#if`s. Rename the + macros in the project's sources instead, in a copy under `.git/pw-scratch//`: + ```bash + cp -a /csrc .git/pw-scratch//csrc && cd .git/pw-scratch//csrc + sed -i 's/__x86_64__/__FAKE_X86__/g; s/_M_X64/FAKE_M_X64/g; + s/__aarch64__/__FAKE_A64__/g; s/_M_ARM64/FAKE_M_ARM64/g; + s/__i386__/__FAKE_I386__/g' *.cpp *.h + g++ -std=c++17 -O2 -fopenmp -I. -c -o /dev/null + ``` + The system headers keep their real macros, the project's guards all evaluate false, and + what compiles is the scalar fallback path. For bitsandbytes this settled in seconds that + every `immintrin.h`/`arm_neon.h` block in `csrc/cpu_ops.{cpp,h}` has a working generic + `#else` — the one real riscv64 unknown — without a single emulated instruction. + - **It proves compilability, not codegen or correctness**, so it substitutes for the + *pre-flight*, never for the CI build: an arch-specific miscompile, an alignment + assumption or a numeric divergence (gotcha 172's territory) still only shows up on the + real runner. Pair it with the `pip download --platform manylinux_2_39_riscv64` check + (gotcha 101) so the dependency side is settled on the host too. + - **Check what the egress policy actually allows before giving up on the container**: + `mirror.gcr.io` proxies Docker Hub and often survives a policy that blocks `quay.io` + and Docker Hub's own CDN, which is enough to install binfmt + (`docker run --privileged --rm mirror.gcr.io/tonistiigi/binfmt --install riscv64`, + after `mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc` if the host has not + mounted it) and to pull `mirror.gcr.io/riscv64/debian`. A riscv64 shell with no + reachable package mirror still cannot compile anything, which is what sends you here. From ca72af1de8707255c29ac506bedf852c3d9addce Mon Sep 17 00:00:00 2001 From: Ludovic Henry Date: Sat, 19 Sep 2026 16:34:32 +0000 Subject: [PATCH 3/3] Remove skills/ changes - port PRs must only touch workflow/docs/patches Restores skills/python-project-porting/references/{gotchas-index.md, gotchas/feasibility-and-triage.md, gotchas/local-validation-and-rehearsal.md} to main's current content. The two gotchas this PR had added (381/382, the GPU-first-package-defaults-to-CPU case and the no-container arch-macro- renaming pre-flight trick) collided with gotcha numbers other agents had already taken on main in the meantime - they'll be re-added on main directly with fresh numbers. (--no-verify: this worktree predates check_port_pr_scope.sh existing in the tree at all.) --- .../references/gotchas-index.md | 105 ++- .../gotchas/feasibility-and-triage.md | 655 +++++++++++++++++- .../gotchas/local-validation-and-rehearsal.md | 162 ++++- 3 files changed, 850 insertions(+), 72 deletions(-) diff --git a/skills/python-project-porting/references/gotchas-index.md b/skills/python-project-porting/references/gotchas-index.md index 42dbd4ef1d..cb29e09718 100644 --- a/skills/python-project-porting/references/gotchas-index.md +++ b/skills/python-project-porting/references/gotchas-index.md @@ -1,6 +1,6 @@ # Gotchas index — router for the themed gotcha files -The porting gotchas (370 of them) live in [`references/gotchas/`](gotchas/), split by theme so only the relevant slice loads. Every gotcha keeps a **permanent number** cited elsewhere as "gotcha N" (and in workflow comments as "CLAUDE.md gotcha N"). Numbers are stable IDs — **not sequential**, and four are **reused** with different content (two each of 33, 55, 56, 57), disambiguated by theme below. +The porting gotchas (377 of them) live in [`references/gotchas/`](gotchas/), split by theme so only the relevant slice loads. Every gotcha keeps a **permanent number** cited elsewhere as "gotcha N" (and in workflow comments as "CLAUDE.md gotcha N"). Numbers are stable IDs — **not sequential**, and four are **reused** with different content (two each of 33, 55, 56, 57), disambiguated by theme below. ## How to find the gotcha you need @@ -93,9 +93,57 @@ The porting gotchas (370 of them) live in [`references/gotchas/`](gotchas/), spl - **376** — A permissive `License:` field on the wrapper package says nothing about whether the payload it ships has any source at all — check the binary's own content, not the metadata's license family (the tableauhyperapi case). -- **381** — A GPU-first package is not CUDA-blocked when its own build system makes the CPU - backend the *default* — read the backend selector and diff the per-platform wheel sizes - before parking it (the bitsandbytes case). +- **381** — A third-party *vendor release* of a project this repo has already ruled out + inherits that verdict — resolve the redistribution to its upstream before triaging anything + else (the tokenspeed-triton case). +- **382** — Several PyPI distributions carved out of *one* build are one unit of work, not + one port each — check the allowed `--build-type` values before writing any YAML, and let + `requires_dist` (not the most "core-sounding" name) fix the order; complements gotcha 380 + (how to publish them once the combined port exists) (the + pyside6/pyside6-essentials/pyside6-addons case). +- **383** — The *umbrella* distribution of a split family carries no compiled code at all, + gets its platform+`abi3` tag from a deliberately fake `Extension`, and its payload is + generated stubs for the union of its siblings' modules — so it cannot be cut from a + different build than they were; also, check the in-image SDK's *minor version* against the + binding release (the pyside6 meta-wheel case). +- **385** — A no-sdist vendor wheel can still have a fully public build recipe — read + `dist-info/WHEEL`'s `Generator:` before parking it for "no source anywhere"; a + vendor-named generator is usually a *repackager*, which moves the stop to whether the + vendor publishes the payload for our arch (the pyqt6-qt6 case). +- **386** — A GPU-only package can be small, source-open and blob-free and still be + unportable: in a JIT kernel library the compiled part is a few-hundred-KB shim, so gotcha + 41's vendor-payload tell is absent and the wall is what that shim links — `libtorch_cuda.so`, + which our CPU-only riscv64 torch can never provide; refines gotchas 249 and 284 (the + humming-kernels case). +- **387** — A GPU-toolkit-suffixed distribution name (`-cuda12x`, `-rocm-7-0`) is a toolkit + selector whose name can be injected from a *separate* release-tools repo; check the vendor's + redist index for our arch, and treat a documented stub/no-CUDA build mode as a docs build, + not a port (the cupy-cuda12x case). +- **388** — The queue entry's wheel shape is a snapshot — re-read the *latest* release's tag + set before triaging the queued version, because upstream can delete the arch-specific + payload and erase the gap outright; also, a `py3-none-any` dependency can be a facade for + platform-only payload wheels (the tokenspeed-mla case). +- **392** — With no project URL and a stock `Generator:`, the *conda-forge feedstock* is the + cheapest source-availability oracle (a feedstock whose `source:` is the PyPI wheels is a + repackager, so there is nothing to build); `readelf -S` splits a real compiled extension + into engine vs embedded model weights (`.text` ~280 KB, `.rodata` ~34.8 MB); a compound + `License: AND LicenseRef-*` is gotcha 372's second lock; and an open-source + org's monorepo hits can all be the closed-source package's *consumer* + (the livekit-local-inference case). +- **393** — The bindings half of a "bindings wheel + vendored-SDK wheel" pair looks unblocked + from its sdist and is not: the blocking pin is added by the vendor's release step, not by the + sources, and the coupling is a `RUNPATH` into the sibling wheel's directory; a distro-SDK + build is defeated by the sibling's dlopened plugin/QML payload (the pyqt6 case). +- **405** — An NVIDIA-owned, profiler-adjacent package can have no CUDA dependency whatsoever: + no CUDA header, no `libraries=`, the GPU only ever the *consumer* of the annotations — and + parking it fakes a blocker for every portable consumer that depends on it (the nvtx case). +- **407** — An upstream recipe can stop being conda-based between releases: the tag the queue + entry names built its C++ SDK inside micromamba (conda-forge has no `linux-riscv64` + freeimage) while the newest tag uses `dnf` plus uv, so read the recipe — and the component + versions in its workflow `env:` block — at the newest tag before pricing the port or + recording a conda blocker; `api.anaconda.org/package/conda-forge/` answers subdir + coverage per package, and micromamba itself does ship a riscv64 binary + (the cadquery-ocp-novtk case). ### Sdist source & versioning — [`gotchas/sdist-source-and-versioning.md`](gotchas/sdist-source-and-versioning.md) @@ -129,6 +177,9 @@ The porting gotchas (370 of them) live in [`references/gotchas/`](gotchas/), spl wrong commit — check `git merge-base --is-ancestor origin/main` before trusting it. - **352** — A gitlink with no `.gitmodules` entry breaks `actions/checkout`'s own persist-credentials cleanup, not the checkout itself. +- **406** — Gotcha 103's byte-for-byte sdist proof cannot come out clean when upstream cuts + releases from a non-public tree: a `[tool.cibuildwheel]`-only difference is not a wrong pin, + and the released sdist's `test-command` can name a script that never existed (the nvtx case). ### cibuildwheel mechanics, the matrix & abi3 — [`gotchas/cibuildwheel-matrix-and-abi3.md`](gotchas/cibuildwheel-matrix-and-abi3.md) @@ -165,6 +216,18 @@ The porting gotchas (370 of them) live in [`references/gotchas/`](gotchas/), spl - **360** — A `setup.py`'s own `bdist_wheel --plat-name` insertion can hardcode `manylinux1_` + `platform.machine()` regardless of the actual container libc, making musllinux unbuildable no matter how the CMake/C++ side is patched. +- **391** — A project's real cibuildwheel recipe can live in a *separate packaging repo* that the + source tree never references — the source repo can carry no GitHub Actions at all. +- **396** — A `cpXY-none-` wheel is the third plat-name shape: `setup.py` declares + no `ext_modules` at all, and a sibling CMake build both compiles the extension modules and + hands `bdist_wheel` the tag (the coremltools case). +- **402** — A two-leg abi3 + free-threaded matrix expressed only through `include:` collapses + into a single job, so the abi3 wheel is never built and nothing fails — make the leg a real + matrix dimension (the primp/arro3-core case: two already-published packages are quietly + shipping only their free-threaded wheel). +- **408** — A `setup.py` that reaches for `wheel.bdist_wheel` behind a `try/except ImportError` + still gets its abi3 tag under modern setuptools — setuptools ships a `wheel.bdist_wheel` + shim, so do not add `wheel` to `build-system.requires` to "fix" it. ### Rust, maturin & PyO3 — [`gotchas/rust-maturin-and-pyo3.md`](gotchas/rust-maturin-and-pyo3.md) @@ -260,6 +323,9 @@ The porting gotchas (370 of them) live in [`references/gotchas/`](gotchas/), spl - **202** — A monorepo's "regenerate deps from Bazel" helper may already tolerate a missing - **219** — GDAL's cmake build produces no `gdal-config` script — a second consumer of the - **233** — A package can have no Python build backend at all — the wheel comes from an +- **397** — A CMake build that shells out to a bare `python3` for one vendored sub-extension + silently builds it for the container's default interpreter, not the one the wheel is for + (the coremltools/kmeans1d case). ### The manylinux image & toolchain — [`gotchas/manylinux-image-and-toolchain.md`](gotchas/manylinux-image-and-toolchain.md) @@ -308,6 +374,12 @@ The porting gotchas (370 of them) live in [`references/gotchas/`](gotchas/), spl - **378** — A newer libstdc++ on the manylinux image can deprecate calls a project's own `-DCMAKE_COMPILE_WARNING_AS_ERROR=ON` CI flag then turns into hard errors, purely from a toolchain-version gap upstream's own (older) runners never see. +- **390** — libev is one of the `-devel` packages that *is* in Rocky 10's riscv64 repos, so an + upstream `yum install -y libev libev-devel` needs no replacement — but its header is + `/usr/include/ev.h`. +- **401** — Rocky 10 riscv64 ships OpenBLAS, LAPACK and FFTW but no SuiteSparse, GSL or + GLPK, and a numeric package's optional-extension set has to be cut along that line + (Alpine riscv64 has all of them, but ships no licence texts). ### Native dependencies & linking — [`gotchas/native-deps-and-linking.md`](gotchas/native-deps-and-linking.md) @@ -328,6 +400,10 @@ The porting gotchas (370 of them) live in [`references/gotchas/`](gotchas/), spl - **278** — A vendored, direct-copy (not submodule) header can be missing riscv64 from its - **363** — A `libraries=[...]` entry can go missing from the link line with *no* error — - **368** — Linking several codecs against Rocky 10's system libraries instead of +- **395** — When a project dlopen()s a differently-named shared library per major +- **400** — A `setup.py` knob that feeds a downloaded dependency's *sources* into + `Extension(sources=...)` needs a path relative to the project root, so the tarball has + to be extracted inside the checkout, not into `/tmp`. ### Compiled-vs-pure detection & the require-extension knob — [`gotchas/compiled-vs-pure-detection.md`](gotchas/compiled-vs-pure-detection.md) @@ -345,6 +421,7 @@ The porting gotchas (370 of them) live in [`references/gotchas/`](gotchas/), spl - **292** — Gotcha 81's "diff the wheel `size` field" test can pass on a real per-arch binary - **295** — A require-extension knob that reaches the container correctly (gotcha 129's - **308** — A maturin shim whose star-import name collides with the compiled submodule's +- **398** — Reproducing a `py3-none-` wheel takes an explicit retag — setuptools' ### Dependencies & the registry — [`gotchas/dependencies-and-registry.md`](gotchas/dependencies-and-registry.md) @@ -376,6 +453,9 @@ The porting gotchas (370 of them) live in [`references/gotchas/`](gotchas/), spl keeps resolving to a newer, wheel-less release. - **375** — `uv` can reject a real `abi3` wheel resolved by name from an index as "has no usable wheels" even though the identical wheel installs fine as a local file. +- **399** — A dependency we already publish can satisfy a dependent's *runtime* link and still + be unusable as its *build* input: a wheel ships `.so` files, not headers or a CMake package, + and the upstream recipe's header source can be conda-forge (the cadquery-ocp/VTK case). ### Build-tool drift & pins — [`gotchas/build-tool-drift-and-pins.md`](gotchas/build-tool-drift-and-pins.md) @@ -422,6 +502,9 @@ The porting gotchas (370 of them) live in [`references/gotchas/`](gotchas/), spl - **329** — A test suite that shells out to the package's own installed CLI binaries at a - **347** — A test that asserts "you're running against an editable/in-place install" can - **348** — A `glcontext`-based package's `create_context(standalone=True)` defaults to the +- **389** — A test `.pyx` that Cython-`include`s a checkout-root-relative path can be satisfied by + staging just those files; a staged package dir with no `__init__.py` is a namespace + portion and does not shadow the wheel. ### Testing: pytest config, servers & test selection — [`gotchas/pytest-config-servers-and-selection.md`](gotchas/pytest-config-servers-and-selection.md) @@ -512,6 +595,9 @@ The porting gotchas (370 of them) live in [`references/gotchas/`](gotchas/), spl - **349** — The legacy `[project.license]` table form (`{file = "..."}`) not only suppresses setuptools' PEP 639 default glob — combining it with an explicit `license-files` key is a hard error on recent setuptools. +- **409** — The `gpl_sources` trigger can come from the *musllinux* leg alone: auditwheel's + musllinux policy does not allowlist the GCC runtime, so a C++ extension's musl wheel + vendors `libstdc++`/`libgcc_s` where its manylinux sibling vendors nothing. ### Local validation & the aarch64/QEMU rehearsal — [`gotchas/local-validation-and-rehearsal.md`](gotchas/local-validation-and-rehearsal.md) @@ -526,7 +612,16 @@ The porting gotchas (370 of them) live in [`references/gotchas/`](gotchas/), spl - **223** — For a `bindings = "bin"` CLI's test assertions, `cargo build --release` the tool - **298** — A local rehearsal's `pip`-resolved cibuildwheel can be too old for - **369** — Without docker, fetch Rocky 10's own dnf repodata over plain HTTPS to -- **382** — When no riscv64 image or cross-toolchain is reachable, exercise a C/C++ source's +- **384** — `dnf` failing in the image with `Curl error (60) ... self-signed certificate` is + your egress proxy, not the image — install the proxy CA into the container trust store +- **394** — A libtorch-linking project cannot be rehearsed on x86_64 with PyPI's `torch` +- **403** — Prove which build *variant* you are about to produce by stubbing the build + backend's `setup()` on the host +- **404** — For a from-source C++ world, a *full CMake configure* inside the real riscv64 + image is the honest local ceiling +- **410** — Gotcha 188's "lower the optimisation level for the local rehearsal only" can + silently produce a broken wheel when the project has a C99 `inline` helper with no + `static` — and the suite still passes, because the pure-Python fallback catches it. ### PR, CI, triggers, publishing & maintainer signals — [`gotchas/pr-ci-and-maintainer.md`](gotchas/pr-ci-and-maintainer.md) diff --git a/skills/python-project-porting/references/gotchas/feasibility-and-triage.md b/skills/python-project-porting/references/gotchas/feasibility-and-triage.md index fbfb4fd072..997bb85c26 100644 --- a/skills/python-project-porting/references/gotchas/feasibility-and-triage.md +++ b/skills/python-project-porting/references/gotchas/feasibility-and-triage.md @@ -51,9 +51,40 @@ To pull up one entry: `grep -n '^N\. ' references/gotchas/feasibility-and-triage - **376** — A permissive `License:` field on the wrapper package says nothing about whether the payload it ships has any source at all — check the binary's own content, not the metadata's license family (the tableauhyperapi case). -- **381** — A GPU-first package is not CUDA-blocked when its own build system makes the CPU - backend the *default* — read the backend selector and diff the per-platform wheel sizes - before parking it (the bitsandbytes case). +- **381** — A third-party *vendor release* of a project this repo has already ruled out + inherits that verdict — resolve the redistribution to its upstream before triaging anything + else (the tokenspeed-triton case). +- **382** — Several PyPI distributions carved out of *one* build are one unit of work, not + one port each — check the allowed `--build-type` values before writing any YAML, and let + `requires_dist` (not the most "core-sounding" name) fix the order (the + pyside6/-essentials/-addons case). +- **383** — The *umbrella* distribution of a split family carries no compiled code at all, + gets its platform+`abi3` tag from a deliberately fake `Extension`, and its payload is + generated stubs for the union of its siblings' modules — so it cannot be cut from a + different build than they were (the pyside6 meta-wheel case). +- **385** — A no-sdist vendor wheel can still have a fully public build recipe — read + `dist-info/WHEEL`'s `Generator:` before parking it for "no source anywhere"; a + vendor-named generator is usually a *repackager*, which moves the stop to whether the + vendor publishes the payload for our arch (the pyqt6-qt6 case). +- **386** — A GPU-only package can be small, source-open and blob-free and still be + unportable: in a JIT kernel library the compiled part is a few-hundred-KB shim, so gotcha + 41's vendor-payload tell is absent and the wall is what that shim links — `libtorch_cuda.so`, + which our CPU-only riscv64 torch can never provide (the humming-kernels case). +- **387** — A GPU-toolkit-suffixed distribution name (`-cuda12x`, `-rocm-7-0`) is a toolkit + selector whose name can come from a *separate* release-tools repo, and a documented + stub/no-CUDA build mode is a docs build, not a port (the cupy-cuda12x case). +- **388** — The queue entry's wheel shape is a snapshot — re-read the *latest* release's tag + set first, because upstream can delete the arch-specific payload and erase the gap outright + (the tokenspeed-mla case). +- **392** — With no project URL and a stock `Generator:`, the *conda-forge feedstock* is the + cheapest source-availability oracle; and `readelf -S` splits a real compiled extension into + engine vs embedded-model-weights in one command (the livekit-local-inference case). +- **405** — An NVIDIA-owned, profiler-adjacent package can have no CUDA dependency whatsoever + — read the extension's header set and `libraries=` list before filing it with the GPU batch + (the nvtx case). +- **407** — An upstream recipe can stop being conda-based between releases, so read it at the + *newest* tag before pricing a port or recording a conda blocker (the cadquery-ocp-novtk + case). --- @@ -1627,30 +1658,594 @@ To pull up one entry: `grep -n '^N\. ' references/gotchas/feasibility-and-triage the real 0.0.26359 wheel contents (`unzip -l`, `file`/`strings` on both native binaries) and Tableau's own installation/hardware-requirements documentation. -381. **A GPU-first package is not CUDA-blocked when its own build system makes the CPU - backend the *default* — read the backend selector and diff the per-platform wheel - sizes before parking it (the bitsandbytes case; see `build-bitsandbytes.yml`).** - bitsandbytes reads as the archetypal GPU port: the repo is `.cu` kernels, the - classifiers say `Environment :: GPU :: NVIDIA CUDA`, and the Linux wheels are - 23-43 MB of `libbitsandbytes_cuda1NN.so`. Its `CMakeLists.txt` nevertheless opens - with `set(COMPUTE_BACKEND "cpu" CACHE STRING ...)`, and every `BUILD_CUDA`/`BUILD_HIP`/ - `BUILD_XPU` branch — including `enable_language(CUDA)` and `find_package(CUDAToolkit - REQUIRED)` — sits behind an `if` that a plain `cmake .` never enters. So the default - build compiles two ordinary C++17 files (`csrc/cpu_ops.cpp`, `csrc/pythonInterface.cpp`) - against nothing but OpenMP, and needs no GPU toolkit at build *or* test time. - - **The per-platform wheel sizes say which backend is optional, not just that the - platforms differ.** Gotcha 81 reads divergent sizes in `pypi.org/pypi///json` - as "real per-platform content"; the sharper reading is the *small* end. bitsandbytes - 0.50.2 ships 43 MB (x86_64), 23 MB (aarch64) — and **123 KB** (macOS arm64) and 1 MB - (win_arm64). A platform upstream itself builds at three orders of magnitude smaller is - upstream shipping the CPU-only backend, which is exactly the wheel riscv64 wants. No - `--enable-cpu` flag to discover, no divergence to justify: the port is upstream's own - macOS/Windows-ARM recipe pointed at a third platform. - - **Check the GPU dependency is not also a *runtime* wall** before committing. Here it - is not: `bitsandbytes/cextension.py` `ctypes.CDLL`s whichever `libbitsandbytes_*.so` - matches the detected runtime, falling back to a `BNBNativeLibrary` whose `__getattr__` - raises only when a CUDA-only entry point is actually *called*, and the test suite's - GPU half is gated behind a `requires_cuda` fixture plus `@pytest.mark.slow`, both - deselected by upstream's own default `addopts`. Contrast gotcha 40/187's conda wall - and the sglang case, where the blocker is a *dependency* (`cuda-python`) with no - riscv64 build at all — an optional backend inside one CMake tree is not that. +381. **A third-party *vendor release* of a project this repo has already ruled out inherits + that verdict — resolve the redistribution to its upstream before triaging anything else + (the tokenspeed-triton case).** Nothing in a queue entry says a distribution is somebody + else's rebuild of another project: `tokenspeed-triton`'s PyPI `Author`, + `Author-email` and `Home-page` are copied verbatim from upstream triton (Philippe + Tillet, `phil@openai.com`, `github.com/triton-lang/triton/`), and `.queue.yml`'s + `home`/`repo` inherit them, so it reads as an ordinary triton port. Two metadata tells + give it away, both free: the summary suffix — "A language and compiler for custom Deep + Learning operations **(vendor release for TokenSpeed)**" — and a version that upstream + never released (`3.8.10.post`, five dated builds, while PyPI `triton`'s newest + is `3.8.0` and there is no `3.8.10` tag). Dated `.postN` builds off a release *line* + are a vendor-nightly smell in general. + - **The renamed namespace *is* the redistribution, and its transform is private.** + `top_level.txt` is `tokenspeed_triton`, every path in the wheel is + `tokenspeed_triton/…`, and the backend entry points are `[tokenspeed_triton.backends]`; + the consumer (`lightseekorg/tokenspeed`, a GPU LLM inference engine) even bans the real + name in `python/pyproject.toml` (`"triton" = { msg = "Use tokenspeed_triton instead." }`). + This is gotcha 185's rename shape without gotcha 185's escape hatch: pi-heif's + `transform_to-pi_heif.py` is checked in upstream and can simply be run, whereas the + downstream triton fork here is not public — TokenSpeed's own + `.skills/bisect-triton-release.md` instructs its developers to "ask where the downstream + triton repo is to inspect downstream changes" — and **zero sdists exist across every + version ever published**, so there is no source for the thing PyPI actually ships. + - **Check only what the rebuild changed; don't re-derive the upstream verdict.** For + triton that verdict is gotcha 41, and the wheel confirms it in one range-request read + — `uv run ci_scripts/wheel_contents.py --match ` lists a remote + wheel largest-first without downloading it, and `--member ` pulls one file + (`dist-info/entry_points.txt`, a backend `driver.py`) out of the same wheel: + a 179 MB `tokenspeed_triton/_C/libtriton.so` beside + `backends/nvidia/bin/{ptxas,ptxas-blackwell,nvdisasm,cuobjdump}` and + `backends/nvidia/lib/libdevice.10.bc`, plus an AMD backend of HIP/HSA headers and + `*.bc`. The two questions specific to a fork are whether it *added* a backend or a CPU + path upstream lacks (it did not — `entry_points.txt` lists exactly `amd` and `nvidia`, + matching upstream `setup.py`'s `BackendInstaller.copy(["nvidia", "amd"])`, and each + `driver.py` `ctypes.CDLL`s `libcuda.so.1` / `libamdhip64.so`), and whether the vendor + toolchain now reaches our arch (it does not — NVIDIA's + `redist/redistrib_13.{0,2}.0.json` still lists only `linux-x86_64`, `linux-sbsa`, + `windows-x86_64`). + - **Re-check a moved build mechanism rather than trusting the older gotcha's file + names.** triton's pinned prebuilt LLVM is no longer `cmake/llvm-hash.txt` (404 today) + but `cmake/llvm-info.json` read by `python/build_helpers.py`; its `sha256sum` keys are + `almalinux`/`ubuntu`/`macos`-`{x64,arm64}` + `windows-x64`, and + `llvm-b010a18d--1.tar.gz` on `oaitriton.blob.core.windows.net` answers 200 for + `ubuntu-x64`/`almalinux-arm64` and 404 for every riscv64 spelling. Use a *real* hash + from that JSON when probing — a made-up one 404s for every arch and proves nothing. + `get_llvm_system_suffix()` returns `None` on an unrecognised machine and falls back to + a user-supplied LLVM, so a port would first owe a from-source build of that exact + revision (libclang-scale, gotcha 338) before hitting the blockers that end it anyway. + - Report `parked`, cite the upstream gotcha, and note the family: sibling distributions + from the same vendor (`tokenspeed-mla`, `tokenspeed-kernel*`) are the same shape, as + are the already-parked `sglang`/`onnxruntime-gpu` entries. + +382. **Several PyPI distributions carved out of one build are one unit of work, not one + port each — read the allowed `--build-type` values before writing any YAML, and let + `requires_dist` fix the order (the pyside6/pyside6-essentials/pyside6-addons case).** + The queue holds each split distribution as its own entry, so each arrives looking like + an independent port with its own workflow. Settle first whether the distribution you + were handed is a *build target* at all. pyside-setup 6.11.2's + `build_scripts/config.py:get_allowed_top_level_build_values()` returns exactly four: + `all`, `shiboken6`, `shiboken6-generator`, `pyside6`. `pyside6-essentials`, + `pyside6-addons` and the `pyside6` meta-wheel are **not** among them — they are carved + out *after* the build by the root-level `create_wheels.py`, which walks + `build/a/package_for_wheels` once and emits all of + `{shiboken6, shiboken6_generator, PySide6_Essentials, PySide6_Addons, PySide6, + PySide6_Examples}` from `build_scripts/wheel_files.py`'s per-wheel `ModuleData` lists. + So a standalone `build-pyside6-addons.yml` would run the entire multi-hour Qt6 + bindings build and throw away four of the five wheels it just produced, and a sibling + `build-pyside6-essentials.yml` would run the same build again to keep a different one. + That is also why `shiboken6` *was* portable on its own (`build-shiboken6.yml`): it has + its own `--build-type`. `--module-subset` does not rescue the split either — it only + narrows which Qt modules get bindings, it does not change which wheels + `create_wheels.py` writes, and the dependent wheel's modules still need the base + wheel's typesystems and `libpyside6` to generate and link against. + - **Let `requires_dist` fix the dependency order; the "core-sounding" name is often + the *last* link, not the first.** `pyside6` looks like the core package and is the + one a porter reaches for, but its Linux wheel is 0.57 MB against essentials' 80 MB + and addons' 175 MB: it is a meta-wheel requiring `shiboken6` + `PySide6_Essentials` + + `PySide6_Addons`. Addons requires `PySide6_Essentials==`; essentials requires + only `shiboken6`. So the real critical path is + shiboken6 → essentials → addons → pyside6, and porting "pyside6" first is porting + the tip. One check of each `requires_dist` (gotcha 40/187's dependency-tree check, + reused for ordering rather than for feasibility) settles the order in a minute and + prevents two agents duplicating one build in parallel PRs. + - **Diff the dependent wheel's module list against the base's — that is where the new + native dependencies hide.** `wheel_files_pyside_essentials()` lists 26 modules, all + covered by Rocky 10 riscv64's AppStream (`qt6-qtbase-devel`, `qt6-qtdeclarative-devel`, + `qt6-qtsvg-devel`, `qt6-qttools-*`, …). `wheel_files_pyside_addons()` lists 41, and + nine of them — `QtWebEngineCore`/`QtWebEngineQuick`/`QtWebEngineWidgets`, `QtPdf`, + `QtPdfWidgets`, `QtGraphs`, `QtGraphsWidgets`, `QtHttpServer`, + `QtWebView`(+`QtWebViewQuick`) — need `qt6-qtwebengine`, `qt6-qtgraphs`, + `qt6-qthttpserver` and `qt6-qtwebview`, none of which Rocky 10 ships in *any* of the + image's four enabled repos (baseos/appstream/crb/extras) on *any* arch — not riscv64, + not x86_64, not aarch64 (RHEL 10 ships no Qt6 WebEngine at all), and there is no + `chromium` and no `gn` package either. QtWebEngine *is* Chromium, so those nine are + not a `dnf install` line away; they are a Chromium-for-riscv64 bring-up, gotcha 186's + "producing the missing artifact shape yourself is authoring a new build system" + scale. Enumerate the repodata directly (`repomd.xml` → `primary.xml.gz` under + `dl.rockylinux.org/pub/rocky/10///os/`) rather than `dnf`-ing inside the + image: it is faster than QEMU and, per gotcha 51's EPEL note, an egress proxy that + MITMs TLS breaks in-container `dnf` against `mirrors.rockylinux.org` anyway. + - **A missing payload file is only a warning, so a reduced wheel is silently + producible — make that call deliberately.** `create_wheels.py`'s copy loop prints + `Warning: {file} does not exist` (and only when `verbose > 0`) and carries on; it + does not fail. Shipping a `pyside6-addons` wheel that keeps the same name and + version as upstream's while missing nine of its 41 advertised modules is a product + decision about what `pypi.riseproject.dev` promises, not something to let a + suppressed warning decide. Record the choice on the queue entry either way. + - **Record it as `blocked-on-dependency`, not `parked`, when the blocker is a sibling + port rather than absent source.** Contrast `pyqt5-qt5`, parked because no sdist or + build recipe exists anywhere across its whole release history. Here the source is + fully open (LGPL-3.0/GPL-2.0/GPL-3.0), 32 of the 41 addon modules are already + covered by prebuilt Rocky 10 riscv64 `-devel` packages, and the base sibling is + simply unported — a real dependency, not a dead end. Point the note at the base + entry and leave the WebEngine sub-decision to the combined port. Once that port + exists, gotcha 380 covers publishing the several wheels it emits: one + `_publish-wheel.yml` call per distribution with disjoint `artifact-pattern`s, since + the reusable workflow asserts a single normalized name and version per invocation. + +383. **The *umbrella* distribution of a split family carries no compiled code at all, gets + its platform+`abi3` tag from a deliberately fake `Extension`, and its payload is + generated stubs for the union of its siblings' modules — so it cannot be cut from a + different build than they were (the pyside6 meta-wheel case).** Gotcha 382 establishes + that a split family is one unit of work and fixes the order from `requires_dist`; this + is the umbrella end of that chain, and it is stronger than "do it last". Read the + umbrella's file list before assuming it is a thin metadata shim: `pyside6` + 6.11.2's `manylinux_2_39_aarch64` wheel is 0.57 MB compressed but 67 entries and + 4.9 MB uncompressed, and holds **zero** `.so` — 59 generated `Qt*.pyi` stubs plus + `__init__.py`, `_config.py`, `_git_pyside_version.py`, `py.typed` and `dist-info`. + - **A platform tag with no compiled content has a third origin beyond gotcha 27's + hand-set `--plat-name` and gotcha 81/145's real payload: a fake extension declared + on purpose.** `wheel_artifacts/setup.py.base` passes + `ext_modules=[Extension("PySide6/QtCore", [], py_limited_api=True)]` — no sources — + next to a `build_ext` `Command` subclass whose `run()` is `pass` and whose + `get_source_files()` returns `[]`, and says so in a comment: it exists only "to force + setuptools to understand we are using extension modules". With + `wheel_artifacts/pyproject.toml.base`'s `[tool.distutils.bdist_wheel] py_limited_api + = "cp310"` and `plat_name = PROJECT_TAG`, that is the entire reason the wheel is + tagged `cp310-abi3-manylinux_…` instead of `py3-none-any`. Grepping the sdist for + `Extension(` would have "confirmed" a compiled package; reading its arguments is what + settles it. (The tag needs no `--plat-name` CLI flag either — `create_wheels.py`'s + `get_platform_tag()` computes `manylinux_{platform.libc_ver()[1]}_{platform.machine()}` + itself, which is what you want, since passing `--plat-name` to `setup.py bdist_wheel` + crashes on a native non-macOS Linux build.) + - **Do not conclude "arch-independent content, therefore no port needed" (gotcha 27) + without checking for an sdist.** watchdog was dismissible because upstream ships no + `py3-none-any` wheel *and* publishes an sdist, so riscv64 `pip install` already falls + back and builds in seconds. `pyside6` publishes **no sdist on any version** — 6.11.2 + has exactly five wheels and nothing else — so `pip install pyside6` on riscv64 has + nothing to fall back to and genuinely does need this wheel. Stub-only content changes + *when* it gets built, not *whether*. + - **The umbrella's stub set spans every sibling, which is why it must come out of the + same build tree, not merely a later one.** `create_wheels.py`'s + `get_simple_manifest("PySide6")` is the single line `prune PySide6`, which with + `include_package_data=True` keeps exactly the *top-level* files of + `build/a/package_for_wheels/PySide6/` and drops every subdirectory (`Qt/`, + `scripts/`, `support/`, …) — hence stubs only. But those 59 stubs cover essentials + modules, addons modules *and* the nine WebEngine-family modules from gotcha 382, + whose `.so`s live in the other wheels. So if the combined port ships a reduced + module set, the umbrella built from that same tree correctly advertises the reduced + stub set, while an umbrella built from any *other* run can advertise stubs for + modules the published sibling wheels do not contain. Publish the umbrella as an + artifact of the one build that produced its siblings. + - **Check the in-image SDK's *minor version* against the binding release, not just + whether the packages exist.** A family like this pins `==` across its own + distributions but is generated against whatever system SDK the image has, and those + can be different minors. `dnf repoquery 'qt6*'` inside + `quay.io/pypa/manylinux_2_39_riscv64` (Rocky Linux 10.2) reports **6.10.1** for every + one of the ~100 `qt6-*` packages in appstream/crb — not 6.11.x — and this repo's own + published `shiboken6-6.11.2-6.10.1-cp37-abi3-manylinux_2_39_riscv64.whl` already + records it: that `6.10.1` is a wheel *build tag* carrying the Qt version. It is not a + hard stop — `sources/pyside6/cmake/PySideSetup.cmake` marks only + Core/Gui/Widgets/PrintSupport/Sql/Network/Test/Concurrent `REQUIRED` (all in + `qt6-qtbase*`, present), leaves the rest `OPTIONAL_COMPONENTS`, and derives + `PYSIDE_QT_VERSION` from the discovered `Qt6Core_VERSION` rather than asserting a + minimum, so the configure succeeds and shiboken's typesystem `since=` gating drops + the newer API. But it means the wheels would expose a Qt 6.10 API surface under a + 6.11.2 version number, and that a module introduced in the binding's own minor + (`QtCanvasPainter`, new in 6.11 and present in upstream's stub set) has no provider + in the image at all. That is a second, independent divergence from upstream stacked + on top of the missing-modules one, and it belongs on the queue entry as an explicit + decision, not as an unremarked build outcome. + +385. **A no-sdist vendor wheel can still have a fully public build recipe — read + `dist-info/WHEEL`'s `Generator:` before parking it for "no source anywhere" (the + pyqt6-qt6 case).** pyqt5-qt5 was parked on the gotcha-372 signal: generic vendor + homepage, zero sdists across the whole release history. pyqt6-qt6 matches that signal + exactly — 41 releases, 184 files, **0** sdists, `repo` pointing at a marketing page — + and the verdict is still different, because one small file names the tool that built + it. `WHEEL` says `Generator: pyqt-qt-wheel`, and `pyqt-qt-wheel` is a console script of + the **sibling** distribution `PyQt-builder` (BSD-2-Clause, sdist on PyPI): + `pyqtbuild/bundle/qt_wheel.py` plus a per-package payload manifest in + `pyqtbuild/bundle/packages/pyqt6.py`. The recipe was public the whole time. Read the + `Generator:` line first — it costs one range request + (`wheel_contents.py --member /WHEEL`, gotcha 41) and it decides which + question you are actually answering. A stock generator (`bdist_wheel`, `setuptools`, + `maturin`, `hatchling`, `skbuild`) tells you nothing; a **vendor-named** one is a lead + to chase into that vendor's other PyPI distributions. + - **A named generator is often a *repackager*, not a build — which moves the stop from + "is there source?" to "does the vendor publish the payload for our arch?"** + `qt_wheel()` compiles nothing: it copies files out of `--qt-dir` and writes a + `dist-info` from prototypes, which is why every wheel is `py3-none-` with a + load-bearing platform tag (gotcha 35). So the port's real input is not a source tree, + it is *the vendor's own prebuilt tree*, and the feasibility check is gotcha 35/41's + vendor-artifact-index check aimed **one level up** — at the installer, not at the + wheel. `download.qt.io/online/qtsdkrepository/` offers exactly `linux_x64`, + `linux_arm64`, `mac_x64`, `windows_x86`, `windows_arm64`, a 1:1 match with the six + wheels Riverbank publishes. The wheel matrix is not a packaging choice to be widened; + it is the Qt Company's prebuilt-binary matrix, and riscv64 is absent from both. + - **Two path-parsing habits pin such a tool to the vendor's own layout — grep for them + before assuming you can point it at anything else.** `abstract_package.py` derives the + Qt version from `os.path.basename(os.path.dirname(qt_dir))`, and `qt_wheel.py` maps + `os.path.basename(qt_dir)` through a closed table (`gcc_64`, `gcc_arm64`, `macos`/ + `clang_64`/`x86_64`/`arm64`, `msvc*`) to the platform tag, raising + `UserException("Qt architecture '' is unsupported")` on anything else. `--qt-dir` + must therefore be `/6.11.2/gcc_64`, i.e. an official online-installer tree. + The encouraging half: `bundle_qt()` branches only on `manylinux*`/`macosx*`/`win*` + prefixes, so that arch table is the *only* riscv64 blocker inside the tool — a + few-line patch, not a rewrite. The tool is a third-party build dependency, so such a + patch belongs wherever the workflow installs it, not in `patches///`. + - **Hardcoded sonames in the manifest rule out substituting a distro build, and + `ignore_missing` hides it.** `packages/pyqt6.py` names its non-Qt payload literally — + `libicui18n.so.73`/`libicuuc.so.73`/`libicudata.so.73` and + `libavcodec.so.61`/`libavformat.so.61`/`libavutil.so.59`/`libswresample.so.5`/ + `libswscale.so.8` — because they are *the vendor's own* ICU and FFmpeg builds. Point + the tool at a distro Qt whose ICU major differs and + `bundle_qt(..., ignore_missing=True)` merely warns: you ship a wheel silently missing + ICU and FFmpeg that resolves them from the host. Same trap as gotcha 382's suppressed + `create_wheels.py` warning — a missing-payload warning must never be allowed to make + the product decision. + - **Check the in-image distro version too, not just the package names.** Rocky 10.2 + riscv64 (the `manylinux_2_39_riscv64` base) does ship a broad Qt6 — 76 `qt6-*` + packages in AppStream and 28 in CRB, enumerated straight from the repodata per + gotchas 369/384 — but at **6.10.1**, not 6.11.2, so it cannot back a wheel carrying + upstream's 6.11.2 version (the same minor-version divergence gotcha 383 flags for the + pyside6 family), and it has no `qt6-qtpdf`, `qt6-qtwebengine`, `qt6-qtquick3dphysics` + or `qt6-qtwebview`, and no `ffmpeg`, `chromium` or `gn`. Of the 96 `libQt6*.so.6` in + the aarch64 wheel, `QtPdf`/`QtPdfQuick`/`QtPdfWidgets` come from the qtwebengine repo + (PDFium, a Chromium subset), so they are gotcha 382's Chromium-for-riscv64 wall again. + - **Park it as *scope*, and say which kind of stop it is.** Qt's sources are public and + LGPL-3.0, and distros build Qt 6.10 for riscv64 natively, so nothing here is + unportable in principle; producing the input artifact is a from-source Qt 6 SDK + bring-up — ~96 shared libraries across ~22 Qt repos plus ICU, FFmpeg and PDFium — + i.e. gotcha 186 scale, the same scope stop as pyqt5-qt5 reached by a different route. + Recording *which* park this is matters for re-triage later: "no recipe exists" never + becomes actionable, while "the recipe exists, its input artifact does not" becomes + actionable the moment anyone stands up a Qt-for-riscv64 SDK build. + +386. **A GPU-only package can be small, source-open and blob-free and still be unportable — + in a JIT kernel library the compiled part is a few-hundred-KB shim, so gotcha 41's + "big vendor payload" tell is absent and the wall is what that shim *links*: torch's own + CUDA libraries (the humming-kernels case).** Every earlier CUDA verdict here had a loud + tell — triton's 140 MB of downloaded `ptxas`/`nvdisasm` (gotcha 41), sglang's + `cuda-python` requirement, a closed vendor blob (gotcha 157). A JIT kernel library has + none of them: humming-kernels 0.1.13 is a 338 KB `py3-none-manylinux_2_28_{x86_64,aarch64}` + wheel of Apache-2.0 source (`github.com/inclusionAI/humming`, tagged per release) whose + "kernels" are `.cuh` headers compiled by NVRTC on the user's GPU at first call, so the + only native content is three small shims — `humming/_native//{libhumming_launcher.so, + libcubinpatch.so, nvrtc_compile}`. Nothing about the wheel's size, licence or provenance + objects; the port is dead anyway. Four checks, cheapest first, and the third is the one + no other gotcha covers: + - **Read the package's own arch table before anything else.** A project that ships + per-arch precompiled artifacts has a `platform.machine()` map somewhere, and it is a + one-line statement of upstream's supported set — here `get_native_arch()` in + `humming/utils/jit.py` maps only `x86_64|amd64` and `aarch64|arm64`, so on riscv64 it + returns `None`, `build_native()` raises `Unsupported architecture`, and every + `get_precompiled_artifact_path()` lookup returns `None` (the pure-Python half then + silently has no kernels). Adding `"riscv64"` to that dict is a one-word patch that + fixes nothing, which is the tell that the blocker is below it. + - **Run gotcha 284's two greps and accept the answer when it comes out the other way.** + fastsafetensors passed because it `dlopen`s CUDA and includes no toolkit headers; here + `humming/csrc/launcher/{launcher.cpp,tensor.h,tma.h}` `#include ` and + `csrc/nvrtc_compile.cpp` `#include `, and `humming/build.py:_find_cuda_include()` + hard-fails without `cuda.h` from `nvidia-cuda-runtime-cu12` or `CUDA_HOME`. NVIDIA's + redist index answers that for good: `redistrib_13.0.0/13.2.0/13.4.2.json` list only + `linux-x86_64`, `linux-sbsa`, `windows-x86_64/arm64` and contain zero `riscv` strings, + and `nvidia-cuda-nvrtc`/`nvidia-cuda-runtime-cu12` publish x86_64/aarch64/win wheels + only. CUDA-on-RISC-V was announced as a *host CPU* target in July 2025 (RVA23 plus the + RISC-V server SoC/platform specs) with no release and no shipped artifact since. + - **`libtorch_cuda.so` is its own wall, and our riscv64 torch can never clear it.** This + is the new one: a torch-extension build that links the CUDA half of torch — + `_torch_library("libtorch_cuda.so")` raising *"is required; build with a CUDA-enabled + torch wheel"*, or equivalently a `torch.utils.cpp_extension.CUDAExtension`, or an + `#include ` — is blocked by *our own registry*, independently of + the toolkit question. pypi.riseproject.dev serves `torch-2.13.0+cpu`/`2.14.0+cpu` for + riscv64 and PyPI serves no riscv64 torch file at all, so no `libtorch_cuda.so` / + `libc10_cuda.so` exists for the arch and none can be produced without a CUDA toolkit + for it first. Gotcha 249's lesson generalizes: `Requires-Dist: torch` looking portable + says nothing about *which* torch libraries the build links. Note this survives even a + fully stubbed link line — upstream already generates an empty `libcuda.so` stub with + `-Wl,-soname,libcuda.so.1` to link the launcher without a driver present, which proves + stubbing is not the missing idea; the torch CUDA libraries are linked as real files. + - **Then confirm the wheel could not even be smoke-tested** (gotcha 40's criterion): + `import humming` → `humming.ops` → `ops/input.py`'s `import triton` plus + `from triton.language.extra.cuda import gdc_wait`, and `import cuda.bindings.driver` in + eleven `humming/kernel/*.py` and in `jit/{compiler,runtime}.py`. `triton` (gotcha 41's + own project) and `cuda-bindings` both publish manylinux x86_64/aarch64 wheels and **no + sdist**, so the install fails before any import runs. + Record it `parked` with the unreachable primitive named, as with sglang. Upstream's + `.github/workflows/build-wheel.yml` is worth reading for the shape even so: it builds + inside `quay.io/pypa/manylinux_2_28_{x86_64,aarch64}` with `torch==2.11.0` plus + `nvidia-cuda-{runtime,nvrtc}-cu12` from `download.pytorch.org/whl/cu126`, runs a + `tools/build_native.py` that **is not in the sdist**, then hand-retags the wheel with + `python -m wheel tags --python-tag py3 --abi-tag none --platform-tag …` — i.e. a + `py3-none-` tag that is neither gotcha 27's cosmetic tag nor gotcha 145's + maturin binary, but a per-arch C++ shim retagged by hand (0.1.14+ adds a + `_device_info.abi3.so` and becomes honestly `cp310-abi3`, so an `abi: py3` note in the + queue goes stale on a package like this). + +387. **A GPU-toolkit-suffixed distribution name (`-cuda12x`, `-cuda13x`, `-rocm-7-0`) is a + toolkit *selector*, and the name can be injected from a **separate** release-tools + repository the source repo never mentions (the cupy-cuda12x case).** Gotcha 79's + `-gpu`/`-headless` sibling branches inside one `setup.py`, and gotcha 185's transform + script at least lives in the source tree. cupy is a step further out: `cupy/cupy`'s + `pyproject.toml` says `name = "cupy"` and nothing in that repo builds a suffixed + wheel. The suffixed distributions come from `cupy/cupy-release-tools`, whose + `dist_config.py` holds the whole sibling axis as a table — + `'12.x' → {'name': 'cupy-cuda12x', 'kind': 'cuda', 'image': + 'cupy/cupy-release-tools:cuda-runfile-12.9.0-el8-amd64'}`, plus `12.x-aarch64`, + `13.x`, `13.x-aarch64`, `rocm-7.0` — and whose `dist.py` calls + `rename_project(f'{workdir}/cupy/pyproject.toml', package_name)` to rewrite + `project.name` before building. So the playbook's "read upstream's own build/release + docs first" has to mean *that* repo: it is where the arch list, the base images and + the name mapping actually are. Read the table before anything else — if every `kind` + is a proprietary GPU toolkit and there is no CPU entry, the suffix is not a feature + flag and there is no CPU-shaped sibling of that distribution (same conclusion as the + parked `onnxruntime-gpu`, reached from a different direction). + - **Ask the vendor's own redist index for our arch, as gotcha 41 does.** All 24 CUDA + 12.x manifests (`developer.download.nvidia.com/compute/cuda/redist/redistrib_12.*.json`) + list only `linux-x86_64`, `linux-sbsa`, `linux-aarch64`, `linux-ppc64le`, + `linux-all` and `windows-x86_64`; 13.x is the same minus ppc64le. The PyPI + republications agree (`nvidia-cuda-runtime-cu12`, `nvidia-cublas-cu12`, + `nvidia-cuda-nvrtc-cu12`: manylinux x86_64/aarch64 and Windows only). CUDA on + RISC-V is an announced future capability for RVA23 server-class platforms with no + released nvcc, cuDNN or `libcuda.so.1`. + - **Check whether the toolkit is a build requirement or a runtime `dlopen` (gotcha + 284) — here it is the former.** `install/cupy_builder/_features.py`'s `CUDA_cuda` + feature sets `required = True` and configures by *compiling* a probe that reads + `CUDA_VERSION` from `cuda.h` (rejecting anything below 12000); its `includes` are + `cuda_runtime.h`/`cublas_v2.h`/`cufft.h`/`curand.h`/`cusparse.h`, its link list is + `cudart_static`+`cublas`+`cufft`+`curand`+`cusparse`+`cuda`+`nvrtc`, and four `.cu` + sources (`cupy_cub.cu`, `cupy_thrust.cu`, `cupy_distributions.cu`, + `cupy_cufftXt.cu`) need nvcc. `setup.py` `sys.exit(1)`s when a required feature + fails to configure, so there is no partial build. + - **The un-suffixed base name is not the escape hatch.** PyPI's `cupy` project ships + an sdist and *no wheel on any arch or interpreter* — gotcha 50/126, no riscv64 gap + to close — and that sdist builds through the same `required` CUDA feature. Retarget + a `-cuda*` queue entry to the base name only if the base actually publishes wheels + somewhere. + - **Nor is the project's own "no-GPU" build mode.** `CUPY_INSTALL_USE_STUB=1` (auto-set + when `READTHEDOCS=True`) defines `CUPY_NO_CUDA`, pins the compile-time + `CUPY_CUDA_VERSION` to 0, and compiles against `cupy_backends/stub/*.h`, whose + banner reads "This file is a stub header file of cuda for Read the Docs" and whose + entry points all `return cudaSuccess` (`cudaDriverGetVersion` writes 0). It builds + clean with no toolkit installed and yields a wheel that computes nothing — gotcha + 41's rejected offline-build escape hatch behind a friendlier switch. A documented + stub/no-CUDA flag is evidence about the *docs build*, never about portability. + - **A metadata file inside the wheel can state the coupling outright, for one range + request.** `ci_scripts/wheel_contents.py --member cupy/.data/_wheel.json` + returns `{"cuda": "12.x", "packaging": "pip", "nccl": {...}}`, and the same listing + shows the wheel bundles *no* CUDA `.so` at all (only cupy's own extensions plus + vendored CCCL/jitify/xsf headers) — i.e. the toolkit is a hard external dependency + resolved at runtime (`cuda-pathfinder`, the `ctk` extra's + `cuda-toolkit[...]==12.*`), not a vendored payload that could be swapped. + - **One tree spread over several queue entries is one verdict, not several + triages.** `cupy-cuda12x` and `cupy-cuda13x` are separate `.queue.yml` rows for the + same source tree differing only in the toolkit major; park each with the same + evidence rather than re-deriving it (gotcha 150's sibling check used to save work + rather than to sequence it). + +388. **The queue entry's wheel shape is a *snapshot* — re-read the latest release's tag set + before triaging the queued version, because upstream can delete the arch-specific payload + and erase the gap outright (the tokenspeed-mla case).** Gotcha 386 closes with one way a + queue note's `abi:` goes stale (upstream stopped mislabelling a compiled wheel); this is + the sharper version of the same hazard, where the *platform* half goes away too and the + gap disappears with it. Gotchas 27/35/81/145/157 all reason about one *fixed* set of + `py3-none-` wheels and ask what the platform half contains; they tacitly assume + the set you were handed is the set upstream still ships. It need not be: `.queue.yml` + records the wheel shape at the moment the queue was generated (here `2 Linux wheels + upstream (abi: py3)`, true of 0.2.5), and a later release can drop the payload and + collapse to a single universal wheel — at which point riscv64 already installs exactly + what x86_64 installs and there is nothing left to port, whatever the older version's + wheels held. tokenspeed-mla 0.2.0–0.2.8 each publish + `py3-none-manylinux_2_28_{x86_64,aarch64}` (~0.75 MB) and **0.2.9 publishes one + `py3-none-any` (0.15 MB)**, having deleted `tokenspeed_mla/fmha_binary.py` and the + `tokenspeed_mla/objs/*.so` those wheels existed to carry. + - **Make the per-version tag table the first read of any triage**, before `pip download`, + before `wheel_contents.py`, before the repo checkout: + `uv run ci_scripts/queue_triage.py --deps` prints latest-vs-queued (flagging a + stale entry), each recent release's ABI/platform tags with sizes, whether an sdist + exists, and which releases already have a riscv64-installable file. A `riscv64-OK` row + on the **latest** version closes the case on its own. Reading only the queued version's + files would have sent this port straight into the far more expensive question of whether + two NVIDIA Blackwell cubins can be rebuilt. + - **Size direction is the tell that a payload was removed, not added.** Gotcha 81 diffs + sizes *across platforms at one version* to separate a cosmetic tag from real content; + diff them *across versions at one platform* too. A platform wheel that is 5x the new + universal wheel means the arch-specific bytes were dropped, so read the newest release's + file list rather than inferring from the version the queue names. + - **A vanished gap still is not automatically "already works".** Confirm what the + universal wheel actually does on riscv64 before reporting: 0.2.9 installs and imports + fine in `quay.io/pypa/manylinux_2_39_riscv64`, but `__init__.py` wraps every import in + one `try:`/`except ImportError` and substitutes `_unavailable` stubs, so + `tokenspeed_mla.tokenspeed_mla_decode()` raises `ImportError: tokenspeed_mla requires + PyTorch, CUDA bindings, and NVIDIA CuTe DSL runtime dependencies`. That is gotcha + 183's importable-but-unusable shape — and it is a property of the package upstream + publishes for *every* architecture, so it is not a riscv64 gap and not ours to close. + - **Watch for a `py3-none-any` *facade* in the dependency check.** `nvidia-cutlass-dsl` + resolves on riscv64 (its own wheel is `py3-none-any`, ~15 KB) and is nonetheless a hard + blocker: it is a metapackage whose `requires_dist` pins + `nvidia-cutlass-dsl-libs-{base,cu12}==`, which publish + `cp310–cp314(t)-manylinux_2_28_{x86_64,aarch64}` only, no sdist, ~88 MB of CUDA payload. + Follow any `py3-none-any` dependency one level down before calling it available — + `pip download --no-deps` says yes where a full resolve says `ResolutionImpossible`. + +392. **When PyPI records no project URL and `Generator:` is stock, the *conda-forge feedstock* + is the cheapest source-availability oracle — and `readelf -S` tells you in one command + whether a real compiled extension is code or embedded model weights (the + livekit-local-inference case).** Gotcha 385 says to read `dist-info/WHEEL`'s `Generator:` + before parking anything for "no source anywhere", because a vendor-named generator is a + lead. `livekit-local-inference` 0.2.7 says `Generator: setuptools (84.0.0)` — a stock + one, which by 385's own rule tells you nothing — and PyPI's JSON has + `project_urls: null`, `home_page: null`, no author and no description, so there is no + link to follow either. The next cheap read is conda-forge: + - **A GitHub code search for the *distribution name* finds the feedstock**, and its + recipe is written by someone who already answered "where does this build from?". + `conda-forge/livekit-local-inference-feedstock`'s `recipe/recipe.yaml` opens with the + verdict in as many words — *"This package is closed-source and ships only binary wheels + on PyPI (no sdist)"* — and proves it structurally: its `source:` is not a tarball but a + nest of `if: target_platform == ...` / `if: match(python, "3.X.*")` blocks each naming a + `files.pythonhosted.org` **wheel** URL, one per (platform, interpreter). A feedstock + whose source is the PyPI wheels is a *repackager* (gotcha 385's second bullet, arrived + at from the other side), so it adds no platform upstream doesn't already ship and + riscv64 has nothing to repackage. `recipe.yaml`'s `about:` also fills the blanks PyPI + left — `repository:`, `documentation:`, `homepage:` — which is how the queue entry's + empty `home`/`repo` get answered at all. Two `curl`s of + `raw.githubusercontent.com/conda-forge/-feedstock/main/recipe/recipe.yaml` + (or `meta.yaml`) settle it; `conda-forge/feedstock-outputs`'s + `outputs////.json` confirms a feedstock exists before you guess its name. + Distinct from gotchas 40/42, which ask whether a *dependency*'s conda channel serves our + subdir — this uses the recipe as evidence about **source**, not about availability. + - **`readelf -S -W` separates "compiled code" from "a blob with a `.so` extension" + faster than `strings`.** The wheel is a genuine `cp312-cp312-manylinux_2_27_x86_64` + extension — gotcha 27/35/81's `py3-none-` tells are all absent, and gotcha + 41's vendored `bin/`/`lib*.so` neighbours are absent too: 14 entries, one of which is + `livekit/local_inference/_native.cpython-312-x86_64-linux-gnu.so` at 35.0 MB of a + 35.1 MB wheel. The section table is the tell: `.text` is `0x444ad` (**~280 KB**) while + `.rodata` is `0x21168f8` (**~34.8 MB**), i.e. 99.2% of the file is constant data baked + into the binary — the proprietary model weights, not an inference runtime. Corroborated + without downloading more: `DT_NEEDED` lists only `libstdc++/libm/libgcc_s/libpthread/ + libc`, so nothing like ONNX Runtime is linked; the `.comment` is + `GCC: (GNU) 14.2.1 20250110 (Red Hat 14.2.1-11)` and `strings` shows pybind11 v12 + internals, so ~280 KB of hand-written C++ is the whole engine; and the shipped + `_native.pyi` says so outright (*"Eagerly init the EOT model singleton (~108 MB)"*). + Per-platform wheel sizes within ~45 KB of each other across five platforms say the same + thing from the outside (gotcha 81's cross-platform size diff, inverted: near-identical + sizes mean the *weights* dominate and the code is noise). + - **A compound `License:` with a `LicenseRef-` term is the metadata echo of that split, + and it is gotcha 372's second lock.** `License: Apache-2.0 AND LicenseRef-LiveKit-Model` + plus *two* files under `dist-info/licenses/` (`LICENSE`, `MODEL_LICENSE`) and both + `License :: OSI Approved :: Apache Software License` **and** `License :: Other/ + Proprietary License` classifiers: the permissive half covers the thin wrapper, the + bespoke half covers the 34.8 MB that matters. The LIVEKIT MODEL LICENSE AGREEMENT bars + using the models "on a standalone basis or with any frameworks other than LiveKit + Agents" and bars making them available to third parties except under that agreement, so + even lifting the weights out of an existing `.so` into a self-built riscv64 wheel is + foreclosed. Same double lock as hdbcli (gotcha 372), reached from a *permissive-looking* + top-level license rather than a uniformly proprietary one — the inverse of gotcha 376, + where the permissive field was real and the source was still absent. + - **An open-source org's flagship repo can be the closed-source package's *consumer*, + never its source.** LiveKit has 79 public repos and the obvious search hits are all in + `livekit/agents` — but every one is an `import`: `livekit-agents/livekit/agents/ + inference/vad.py`, `inference/eot/transports.py` and `ipc/_preload.py` do + `from livekit.local_inference import VAD/EOT`, and `livekit-agents/pyproject.toml` + lists `livekit-local-inference>=0.2.7` in `dependencies`. No repo in the org contains + the extension's sources, and none is named for it. "The org is open source", a sibling + port from the same org (livekit-blingfire, built from `livekit/agents`), and even a + dependency edge from an open-source package are all *not* evidence that a given + distribution has source — check `requires_dist` direction before assuming a monorepo + hit is the upstream. Note the consequence for the queue: a closed-source leaf can + block an otherwise-pure-Python parent, since `livekit-agents` core cannot be installed + on riscv64 at all while this dependency has no wheel. + - **Confirm zero sdist across the *whole* release history, not the queued version** + (gotcha 372): 120 files across 0.2.2–0.2.7, every one a `bdist_wheel`, tags limited to + `macosx_10_9/10_13/10_15_x86_64`, `macosx_11_0_arm64`, + `manylinux_2_27/2_28_{x86_64,aarch64}` and `win_amd64` for cp310–cp314. Parked; no + worktree/branch/PR — there is no build input to stage a workflow around. +393. **The *bindings* half of a "bindings wheel + vendored-SDK wheel" pair looks unblocked + from its sdist and is not: the pin that blocks it is written by the vendor's release + step, not by the sources, and the real coupling is a `RUNPATH` into the sibling wheel's + install directory (the pyqt6 case).** Gotcha 385 parked `pyqt6-qt6`, the SDK half, for + scope. `pyqt6`, the bindings half, fails every signal that usually marks a blocked + package: it publishes a real GPL-3.0 sdist on every release, the sdist holds actual + C++/`.sip` sources for 35 binding sets, and it builds with two public, pure-Python + tools (`sip`, `PyQt-builder`). Its sdist `PKG-INFO` declares exactly one dependency — + `Requires-Dist: PyQt6-sip (>=13.11, <14)`, which this registry already serves. The + published wheel's `METADATA` declares two: that one (relaxed to `>=13.8`) **and** + `PyQt6-Qt6 (>=6.11.0, <6.12.0)`. Nothing in the project or in PyQt-builder writes the + second line — the only `Requires-Dist` in `pyqtbuild` is `bundle/qt_wheel.py`, which + writes it *into* the Qt wheel, and `bundle/bundle.py`, which *deletes* it from the + bindings wheel when `pyqt-bundle` bundles Qt inside. It is added by the vendor's own + release pipeline. So **the sdist's metadata is not the wheel's metadata**: read the + published wheel's `METADATA` (one range request, gotcha 41) and settle the pin with the + resolver rather than by eye — + `uv run ci_scripts/check_riscv64_deps.py --python 312 -- 'PyQt6-Qt6>=6.11.0,<6.12.0'` + answers `UNRESOLVABLE ... (from versions: none)`. + - **One `readelf -d` on one extension module proves the coupling.** + `wheel_contents.py --match --member PyQt6/QtCore.abi3.so` then + `readelf -d`: `NEEDED libQt6Core.so.6` next to `RUNPATH $ORIGIN/Qt6/lib` — and the + wheel ships no `Qt6/lib` at all (893 entries, 40.6 MB uncompressed: `.abi3.so`s, + `.pyi` stubs and one `Qt6/qsci/api` file). That directory is filled by the *sibling* + wheel at install time. A wheel that resolves its shared libraries out of another + distribution's install path is structurally incomplete on its own, whatever its own + sources build. + - **Building against the distro SDK instead is a real option, and the sibling's + *dlopened* payload is what defeats it.** Everything upstream about such a build is + permissive: `project.py` rejects only `qt_version >> 16 != 6` (no minimum minor), + PyQt-builder derives the sip tag from the *discovered* `qt_version_tag` + (`bindings.py`), and sipbuild's `update_buildable_bindings()` *silently deletes* any + bindings whose config test fails, so a build against Rocky 10 riscv64's Qt 6.10.1 + (`qmake6` is in `qt6-qtbase-devel`; 28 module `-devel` packages in AppStream) + configures and produces a reduced, Qt-6.10-API wheel under a 6.11.0 version number + (gotcha 383's divergence, with gotcha 382's "a warning must not make the product + decision" on top). auditwheel then bundles the Qt libraries the extensions *link*. + It cannot bundle what Qt `dlopen`s — the platform plugins (`platforms/libqxcb.so`, + `libqoffscreen.so`), the imageformat and sqldriver plugins, the QML module tree — and + the bundled distro `libQt6Core` keeps its compiled-in `/usr/lib64/qt6/plugins` prefix, + so the wheel imports cleanly and then dies at the first `QApplication` with "no Qt + platform plugin could be initialized". **When the sibling wheel supplies plugins, QML + and data as well as libraries, auditwheel's linked-library bundling is not a + substitute for it** — reproducing that payload *is* the sibling's port. + - **For a vendor pair `` + `-`, triage the SDK entry first; it decides + both.** `pyqt5`/`pyqt5-qt5`, `pyqt6`/`pyqt6-qt6` and the pyside6 family are the same + shape three times over. Mark the bindings half `blocked-on-dependency` pointing at the + SDK entry (gotcha 382's rule: the blocker is a sibling port, not absent source), keep + the two entries' notes pointed at each other, and do not re-run the SDK investigation + on the bindings entry — record only what is new on the *consumer* side (the wheel-vs- + sdist metadata split, the `RUNPATH`, the resolver output). + +405. **An NVIDIA-owned, profiler-adjacent package can have no CUDA dependency whatsoever — + read the extension's own header set and `libraries=` list before filing it with the GPU + batch (the nvtx case; see `build-nvtx.yml`).** Gotcha 284 covers CUDA symbols that turn + out to be `dlopen`ed at runtime; this is the step before it, where there are no CUDA + symbols at all and only the vendor's name suggests otherwise. The PyPI `nvtx` + distribution is the `python/` subdirectory of `NVIDIA/NVTX`: five Cython modules over a + header-only C annotation API. `setup.py` declares a single + `Extension('*', sources=['src/nvtx/_lib/*.pyx'], include_dirs=[/c/include])` with + no `libraries=` at all, and the only `cdef extern from` headers across its `.pxd` files + are `nvtx3/nvToolsExt{,Counters,Payload}.h`, `nvtx3/nvToolsExtSemantics*.h` and + `nvtxw3/nvtxw3*.h` — no `cuda.h`, no `cuda_runtime.h`, nothing to link. The GPU is the + *consumer*, not a dependency: annotations are inert until an external profiler injects a + library through `NVTX_INJECTION64_PATH`, and `nvtx.enabled()` is literally + `not os.getenv("NVTX_DISABLE")` with no hardware probe anywhere. + - **Two checks settle it, both cheaper than a CI cycle**: `grep -rn 'libraries=' setup.py` + plus `grep -rn 'cdef extern from\|#include' ` (an instrumentation + SDK's own headers only), and then `auditwheel show` on a locally built wheel — one that + references nothing but `libc.so.6` has no GPU runtime to find. + - **The cost of getting this wrong is not one entry.** An annotation SDK shows up in the + `Requires-Dist` of GPU-ecosystem distributions (vllm's CUDA wheels among them), so + parking it on "NVIDIA ⇒ GPU-only" converts one bad triage into a fake blocker for every + consumer that is itself portable. + - **What actually distinguishes the parked set** (`onnxruntime-gpu`, `cupy-cuda12x`/ + `-cuda13x`, `jax-cuda*-plugin`, `numba-cuda`) is that those need the toolkit's own + headers and libraries — or nvcc — *at build time* (gotcha 387). A vendor's profiling, + tracing or annotation library typically needs neither, and belongs in the ordinary + Cython/C-extension lane. + +407. **An upstream recipe can stop being conda-based between releases, so read it at the + *newest* tag before pricing a port or recording a conda blocker (the + cadquery-ocp-novtk case).** Gotcha 388 says the queue entry's wheel *shape* is a + snapshot; the recipe's *build environment* is one too. `CadQuery/ocp-build-system` at + `v7.9.3.1.1` — the version the queue entry named — builds the OCCT SDK for Linux + inside a micromamba environment (`environment.yml`'s python plus `micromamba install + fontconfig freetype freeimage`), which is gotcha 40's wall: conda-forge has + `linux-riscv64` freetype and fontconfig but **no** freeimage. At `v8.0.1.0.0`, + released since that entry was written, the same repo's Linux path is `dnf` system + libraries plus `astral-sh/setup-uv`, and conda survives only on macOS/Windows — so + the riscv64 port needs no conda at all and is an ordinary CMake build. + - **`https://api.anaconda.org/package/conda-forge/` is the per-package form of + gotcha 42's subdir count** — one small JSON per dependency, + `{f["attrs"]["subdir"] for f in d["files"]}`, with no 100 MB `repodata.json` + download, which is what makes "which of these conda deps is missing for riscv64" a + one-minute question. + - **micromamba itself is never the blocker.** + `https://micro.mamba.pm/api/micromamba/linux-riscv64/latest` serves a real riscv64 + ELF (8.3 MB), so a conda-based recipe fails on *package* coverage only. + - **Diff the recipe, not just the version string** (`git log --oneline v..v + -- .github/` on the recipe repo). The same diff decides which component versions + you build: upstream's workflow `env:` block carries `WHEEL`, `OCP` and `OCCT`, so + read them out of the tag the job checks out instead of hardcoding them, and assert + that `WHEEL` equals the version in `docs/packages/.yaml` so a bump that moves + them fails loudly. diff --git a/skills/python-project-porting/references/gotchas/local-validation-and-rehearsal.md b/skills/python-project-porting/references/gotchas/local-validation-and-rehearsal.md index e3aecf64ff..4817fcd5d8 100644 --- a/skills/python-project-porting/references/gotchas/local-validation-and-rehearsal.md +++ b/skills/python-project-porting/references/gotchas/local-validation-and-rehearsal.md @@ -17,7 +17,16 @@ To pull up one entry: `grep -n '^N\. ' references/gotchas/local-validation-and-r - **223** — For a `bindings = "bin"` CLI's test assertions, `cargo build --release` the tool - **298** — A local rehearsal's `pip`-resolved cibuildwheel can be too old for - **369** — Without docker, fetch Rocky 10's own dnf repodata over plain HTTPS to -- **382** — When no riscv64 image or cross-toolchain is reachable, exercise a C/C++ source's +- **384** — `dnf` failing in the image with `Curl error (60) ... self-signed certificate` is +- **394** — A libtorch-linking project cannot be rehearsed on x86_64 with PyPI's `torch` + your egress proxy, not the image — install the proxy CA into the container trust store +- **403** — Prove which build *variant* you are about to produce by stubbing the build + backend's `setup()` on the host +- **404** — For a from-source C++ world, a *full CMake configure* inside the real riscv64 + image is the honest local ceiling +- **410** — Gotcha 188's "lower the optimisation level for the local rehearsal only" can + silently produce a broken wheel when the project has a C99 `inline` helper with no + `static` — and the suite still passes, because the pure-Python fallback catches it. --- @@ -249,39 +258,118 @@ To pull up one entry: `grep -n '^N\. ' references/gotchas/local-validation-and-r the whole file, and delete it when done; it is Rocky's own public mirror data, not anything project-specific worth keeping. -382. **When no riscv64 image or cross-toolchain is reachable, exercise a C/C++ source's - *generic* architecture path natively by renaming the arch macros in a scratch copy — - `-U__x86_64__` cannot do it, because glibc's own headers key off the same macro.** - Gotchas 9/101/180 all assume a container: `quay.io` for the manylinux images, - `deb.debian.org`/`dl-cdn.alpinelinux.org` for a compiler inside a `--platform - linux/riscv64` base. A restricted-egress host can have working QEMU/binfmt and still - reach none of them, leaving no way to compile a single line for riscv64. The - substitute question is nearly as good: *does the source's non-x86, non-aarch64 branch - compile at all?* — which is the branch riscv64 takes, and it compiles on any host. - The obvious spelling fails: `g++ -U__x86_64__` dies in `/usr/include/gnu/stubs.h` - with `fatal error: gnu/stubs-32.h: No such file or directory`, because undefining the - macro flips glibc's own multilib selection, not just the project's `#if`s. Rename the - macros in the project's sources instead, in a copy under `.git/pw-scratch//`: - ```bash - cp -a /csrc .git/pw-scratch//csrc && cd .git/pw-scratch//csrc - sed -i 's/__x86_64__/__FAKE_X86__/g; s/_M_X64/FAKE_M_X64/g; - s/__aarch64__/__FAKE_A64__/g; s/_M_ARM64/FAKE_M_ARM64/g; - s/__i386__/__FAKE_I386__/g' *.cpp *.h - g++ -std=c++17 -O2 -fopenmp -I. -c -o /dev/null - ``` - The system headers keep their real macros, the project's guards all evaluate false, and - what compiles is the scalar fallback path. For bitsandbytes this settled in seconds that - every `immintrin.h`/`arm_neon.h` block in `csrc/cpu_ops.{cpp,h}` has a working generic - `#else` — the one real riscv64 unknown — without a single emulated instruction. - - **It proves compilability, not codegen or correctness**, so it substitutes for the - *pre-flight*, never for the CI build: an arch-specific miscompile, an alignment - assumption or a numeric divergence (gotcha 172's territory) still only shows up on the - real runner. Pair it with the `pip download --platform manylinux_2_39_riscv64` check - (gotcha 101) so the dependency side is settled on the host too. - - **Check what the egress policy actually allows before giving up on the container**: - `mirror.gcr.io` proxies Docker Hub and often survives a policy that blocks `quay.io` - and Docker Hub's own CDN, which is enough to install binfmt - (`docker run --privileged --rm mirror.gcr.io/tonistiigi/binfmt --install riscv64`, - after `mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc` if the host has not - mounted it) and to pull `mirror.gcr.io/riscv64/debian`. A riscv64 shell with no - reachable package mirror still cannot compile anything, which is what sends you here. +384. **`dnf` failing inside the image with `Curl error (60) ... self-signed certificate in + certificate chain` is a fact about *your session's egress proxy*, not about the image — + install the proxy CA into the container's trust store instead of recording "in-image dnf + is impossible".** A sandbox whose outbound HTTPS goes through a TLS-intercepting proxy + gives the host a CA bundle, but a container gets neither that bundle nor the host's + loopback proxy, so every `dnf makecache`/`repoquery` dies on `mirrors.rockylinux.org` + and it looks like the image cannot reach its own repos. Three things fix it together, + and all three are needed: + ``` + docker run --rm --network host \ + -e HTTPS_PROXY -e HTTP_PROXY -e https_proxy -e http_proxy \ + -v "$PWD/.git/pw-scratch/:/s" "$MANYLINUX_RISCV64_IMAGE" bash -c ' + cp /s/ca-bundle.crt /etc/pki/ca-trust/source/anchors/proxy.crt + update-ca-trust extract + dnf repoquery --qf "%{name}|%{version}|%{reponame}\n" "qt6*"' + ``` + `--network host` is what lets the container reach a proxy listening on the host's + loopback; the env vars are not inherited unless named; and `update-ca-trust extract` + (Rocky's anchors directory, *not* `/etc/ssl/certs`) is what makes curl inside `dnf` + accept the intercepted chain. This matters because two queue entries had already + recorded the proxy failure as an image limitation and fallen back to gotcha 369's + raw-repodata parse — which is still the right tool for "is it packaged, in which repo", + but cannot answer what `dnf` actually *resolves*, and cannot show you the installed + on-disk layout (`/usr/lib64/cmake/Qt6*`, `ClangConfig.cmake`, real `.so` names) that a + CMake `find_package` will or will not hit. + - **Use `repoquery` for inventory and reserve `install` for layout questions.** A + `repoquery` is metadata-only and answers in seconds even under QEMU — and it returns + the SDK's *version*, which is the field most likely to be assumed rather than checked + (gotcha 383). Actually installing a large `-devel` set is emulated `rpm` scriptlet + work and can take tens of minutes on a loaded host, so do not put it on the critical + path of a triage decision; note how far it got and move on. + - This is the container half of the rule already stated for the host: never disable TLS + verification or unset the proxy variables to make a fetch succeed. + +394. **A project that links libtorch cannot be rehearsed on an x86_64 host with the + `torch` wheel PyPI serves, because that one is a CUDA build: `find_package(Torch)` + pulls in `Caffe2Config.cmake`, which hard-fails with "Your installed Caffe2 version + uses CUDA but I cannot find the CUDA libraries" before CMake reaches a single line + of the project's own configuration (the torchcodec case).** The failure has nothing + to do with the project or with riscv64 — the riscv64 `torch` on our registry is a + `+cpu` build whose `Caffe2Config.cmake` has the CUDA branch compiled out, so the same + configure succeeds there. Two consequences worth knowing before spending a rehearsal + cycle on it: + - **A CPU-only torch is the prerequisite for any local rehearsal of a libtorch + extension**, and PyPI has none for linux x86_64 (the CPU variants live on + `download.pytorch.org/whl/cpu`, a separate index); linux aarch64's PyPI `torch` + *is* CPU-only, which is one more reason gotcha 101's aarch64 rehearsal is the right + host for this family of packages. + - **Everything before `find_package(Torch)` still validates cheaply on x86**, and for + a scikit-build-core/CMake project that is most of the interesting surface: the + build frontend and `--no-build-isolation` wiring, `pkg-config` discovery of a + source-built native dependency, the backend finding `pybind11`, and any + licence-guard/env-var gate the project puts in front of a wheel build. Run it and + read how far the configure got rather than treating the CUDA error as a dead end. + +403. **Prove which build *variant* you are about to produce by stubbing the build + backend's `setup()` on the host — it costs seconds and is the only cheap guard on + gotcha 79's trap, where the wrong sibling compiles for hours under your artifact + name.** For a sibling port selected by env vars plus a pre-stamped generated file + (`ENABLE_CONTRIB`/`ENABLE_HEADLESS` + `cv2/version.py`), put a fake module in + `sys.modules` exposing whatever `setup.py` imports, have its `setup(**kw)` record + `kw["name"]`/`kw["version"]`/`kw["license"]`/`cmake_args` and raise `SystemExit`, then + `runpy.run_path("setup.py", run_name="__main__")`. It is arch-independent, needs no + toolchain, and works with `.git` already deleted — exactly the tree the container sees. + Two habits make it worth the five lines: + - **Assert the negative too.** Re-run with the generated file stamped `False` and the + env vars still set: if the name does not change, the env vars are decorative and the + stamp is the real selector — the fact the workflow's `grep -Fqx` guards. Getting plain + `opencv_python` back from a contrib+headless environment turns gotcha 79's warning + into something measured rather than quoted. + - **Read the `cmake_args` list it captured** instead of re-deriving the flags from the + `setup.py` source; a variant's flags are assembled across several conditionals and the + captured list is the authoritative answer. + +404. **For a from-source C++ world (OpenCV+contrib and friends), a *full CMake configure* + inside the real riscv64 image is the honest local ceiling — budget ~40 minutes for it + and report what it proved rather than that "a build" was attempted.** Under + `qemu-riscv64` binfmt on a loaded 4-core x86_64 host, `cmake` over opencv + + opencv_contrib reported `Configuring done (2365.7s)` / `Generating done`; the compile of + the ~50 modules that follows is days of emulation and is not a local task. The configure + summary carries most of what a reviewer would otherwise take on trust — the + extra-modules path and its submodule SHA, the module list, `GUI: NONE` for a headless + variant, the baseline `-march=rv64gc` and which SIMD kernels were dropped, and which + third-party libraries are vendored (`build (…)`) rather than external. Two setup notes + that each cost a restart: + - **Mount the source tree read-write.** OpenCV's `OpenCVDownload.cmake` writes a + `.cache/` directory *into the source dir*, so a `:ro` mount fails the configure at + once with "Read-only file system" — which reads like a real port problem. + - **Give the container the egress proxy.** Those same third-party fetches go to + `raw.githubusercontent.com`: run with `--network host`, pass the host's `HTTPS_PROXY`, + and mount the proxy CA (gotcha 384). Without it the downloads fail and the modules + needing them quietly drop out of the summary you are reading. + +410. **Gotcha 188's "lower the optimisation level for the local rehearsal only" can + silently produce a *broken* wheel when the project has a C99 `inline` helper with no + `static`: the extension links, ships, and passes the suite, because the package's own + pure-Python fallback catches the ImportError (the cassandra-driver case).** + `cassandra/cmurmur3.c` defines `inline int64_t rotl64(...)` — under C99/gnu11 that + emits no out-of-line definition, so at `-O3` the call is inlined and at `-O0` the + `.so` keeps an undefined `rotl64`. The rehearsal's wheel therefore contained all + twenty `.so` files, passed gotcha 20's presence check, passed auditwheel repair, and + ran the whole unit suite green — 618 passed — while `cassandra.murmur3`'s + `try: from cassandra.cmurmur3 import murmur3 / except ImportError` had quietly fallen + back to Python. The identical `-O3` wheel differed by only two tests (the two that + skip when the C murmur3 is missing), which is far too small a delta to notice. + - **Fix the *check*, not just the rehearsal**: presence in the zip is not proof, so + have `CIBW_TEST_COMMAND` **import** every extension and assert `__file__` ends in + `.so`, plus one real call per hand-written extension + (`murmur3("key") == -6847573755651342660`, `libevwrapper.Loop()`). Then a degraded + or unimportable build fails the job instead of passing it. `readelf --dyn-syms -W + .so | grep UND` on the built wheel is the direct confirmation, and it works on + a riscv64 `.so` from an x86 host. + - **Prefer `-O1`/`-O2` over `-O0`** when trading fidelity for QEMU time, and re-run the + import assertions against a wheel built with upstream's real `CFLAGS` before + believing a green rehearsal.