Skip to main content

wheelgap

CI PyPI License: MIT Python 3.11+ good first issues

Find the Python dependencies that have no prebuilt wheel for your target platform, before your Docker build does.

Point it at your lockfile and the platforms you ship to (ARM servers, Alpine images, Windows on ARM, free-threaded Python 3.14t). It tells you which packages pip or uv will have to compile from source, and which can't be installed at all.

📊 New: wheel coverage for the top 5,000 PyPI packages, by platform and Python version, updated nightly.

wheelgap demo

Install and run (under 30 seconds)

With uv:

uvx wheelgap

Or with pipx:

pipx run wheelgap

Or install it: pip install wheelgap.

Run either one in a project directory. It finds uv.lock, pylock.toml, poetry.lock, pdm.lock, Pipfile.lock, requirements.lock or requirements.txt on its own.

Usage

# Will my Alpine ARM image compile anything?
wheelgap -p 3.13 -t alpine-arm64

# Is my stack ready for free-threaded Python 3.14?
wheelgap -p 3.14t -t all

# Several Pythons at once, Linux on both architectures
wheelgap uv.lock -p 3.12,3.13,3.14 -t linux-x86_64,linux-aarch64

# No -p? A .python-version file next to uv.lock (written by uv or pyenv) sets the Python
wheelgap uv.lock -t linux-x86_64

# Check a Pipenv project's default and development dependencies
wheelgap Pipfile.lock -p 3.13 -t linux-x86_64,linux-aarch64

# Check a PDM project's locked distribution files
wheelgap pdm.lock -p 3.13 -t linux-x86_64,linux-aarch64

# No lockfile? Check packages directly (latest version, or pin one)
wheelgap psycopg2 uvloop numpy==2.3.4 -t windows,win-arm64

# Take the target from the final stage's image (e.g. python:3.13-alpine
# or ghcr.io/astral-sh/uv:0.13.7-python3.13-trixie-slim), following
# named stages like `FROM base AS runtime` back to the image they start from
wheelgap --dockerfile Dockerfile

# Machine-readable, for scripts and CI
wheelgap --json > wheelgap.json

# Why is it a gap? List each rejected wheel and the reason
wheelgap -t alpine-arm64 --explain

Projects usually ship to the same platforms, so you can write the defaults down once in pyproject.toml (next to the lockfile, or in the current directory). Command-line flags still win.

[tool.wheelgap]
python = ["3.13", "3.14t"]
targets = ["linux-x86_64", "linux-aarch64", "musl-aarch64"]
glibc = "2.31"

Each cell in the table is one of:

cell meaning
✓ a compatible wheel exists, so the install is fast
● source no wheel, only an sdist, so it compiles at install time (needs a compiler, headers, time)
✗ missing no wheel and no sdist, so the install fails
– not installed on this target (environment markers exclude it)
✓ bundled no wheel on PyPI, but Pyodide ships its own build (with --pyodide-lock)

Packages that are fine everywhere are hidden; --all shows them.

--explain lists, for each gap, the wheels that were rejected and why, closest misses first:

armlib 1.4.0 on musl-aarch64: ● source
  armlib-1.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl  manylinux_2_17_aarch64 is a glibc wheel; musl (Alpine) needs musllinux

For uv.lock inputs, --explain also shows why the package is installed at all: the shortest dependency path from your project (or from a dependency group, shown as (dev group)) to the gap package. Paths are worked out per target, since markers can pull a package in on one platform only. With --json --explain, each package gets a matching via object keyed by target, like rejected: "via": {"win-arm64": ["myapp", "uvicorn", "uvloop"]}. Other inputs have no dependency graph, so they show no path (and "via": {}).

In --json output, results, rejected and via are all keyed by target label: just the platform ("linux-x86_64") when one Python is checked, or Python and platform ("3.13 linux-x86_64") with several. The top-level targets list gives each target's label, python and platform, so a script can look the key up instead of building it:

import json

data = json.load(open("wheelgap.json"))
key = next(t["label"] for t in data["targets"] if t["python"] == "3.13" and t["platform"] == "linux-x86_64")
uvloop 0.21.0 on win-arm64: ● source
  via: myapp → uvicorn → uvloop

If you think wheelgap got a package wrong, that output (or --json --explain) is all a bug report needs, with no need to share your lockfile.

Exit codes: 0 no gaps, 1 gaps found, 2 bad input. --exit-zero always exits 0. --fail-on missing (or fail-on = "missing" in [tool.wheelgap]) accepts source builds and only exits 1 when a package can't be installed at all.

Use -q / --quiet (or quiet = true in [tool.wheelgap]) to show only the gap summary and package names. Clean runs are silent, JSON and Markdown output are unchanged, and quiet text output cannot be combined with --explain (--json --explain and --markdown --explain work).

Targets

Run wheelgap --list-targets to see target aliases and compatibility limits, or add --json for a machine-readable list.

Only x86 Linux takes manylinux wheels older than glibc 2.17: every other architecture's first manylinux was manylinux2014 (glibc 2.17), so pip accepts nothing older there, and neither does wheelgap.

name aliases wheel tags it accepts
linux-x86_64 linux, linux-amd64, linux/amd64 manylinux_2_*_x86_64 up to --glibc (default 2.36), legacy manylinux2014/2010/1
linux-aarch64 linux-arm64, linux/arm64, linux/arm64/v8 manylinux_2_*_aarch64 from 2.17 up to --glibc, legacy manylinux2014
musl-x86_64 alpine, musl musllinux_1_*_x86_64 up to --musl (default 1.2)
musl-aarch64 alpine-arm64 musllinux_1_*_aarch64
macos-arm64 macos, mac macosx_*_arm64 and universal2 up to --macos (default 14.0)
macos-x86_64 macos-intel macosx_*_x86_64 and universal2
win-amd64 windows, win win_amd64
win-arm64 windows-arm64 win_arm64
linux-armv7l linux-arm, linux-armv7, linux/arm/v7 manylinux_2_*_armv7l from 2.17 (32-bit ARM, e.g. Raspberry Pi OS), legacy manylinux2014, and plain linux_armv7l (which PyPI allows for the Pi)
linux-ppc64le, linux-s390x, linux-riscv64 linux/ppc64le, linux/s390x, linux/riscv64 manylinux_2_* from 2.17 for that arch (riscv64 has no legacy manylinux2014 tag)
musl-armv7l, musl-ppc64le, musl-s390x, musl-riscv64 alpine-armv7 musllinux_1_* for that arch (pip would also take a plain linux_armv7l wheel on Alpine, but it's a glibc build, so wheelgap doesn't count it)
pyodide wasm, wasm32, emscripten Pyodide's ABI for that Python: pyodide_2024_0 (3.12), pyodide_2025_0 / pyemscripten_2025_0 (3.13), pyemscripten_2026_0 (3.14)
ios-arm64 ios, iphoneos ios_*_arm64_iphoneos up to --ios (default 13.0)
ios-simulator-arm64, ios-simulator-x86_64 ios-simulator ios_*_arm64_iphonesimulator / ios_*_x86_64_iphonesimulator
android-arm64, android-x86_64 android android_*_arm64_v8a / android_*_x86_64 up to --android-api (default 24)

iOS and Android (PEP 730 and PEP 738, Python 3.13+) are checked with -t mobile or by name, for apps built with tools like BeeWare's Briefcase. Mobile dependencies are installed by a cross-platform pip run that can only use wheels, so a package with only an sdist shows as ✗ missing. --ios and --android-api (or ios = "15.0" and android-api = 28 in [tool.wheelgap]) set your app's minimum iOS version and Android API level. wheelgap checks PyPI only: Briefcase and Chaquopy also install from their own package repositories (BeeWare's on anaconda.org, and chaquo.com), which have mobile builds of packages like numpy, so a ✗ missing there may still install.

Pyodide (Python in the browser) only installs wheels: there's no compiler, so a package with only an sdist shows as ✗ missing, not ● source. Pyodide wheels are tied to one ABI per Python version and don't work across them. Pyodide also ships its own builds of many popular packages (numpy, pydantic-core and others) that aren't on PyPI. Point --pyodide-lock at the release's pyodide-lock.json (a path or https:// URL, also pyodide-lock = "..." in [tool.wheelgap]) and those count as ✓ bundled, with a note when Pyodide's version differs from the one you pinned:

wheelgap -p 3.12 -t pyodide --pyodide-lock https://cdn.jsdelivr.net/pyodide/v0.27.7/full/pyodide-lock.json

Docker platform names also work with -t, for example -t linux/amd64,linux/arm64 (also linux/arm64/v8, linux/arm/v7, linux/ppc64le, linux/s390x and linux/riscv64).

-t all checks the eight common native platforms, and -t everything adds the armv7l, ppc64le, s390x and riscv64 Linux targets (glibc and musl). Pyodide, iOS and Android are only checked when you name them. Python versions take a trailing t for free-threaded builds (3.14t), which correctly rejects abi3 wheels. Without -p, wheelgap reads a .python-version file next to the lockfile (or pyproject.toml) if there is one, and otherwise uses the Python it is running on.

In CI (GitHub Action)

- uses: actions/checkout@v7
- uses: wheelgap/wheelgap@v0.6.3
  with:
    args: uv.lock -p 3.13 -t linux-x86_64,linux-aarch64

The job fails when a dependency would build from source on a target you ship, for example when a dependency bump drops ARM wheels. The gap table also goes to the run's summary page (set summary: false to turn that off).

Outside the Action, --markdown prints the same report as Markdown, for a job summary (wheelgap --markdown >> "$GITHUB_STEP_SUMMARY") or a PR comment. With --explain, the rejected wheels go in a collapsed section.

With pre-commit

repos:
  - repo: https://github.com/wheelgap/wheelgap
    rev: v0.6.3
    hooks:
      - id: wheelgap

The hook runs when uv.lock, poetry.lock, pdm.lock, pylock.toml, Pipfile.lock, or a requirements*.txt or requirements*.lock file changes. Put your targets in [tool.wheelgap] in pyproject.toml so the hook checks the right platforms. Pass extra options with args:, for example args: [--fail-on, missing].

From Python

import wheelgap

targets = wheelgap.make_targets(python="3.13", platforms=["linux-aarch64", "musl-aarch64"])

result = wheelgap.check_package("numpy", "2.3.4", targets)  # looks the files up on PyPI
result.ok  # True if every target has a wheel
result.gaps  # the targets that don't
result.to_dict()  # {"name": ..., "results": {"3.13 musl-aarch64": "wheel", ...}}

for r in wheelgap.check_lockfile("uv.lock", targets):  # markers and the uv graph respected
    print(r.name, r.version, [t.platform for t in r.gaps])

check_packages([...]) checks many packages in parallel, and check_package(..., files=[...]) works offline from filenames you already have.

Scanning the top PyPI packages

python -m wheelgap.scan --top 5000 --out coverage.json

This checks the latest release of the most-downloaded packages (from top-pypi-packages) against the eight common platforms (and Pyodide), for Python 3.13, 3.14, 3.14t, 3.15 and 3.15t by default, and writes one JSON file with per-platform totals. It's the data behind the coverage tracker on the roadmap.

How it works

  • Lockfiles are read offline. uv.lock, pylock.toml, poetry.lock and pdm.lock already record every file published for each pinned version (except a uv.lock or pylock.toml whose requires-python leaves out the Python you check; see below). wheelgap parses the wheel filenames' tags with packaging and checks them against the exact tag set each target accepts. That is the same logic pip and uv use to pick a wheel.
  • requirements.txt, requirements.lock and bare package names are looked up on the PyPI JSON API. Feed it a fully pinned file (from Rye, uv pip compile, pip-compile or uv export): wheelgap checks the lines you give it and doesn't resolve dependencies. Rye's default (non-universal) locks are resolved for the machine that created them and carry no platform markers.
  • Pipfile.lock reads default, develop and any custom package categories (such as docs or tests) and looks up distribution filenames on PyPI for their pinned versions. Environment markers are preserved; Git, path, file and editable dependencies are skipped as non-index packages.
  • Environment markers are respected. A pywin32 ; sys_platform == 'win32' dependency won't show up as a Linux gap. For uv.lock the dependency graph is walked per target, so transitive platform-specific packages are handled too.
  • --online re-checks lockfile packages against PyPI. Use it when a project has uploaded new wheels for an existing version since you locked, or if your uv.lock was generated with environments restrictions. A uv.lock or pylock.toml whose requires-python leaves out a target's Python (say ==3.13.*, checked with -p 3.14t) may be missing wheels for it, because uv only keeps the wheels that can serve requires-python. Without --online, wheelgap then also looks up its PyPI packages on the index, keeps the lock's own files if a lookup fails, and prints a note (with -q, only when some packages couldn't be re-checked).

Why this exists

Missing wheels show up late, in the most expensive place:

  • Your python:3.13-alpine image on an ARM runner takes 25 minutes because two transitive dependencies don't publish musllinux_aarch64 wheels, and QEMU is compiling them.
  • A build fails with error: Microsoft Visual C++ 14.0 or greater is required on a teammate's Snapdragon laptop.
  • You try free-threaded Python 3.14t and half your stack quietly falls back to source builds, because abi3 wheels don't apply there.

Sites like pyreadiness.org and free-threaded wheels track the top few hundred packages. wheelgap checks your packages, at your pinned versions, for your targets, in about a second, and it can run in CI.

Roadmap

wheelgap is growing into caniuse for Python wheels: an open map of which packages have wheels for which platforms. The CLI stays focused, and around it come a nightly tracker of the most-used PyPI packages, a community knowledge base of workarounds, and campaigns to help upstream projects ship missing wheels. Read the roadmap and join the discussion →

Next up for the CLI: private indexes (#19), and "nearest version with a wheel" hints (#20).

Contributions welcome. Issues labelled good first issue are a good start, and CONTRIBUTING.md has the 3-command dev setup. Questions are welcome in Discussions.

Maintainer

wheelgap is created and maintained by @patrickballard, with a lot of help from its contributors. Thank you, all of you!

License

MIT

Metadata

Release files for wheelgap 0.6.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for wheelgap 0.6.3
File Size Uploaded Provenance Actions
wheelgap-0.6.3.tar.gz 359.6 kB With provenance Details for wheelgap-0.6.3.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for wheelgap 0.6.3
File Interpreter ABI Platform Actions
wheelgap-0.6.3-py3-none-any.whl Python 3 none any Details for wheelgap-0.6.3-py3-none-any.whl

Total release size: 415.1 kB

Release files / wheelgap-0.6.3.tar.gz

Download URL wheelgap-0.6.3.tar.gz
Size 359.6 kB
Tags Source
SHA-256 checksum
How to use checksums
bfb4932af4b4105fb9d644666acc2a64f23c4b529e70f58ac41fafa11f92c4ac
BLAKE2b-256 checksum
How to use checksums
80d93e5419569e7da858503d9c4fe7a832fc772521d03d618d5b4da541dea92a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.

Transparency log

Release files / wheelgap-0.6.3-py3-none-any.whl

Download URL wheelgap-0.6.3-py3-none-any.whl
Size 55.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c09da8c82e2b5b223834b67a881c3d3725f84f49e4a09f5494e10be27df61646
BLAKE2b-256 checksum
How to use checksums
deeead70c517130c748dceaaa76fd62469b7fa499733dae674a667b4b2730212
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.

Transparency log

Security for wheelgap 0.6.3

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

Both files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for wheelgap-0.6.3.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page