Skip to content

Make pip install pamtra work: static OpenBLAS/FFTW, cibuildwheel pipeline - #65

Merged
maahn merged 16 commits into
masterfrom
feature/static-sequential-openblas
Aug 20, 2026
Merged

Make pip install pamtra work: static OpenBLAS/FFTW, cibuildwheel pipeline#65
maahn merged 16 commits into
masterfrom
feature/static-sequential-openblas

Conversation

@maahn

@maahn maahn commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Makes pip install pamtra work as a real, self-contained wheel for the first time -- the whole point of this branch. Builds on top of the earlier meteo_si merge (#64), already reflected here via a clean merge from master.

Platform scope: Linux x86_64 and macOS Apple Silicon (arm64) only. No wheels for Windows or Intel macOS (osx-64) -- see wheels.yml's matrix comment for why Intel macOS specifically was dropped. pyproject.toml's classifiers, readme.md, and doc/source/installation.rst all now say this explicitly.

  • OpenBLAS: built static, single-threaded, with its exported symbol table restricted to just PyInit_pyPamtraLib at link time -- avoids colliding with numpy/scipy's own bundled OpenBLAS in the same process (tools/build_openblas_static.sh).
  • FFTW: same static-build treatment, no collision risk of its own but keeps the wheel's dependency surface minimal (tools/build_fftw_static.sh).
  • netCDF-C/-Fortran/HDF5: only needed by the standalone pamtra CLI, not pyPamtraLib (import pyPamtra gets NetCDF I/O through the pure-Python netCDF4 package) -- so wheel builds pass -Dbuild_cli=false and skip this dependency chain entirely (tools/build_netcdf_stack.sh still used by pip install ./conda builds, which build the CLI by default).
  • New .github/workflows/wheels.yml: cibuildwheel across Linux x86_64 + macOS arm64 (Intel macOS dropped -- GitHub's runner queue for it was badly backlogged), Python 3.10-3.14, with an in-CI check that the built wheel is genuinely self-contained (no libopenblas/libfftw3 linked, exactly one exported symbol). Also wires up both a manual TestPyPI dry-run job and the real, tag-gated PyPI publish job.
  • RELEASING.md updated to describe the PyPI process (replacing the now-outdated "why not PyPI" section) and AI.md documents the build scripts.

Verified end-to-end multiple times: local builds on macOS arm64 and a real Debian x86_64 machine (including catching several bugs cibuildwheel's manylinux container surfaced that a local build didn't -- BFLOAT16/LAPACK target selection in OpenBLAS's own Makefile, missing libxml2-devel, macOS deployment-target mismatches), a full TestPyPI publish + clean-venv install proving the wheel actually works with zero system libraries preinstalled, and the CI matrix (including the newly-added Python 3.10 and the meteo_si dependency together).

Test plan

  • pytest tests/ full suite green on every pip install . variation exercised (static deps, build_cli=false, with meteo_si)
  • wheels.yml build-only run green on Linux + macOS arm64, Python 3.10-3.14
  • In-CI self-containment check (no bundled-lib collisions, single exported symbol) passing
  • TestPyPI dry-run publish + fresh-venv install verified working end-to-end
  • Real PyPI publish (next: tag + push per RELEASING.md, after this merges)

🤖 Generated with Claude Code

maahn and others added 16 commits August 18, 2026 15:01
A dynamic OpenBLAS bundled into a PyPI wheel would collide at runtime
with numpy/scipy own bundled copy in the same process (duplicate
global symbols, shared thread-pool state) -- version matching alone
does not prevent this, since nothing renames or namespaces the symbols.

tools/build_openblas_static.sh builds OpenBLAS USE_THREAD=0 (PAMTRA
only uses it for small per-particle T-matrix solves, not large GEMMs)
and static-only. meson.build's pyPamtraLib target now also restricts
its exported dynamic symbol table to just PyInit_pyPamtraLib at link
time, since -fvisibility=hidden on OpenBLAS own build does not reach
its hand-written assembly kernels (they set .globl directly). Verified
with nm/otool (only PyInit_pyPamtraLib exported, no libopenblas linked)
and the full pytest suite (66 passed, including the T-matrix-exercising
regression tests).

This is a building block for an eventual cibuildwheel pipeline, not a
full one -- FFTW and netCDF-Fortran still need their own bundling story.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
OpenBLAS own build output warns about exactly this: any flags passed to
make during build must also be passed to make install, or install can
fail. On Linux this is not just a warning -- without NO_SHARED=1 at
install time, make install tries to `install` a shared library that
was never built and exits non-zero (install: cannot stat
libopenblas...so: No such file or directory), so the whole script
aborts under set -euo pipefail before ever generating openblas.pc.
macOS masked this because the equivalent step there uses cp in a make
recipe line that is allowed to fail, so it looked like a warning
instead of a hard error. Verified on a real Debian x86_64 machine:
install now completes and openblas.pc is generated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pyproject.toml: [project] name pyPamtra -> pamtra, so `pip install pamtra`
works once published. The import name stays `import pyPamtra` unchanged
(driven by meson.build's install_sources subdir, not this field) -- same
pattern as `pip install beautifulsoup4` -> `import bs4`. This also aligns
with conda-recipe/recipe.yaml, which was already named pamtra, not
pyPamtra.

tools/build_fftw_static.sh mirrors build_openblas_static.sh: builds FFTW
static-only (--disable-shared, --with-pic) so it needs no auditwheel/
delocate bundling step later. Unlike OpenBLAS, FFTW has no known symbol-
collision risk with other commonly-bundled packages, so this is purely to
keep the wheel's runtime dependency surface minimal, not a correctness
requirement.

Verified on both macOS arm64 (here) and Debian x86_64 (over SSH): built
against both static libs together, confirmed via otool/ldd that neither
libopenblas nor libfftw3 is a runtime dependency anymore, confirmed the
exported symbol table still contains only PyInit_pyPamtraLib, and ran the
full pytest suite on both (66/66 on macOS, 65/66 on Debian -- the one
Debian failure is test_data_autofetch.py's network-failure test, unrelated
to this change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… build

meson.build: pyPamtraLib listed netcdfdep as a dependency, but nothing it
compiles ever calls netCDF -- confirmed via grep that the only Fortran
source using netCDF at all is write_nc_results.f90, which belongs
exclusively to the standalone pamtra executable's own source list, never
pyPamtraLib's. Verified empirically too: removed the dependency, rebuilt
with no netcdf present in the environment at all, still links cleanly,
import pyPamtra works, and the full pytest suite still passes (66/66).

This matters for wheel-building: import pyPamtra already gets NetCDF I/O
purely through the netCDF4 Python package (a runtime dependency that ships
its own self-contained wheels), so the compiled extension needed no direct
C-level link to libnetcdf at all -- it was dead weight, and arguably a
latent risk of its own (a second, separately-linked copy of netCDF-C
sharing a process with netCDF4's bundled copy). Only the standalone pamtra
CLI binary target still needs netCDF-C/-Fortran.

tools/build_netcdf_stack.sh builds that stack (HDF5 -> netCDF-C
--disable-dap --disable-nczarr -> netCDF-Fortran) as ordinary dynamic
libraries into one prefix, for the pamtra CLI target specifically. Unlike
OpenBLAS/FFTW this is intentionally left dynamic rather than static:
HDF5/netCDF have no known symbol-collision risk with other commonly-
bundled PyPI packages (unlike OpenBLAS with numpy/scipy), so the standard
auditwheel/delocate dynamic-bundling path -- what netCDF4's and h5py's own
PyPI wheels already use -- is the right, lower-risk tool here rather than
reinventing static linking for a much larger, more complex dependency
chain. Verified locally: all three stages built cleanly on the first try.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
meson.build's netcdf-fortran includedir workaround used
include_directories(), which meson rejects for absolute paths that
resolve inside the source tree. That's exactly what happens with
tools/build_netcdf_stack.sh's default prefix (<repo>/build-deps/netcdf),
failing with "Tried to form an absolute path to a dir in the source
tree." Switched to a raw '-I' compile_args string instead, which the
compiler accepts with no such restriction. This isn't just a local-
testing artifact -- a real cibuildwheel before-all hook building
dependencies in-tree would hit the same thing.

Verified end-to-end on both macOS arm64 and Debian x86_64: built
pyPamtra + the standalone pamtra CLI against all three custom
dependency prefixes (static OpenBLAS, static FFTW, dynamic
HDF5/netCDF-C/netCDF-Fortran) together, confirmed via otool/ldd (with
LD_LIBRARY_PATH set, since these are dynamic libraries at a
non-standard prefix -- see the AI.md note added here) that everything
resolves to our own builds rather than system copies, and ran the full
pytest suite on both machines.

Also documents tools/build_fftw_static.sh and
tools/build_netcdf_stack.sh in AI.md alongside the existing OpenBLAS
entry, including the LD_LIBRARY_PATH gotcha for local (pre-wheel)
testing of the dynamic netCDF stack.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pyproject.toml: [tool.cibuildwheel] targets cp311-cp314 on manylinux_2_28
(Linux x86_64) and macOS (both archs), matching the existing pip-build CI
matrix. before-all runs the new tools/cibw_before_all.sh, which installs
gfortran/zlib-devel (Linux) or gcc (macOS) then builds all three bundled
dependencies -- static OpenBLAS, static FFTW, dynamic HDF5/netCDF-C/
netCDF-Fortran -- into /tmp/pamtra-deps, deliberately outside the checked-
out source tree (a prefix under the repo can trip meson.build's
netcdf-fortran includedir handling, per the commit that fixed that for
local testing -- building outside the tree sidesteps the question rather
than relying on that fix alone). test-command reuses the existing pytest
suite plus a `pamtra -h` smoke test, both already used in ci.yml, with no
LD_LIBRARY_PATH set -- unlike local dev testing, this doubles as a check
that auditwheel/delocate's repair step actually rewrote the wheel to load
its own bundled libraries rather than relying on an ambient env var.

tools/build_openblas_static.sh: switched from the default `make` target to
`make libs` explicitly. Discovered by running cibuildwheel locally (it can
build macOS wheels without Docker): OpenBLAS's default target is
`all :: tests`, which also builds and links its own BLAS/LAPACK self-test
programs -- unneeded here, and failing to link specifically inside
cibuildwheel's sandboxed build environment even though the exact same
script worked in a plain interactive shell. `libs` builds only the
library itself; `make install` was already a separate, unaffected target.

Locally verified end-to-end (via `cibuildwheel --only cp313-macosx_arm64`)
that before-all now succeeds and produces the dependency prefixes
correctly. Couldn't verify the full wheel build to completion on this
machine: cibuildwheel's macOS path needs the official python.org
"Framework" CPython installs that GitHub-hosted macOS runners ship with
for exactly this purpose, which this dev machine (conda-only Python)
doesn't have -- not something to install system-wide without asking.
Real CI is the next step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
.github/workflows/wheels.yml runs cibuildwheel across
[ubuntu-latest, macos-13, macos-14] (Linux x86_64 + macOS Intel + macOS
Apple Silicon), triggered manually or on pushes touching packaging files.
Deliberately not on every PR -- a full wheel build (compiling OpenBLAS,
FFTW, and the whole HDF5/netCDF stack from source per commit) is expensive
enough to not want on the normal PR loop.

After each build, unpacks one wheel and re-runs this session's manual
verification (nm/ldd on Linux, nm/otool on macOS) inline in CI: exactly
one exported symbol (PyInit_pyPamtraLib) and no libopenblas/libfftw3
linked. This is deliberately not just "did cibuildwheel exit 0" -- it
confirms the actual property the static+hidden-symbol OpenBLAS work exists
for survives the real auditwheel/delocate repair step, not just the
hand-built local case already verified on two real machines.

No publish step yet -- that's gated on manually setting up PyPI trusted
publishing first (needs a PyPI account login, not something automatable).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@v4 exists as a git tag but GitHub Actions' action resolver could not
find it (Unable to resolve action pypa/cibuildwheel@v4, unable to find
version v4) -- likely a marketplace-indexing quirk rather than a real
missing tag, but pinning the exact release avoids relying on that
resolving correctly either way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both found by actually running the wheels.yml workflow on GitHub Actions
(the local cibuildwheel test never got far enough to catch either, since
it hit the missing-Framework-Python wall first).

tools/build_openblas_static.sh: `make libs` (added in the previous commit
to skip OpenBLAS's failing self-test suite) turned out to skip more than
just the tests -- OpenBLAS's own target graph is `shared : libs netlib
$(RELA)`, so LAPACK (netlib) is a separate prerequisite of `shared`, not
of `libs`. A `libs`-only build produces a static archive with BLAS but no
LAPACK at all. This went unnoticed locally because pyPamtraLib never
calls into LAPACK and linked fine either way; only the standalone pamtra
executable's radmat.f90 -> minvert_lapack_ -> dgetri_ chain exposed it,
failing on macos-14 in CI with "Undefined symbols ... _dgetri_". Switched
to `make shared`, which builds libs+netlib but -- since NO_SHARED=1 is
already set -- still skips both the actual .dylib/.so assembly (wrapped
in `ifneq ($(NO_SHARED), 1)` in OpenBLAS's Makefile) and the `tests`
target that depends on `shared` succeeding. Confirmed locally: `nm` now
shows _dgetri_ present in the archive, and the full local build (both
pyPamtraLib and the pamtra executable, tested via the existing pytest
suite) is back to 66/66 passing.

tools/cibw_before_all.sh: added libxml2-devel to the manylinux yum/dnf
install list. netCDF-C's ./configure wants libxml2-config even with
--disable-dap set (some other feature depends on it, not just DAP) --
failed on ubuntu-latest in CI with "Cannot find xml2-config utility".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The standalone pamtra executable kept segfaulting specifically inside
cibuildwheel's manylinux container, on real computation (not just -h),
after building and auditwheel-repairing successfully with no visible
errors. Extensive investigation (rebuilding the same wheel + repair step
by hand on a real Debian machine, reproducing the actual auditwheel
repair mechanism outside the container) could not pin down the cause --
a properly repaired wheel worked fine there once a red herring (a stale
system-linked pamtra binary shadowing the venv's own on PATH) was ruled
out, so the container-specific segfault remains unexplained. Given
pyPamtraLib itself needs no netCDF at all (see the previous commit) and
the CLI was the only reason wheel builds needed netCDF-C/-Fortran/HDF5,
the pragmatic fix is to just not ship the CLI in the wheel.

meson_options.txt: new build_cli boolean option, default true.
meson.build: netCDF-C and netCDF-Fortran resolution (previously
unconditional -- netcdfdep was a hard `dependency('netcdf')` that would
fail the whole build if netCDF-C were absent) is now wrapped in
`if build_cli`, with both left as disabler() otherwise. Since the pamtra
executable target lists both as dependencies, disabler() propagation
means the executable target itself is silently skipped when build_cli is
off, with no separate `if` needed around the executable() call.

pyproject.toml: [tool.cibuildwheel] passes -Dbuild_cli=false via
config-settings; tools/cibw_before_all.sh no longer calls
build_netcdf_stack.sh (still exists for local/dev CLI-included builds).
Verified locally: `pip install . -Csetup-args=-Dbuild_cli=false` with
only static OpenBLAS/FFTW on PKG_CONFIG_PATH (no netcdf at all) builds
cleanly, installs no pamtra binary, and the full pytest suite (which
skips test_cli_binary.py cleanly via its own pamtra_binary fixture when
none is found) still passes.

Also fixes the macOS build failure found in the same CI run: delocate
refused to repair a wheel tagged for macOS 11.0 (cibuildwheel's arm64
default) when the bundled libgfortran/libquadmath (from GitHub-hosted
runners' Homebrew gcc) themselves require macOS 14.0 --
"Library dependencies do not satisfy target MacOS version 11.0". Set via
MACOSX_DEPLOYMENT_TARGET=14.0 in [tool.cibuildwheel.macos]'s environment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GitHub's Intel-macOS runner queue is badly backlogged -- a build sat
queued for 3+ hours here with no sign of clearing, while the same commit
built cleanly on ubuntu-latest and macos-14 (Apple Silicon) within
minutes. Consistent with GitHub winding that pool down as Apple's own
Intel Mac lineup ages out. Dropping it intentionally rather than
continuing to wait on it; revisit if the queue situation changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Uses trusted publishing (OIDC via the testpypi GitHub Actions
environment, no stored token) per pypa/gh-action-pypi-publish's
documented pattern. Downloads the per-OS wheel artifacts from the build
job (wheels-ubuntu-latest, wheels-macos-14) into one dist/ directory
before publishing, since this workflow's matrix build uploads one
artifact per platform rather than a single combined one.

Manual dispatch only, not on the workflow's other (packaging-file-push)
trigger: TestPyPI rejects re-uploading the same version, so publishing
on every such push would just fail after the first success. This is
meant as a one-off dry run to validate the pipeline before promoting to
a real, tag-gated PyPI publish job.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pyproject.toml/ci.yml: add cp310/"3.10" to the cibuildwheel build target
and the pip-build test matrix respectively. Both previously started at
3.11 even though pyproject.toml already declares requires-python >=3.10
-- a real gap now that this is heading toward an actual PyPI release,
not just an oversight to leave in place.

wheels.yml: new publish-pypi job, gated on an actual vX.Y.Z release tag
(startsWith(github.ref, 'refs/tags/v')) -- never on this workflow's other
triggers (workflow_dispatch, packaging-file pushes), so nothing here can
accidentally publish a real release. Uses trusted publishing (OIDC) via
the pypi GitHub Actions environment, same pattern as the existing
publish-testpypi job. Adding `tags: - 'v*'` to the workflow's push
trigger is safe alongside the existing `paths:` filter even though a tag
push touches none of those files: GitHub doesn't apply path filtering to
tag pushes at all, only to branch pushes.

No tag pushed as part of this commit -- that (and the one remaining
manual prerequisite, registering a pypi.org trusted publisher for this
project) is a deliberate separate step, not automated here.

RELEASING.md: replaces the now-outdated "Why not PyPI" section (this
session's earlier work solved everything it described as blocking) with
a "PyPI" section covering how the bundling actually works and the
trusted-publisher setup it depends on, plus a note in step 2 that
pushing the release tag alone already triggers the real publish -- no
separate manual step beyond that.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed empirically: the previous commit's tags: addition (with no
branches: key) caused this exact push to not trigger the workflow at
all, not just narrow which branches matched. GitHub's documented (if
not obviously so) behavior: adding tags: with no branches: suppresses
branch-push triggering entirely. branches: ['**'] restores it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pyproject.toml classifiers claimed Windows support, which was never
actually true (no Windows CI, no Windows wheel, conda-recipe explicitly
skips win) -- removed. No classifier granularity exists for "macOS
arm64 only, not Intel", so that caveat goes in the actual install docs
instead, added in three places since pip install pamtra never had any
documentation at all before this branch:

- readme.md (also PyPI's project-page description via pyproject.toml's
  readme field)
- doc/source/installation.rst, a new section ahead of the from-source
  instructions

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@maahn
maahn merged commit e955dcd into master Aug 20, 2026
34 checks passed
@maahn
maahn deleted the feature/static-sequential-openblas branch August 20, 2026 13:57
maahn added a commit that referenced this pull request Aug 20, 2026
First real PyPI-availability release -- no functional/physics changes to
PAMTRA itself, this is the packaging work from #64/#65 (meteo_si split,
static-OpenBLAS/FFTW wheel pipeline, cibuildwheel, Python 3.10 support).
Bumped rather than kept at 1.0.3 (the existing conda-forge version) to
mark that milestone distinctly. All three places RELEASING.md documents
(pyproject.toml, meson.build, conda-recipe/recipe.yaml) updated together.

conda-recipe/recipe.yaml's source.sha256 is intentionally left at the old
1.0.3 tarball's checksum for now -- RELEASING.md's own process computes
that from the real v1.1.0 tag's generated tarball, which doesn't exist
until after this is tagged. That recipe isn't used by this release
itself (only PyPI); the conda-forge feedstock update is a separate,
later step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant