wheelgap 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ name: test (${{ matrix.os }}, py${{ matrix.python }})
15
+ runs-on: ${{ matrix.os }}
16
+ strategy:
17
+ fail-fast: false
18
+ matrix:
19
+ os: [ubuntu-latest]
20
+ python: ["3.11", "3.12", "3.13", "3.14"]
21
+ include:
22
+ - { os: macos-latest, python: "3.14" }
23
+ - { os: windows-latest, python: "3.14" }
24
+ steps:
25
+ - uses: actions/checkout@v7
26
+ with:
27
+ persist-credentials: false
28
+ - uses: astral-sh/setup-uv@v10.3.3
29
+ - run: uv run --python ${{ matrix.python }} --group dev pytest -v
30
+
31
+ lint:
32
+ runs-on: ubuntu-latest
33
+ steps:
34
+ - uses: actions/checkout@v7
35
+ with:
36
+ persist-credentials: false
37
+ - uses: astral-sh/setup-uv@v10.3.3
38
+ - run: uv run --group dev ruff check .
39
+ - run: uv run --group dev ruff format --check .
40
+
41
+ dogfood:
42
+ name: wheelgap checks its own uv.lock (via the action)
43
+ runs-on: ubuntu-latest
44
+ steps:
45
+ - uses: actions/checkout@v7
46
+ with:
47
+ persist-credentials: false
48
+ - uses: ./
49
+ with:
50
+ args: uv.lock -p 3.11,3.14,3.14t -t all
@@ -0,0 +1,43 @@
1
+ name: Publish to PyPI
2
+
3
+ # Uses PyPI trusted publishing (no API token). One-time setup:
4
+ # 1. https://pypi.org/manage/account/publishing/ -> add a pending publisher:
5
+ # project "wheelgap", owner "patrickballard", repo "wheelgap", workflow "publish.yml", environment "pypi"
6
+ # 2. Repo Settings -> Variables -> add PYPI_PUBLISH = true
7
+ # 3. Run this workflow (Actions tab -> Publish to PyPI -> Run workflow), or publish a release.
8
+
9
+ on:
10
+ release:
11
+ types: [published]
12
+ workflow_dispatch:
13
+
14
+ permissions:
15
+ contents: read
16
+
17
+ jobs:
18
+ build:
19
+ if: vars.PYPI_PUBLISH == 'true'
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v7
23
+ with:
24
+ persist-credentials: false
25
+ - uses: astral-sh/setup-uv@v10.3.3
26
+ - run: uv build
27
+ - uses: actions/upload-artifact@v7
28
+ with:
29
+ name: dist
30
+ path: dist/
31
+
32
+ publish:
33
+ needs: build
34
+ runs-on: ubuntu-latest
35
+ environment: pypi
36
+ permissions:
37
+ id-token: write
38
+ steps:
39
+ - uses: actions/download-artifact@v8
40
+ with:
41
+ name: dist
42
+ path: dist/
43
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-11-08)
4
+
5
+ First release.
6
+
7
+ - Reads `uv.lock`, `pylock.toml` (PEP 751), `poetry.lock`, `requirements.txt` (with `-r` includes), or package names on the command line.
8
+ - Targets: `linux-x86_64`, `linux-aarch64`, `musl-x86_64`, `musl-aarch64`, `macos-arm64`, `macos-x86_64`, `win-amd64`, `win-arm64`, for any CPython version including free-threaded builds (`3.14t`).
9
+ - Evaluates environment markers and walks the `uv.lock` dependency graph per target, so Windows-only or old-Python-only packages don't show up as false gaps.
10
+ - Works offline from lockfiles that record their files; `--online` re-checks against PyPI.
11
+ - `--json` output and CI-friendly exit codes (0 clean, 1 gaps, 2 error). Also usable as a GitHub Action.
@@ -0,0 +1,14 @@
1
+ # Contributing
2
+
3
+ Thanks for helping! wheelgap is deliberately small: one job, one dependency (`packaging`).
4
+
5
+ ```bash
6
+ git clone https://github.com/patrickballard/wheelgap && cd wheelgap
7
+ uv run --group dev pytest # tests
8
+ uv run --group dev ruff check . # lint
9
+ uv run wheelgap numpy -t all # try it
10
+ ```
11
+
12
+ - Issues labelled [`good first issue`](https://github.com/patrickballard/wheelgap/labels/good%20first%20issue) are scoped to be doable in an evening.
13
+ - Please add a test with any behaviour change. Tests never hit the network (see the `fake_pypi` fixture in `tests/conftest.py`).
14
+ - Found a wheel that wheelgap misjudges? Open an issue with the wheel filename and the target; those make great test cases.
wheelgap-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Ballard
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.5
2
+ Name: wheelgap
3
+ Version: 0.1.0
4
+ Summary: Find the Python dependencies that have no prebuilt wheel for your target platform, before your Docker build does.
5
+ Project-URL: Homepage, https://github.com/patrickballard/wheelgap
6
+ Project-URL: Issues, https://github.com/patrickballard/wheelgap/issues
7
+ Project-URL: Changelog, https://github.com/patrickballard/wheelgap/blob/main/CHANGELOG.md
8
+ Author: Patrick Ballard
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: alpine,arm64,docker,free-threading,lockfile,musllinux,packaging,pypi,uv,wheels
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Software Development :: Build Tools
22
+ Classifier: Topic :: System :: Software Distribution
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: packaging>=24.2
25
+ Description-Content-Type: text/markdown
26
+
27
+ # wheelgap
28
+
29
+ [![CI](https://github.com/patrickballard/wheelgap/actions/workflows/ci.yml/badge.svg)](https://github.com/patrickballard/wheelgap/actions/workflows/ci.yml)
30
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
31
+ ![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)
32
+
33
+ **Find the Python dependencies that have no prebuilt wheel for your target platform, before your Docker build does.**
34
+
35
+ 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.
36
+
37
+ ![wheelgap demo](docs/demo.gif)
38
+
39
+ ## Install and run (under 30 seconds)
40
+
41
+ With [uv](https://docs.astral.sh/uv/):
42
+
43
+ ```bash
44
+ uvx --from git+https://github.com/patrickballard/wheelgap wheelgap
45
+ ```
46
+
47
+ Or with pipx:
48
+
49
+ ```bash
50
+ pipx run --spec git+https://github.com/patrickballard/wheelgap wheelgap
51
+ ```
52
+
53
+ Run either one in a project directory. It finds `uv.lock`, `pylock.toml`, `poetry.lock` or `requirements.txt` on its own.
54
+
55
+ ## Usage
56
+
57
+ ```bash
58
+ # Will my Alpine ARM image compile anything?
59
+ wheelgap -p 3.13 -t alpine-arm64
60
+
61
+ # Is my stack ready for free-threaded Python 3.14?
62
+ wheelgap -p 3.14t -t all
63
+
64
+ # Several Pythons at once, Linux on both architectures
65
+ wheelgap uv.lock -p 3.12,3.13,3.14 -t linux-x86_64,linux-aarch64
66
+
67
+ # No lockfile? Check packages directly (latest version, or pin one)
68
+ wheelgap psycopg2 uvloop numpy==2.3.4 -t windows,win-arm64
69
+
70
+ # Machine-readable, for scripts and CI
71
+ wheelgap --json > wheelgap.json
72
+ ```
73
+
74
+ Each cell in the table is one of:
75
+
76
+ | cell | meaning |
77
+ | --- | --- |
78
+ | `✓` | a compatible wheel exists, so the install is fast |
79
+ | `● source` | no wheel, only an sdist, so it compiles at install time (needs a compiler, headers, time) |
80
+ | `✗ missing` | no wheel and no sdist, so the install fails |
81
+ | `–` | not installed on this target (environment markers exclude it) |
82
+
83
+ Packages that are fine everywhere are hidden; `--all` shows them.
84
+
85
+ **Exit codes:** `0` no gaps, `1` gaps found, `2` bad input. `--exit-zero` always exits `0`.
86
+
87
+ ### Targets
88
+
89
+ | name | aliases | wheel tags it accepts |
90
+ | --- | --- | --- |
91
+ | `linux-x86_64` | `linux`, `linux-amd64` | `manylinux_2_*_x86_64` up to `--glibc` (default 2.36), legacy `manylinux2014`/`2010`/`1` |
92
+ | `linux-aarch64` | `linux-arm64` | `manylinux_2_*_aarch64` |
93
+ | `musl-x86_64` | `alpine`, `musl` | `musllinux_1_*_x86_64` up to `--musl` (default 1.2) |
94
+ | `musl-aarch64` | `alpine-arm64` | `musllinux_1_*_aarch64` |
95
+ | `macos-arm64` | `macos`, `mac` | `macosx_*_arm64` and `universal2` up to `--macos` (default 14.0) |
96
+ | `macos-x86_64` | `macos-intel` | `macosx_*_x86_64` and `universal2` |
97
+ | `win-amd64` | `windows`, `win` | `win_amd64` |
98
+ | `win-arm64` | `windows-arm64` | `win_arm64` |
99
+
100
+ `-t all` checks all eight. Python versions take a trailing `t` for free-threaded builds (`3.14t`), which correctly rejects `abi3` wheels. Without `-p`, wheelgap uses the Python it is running on.
101
+
102
+ ### In CI (GitHub Action)
103
+
104
+ ```yaml
105
+ - uses: actions/checkout@v7
106
+ - uses: patrickballard/wheelgap@v0.1.0
107
+ with:
108
+ args: uv.lock -p 3.13 -t linux-x86_64,linux-aarch64
109
+ ```
110
+
111
+ The job fails when a dependency would build from source on a target you ship, for example when a dependency bump drops ARM wheels.
112
+
113
+ ## How it works
114
+
115
+ - **Lockfiles are read offline.** `uv.lock`, `pylock.toml` and `poetry.lock` already record every file published for each pinned version. wheelgap parses the wheel filenames' tags with [`packaging`](https://packaging.pypa.io/) and checks them against the exact tag set each target accepts. That is the same logic pip and uv use to pick a wheel.
116
+ - **`requirements.txt` and bare package names** are looked up on the PyPI JSON API. Feed it a fully pinned file (from `uv pip compile`, `pip-compile` or `uv export`): wheelgap checks the lines you give it and doesn't resolve dependencies.
117
+ - **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.
118
+ - `--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.
119
+
120
+ ## Why this exists
121
+
122
+ Missing wheels show up late, in the most expensive place:
123
+
124
+ - 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.
125
+ - A build fails with `error: Microsoft Visual C++ 14.0 or greater is required` on a teammate's Snapdragon laptop.
126
+ - You try free-threaded Python 3.14t and half your stack quietly falls back to source builds, because `abi3` wheels don't apply there.
127
+
128
+ Sites like [pyreadiness.org](https://pyreadiness.org) and [free-threaded wheels](https://hugovk.github.io/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.
129
+
130
+ ## Roadmap
131
+
132
+ - [ ] Pyodide/WebAssembly, iOS and Android targets
133
+ - [ ] Read targets from `[tool.wheelgap]` in `pyproject.toml`
134
+ - [ ] Private indexes (`--index-url`, the simple API)
135
+ - [ ] Infer the target from a `Dockerfile` (`FROM python:3.13-alpine`, `--platform=linux/arm64`)
136
+ - [ ] "Closest version with a wheel" hints (e.g. upgrade `foo` 1.2 → 1.4 to get `musllinux` wheels)
137
+ - [ ] Publish to PyPI so it's just `uvx wheelgap`
138
+
139
+ Contributions welcome. Issues labelled [good first issue](https://github.com/patrickballard/wheelgap/labels/good%20first%20issue) are a good start, and [CONTRIBUTING.md](CONTRIBUTING.md) has the 3-command dev setup.
140
+
141
+ ## License
142
+
143
+ [MIT](LICENSE)
@@ -0,0 +1,117 @@
1
+ # wheelgap
2
+
3
+ [![CI](https://github.com/patrickballard/wheelgap/actions/workflows/ci.yml/badge.svg)](https://github.com/patrickballard/wheelgap/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+ ![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)
6
+
7
+ **Find the Python dependencies that have no prebuilt wheel for your target platform, before your Docker build does.**
8
+
9
+ 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.
10
+
11
+ ![wheelgap demo](docs/demo.gif)
12
+
13
+ ## Install and run (under 30 seconds)
14
+
15
+ With [uv](https://docs.astral.sh/uv/):
16
+
17
+ ```bash
18
+ uvx --from git+https://github.com/patrickballard/wheelgap wheelgap
19
+ ```
20
+
21
+ Or with pipx:
22
+
23
+ ```bash
24
+ pipx run --spec git+https://github.com/patrickballard/wheelgap wheelgap
25
+ ```
26
+
27
+ Run either one in a project directory. It finds `uv.lock`, `pylock.toml`, `poetry.lock` or `requirements.txt` on its own.
28
+
29
+ ## Usage
30
+
31
+ ```bash
32
+ # Will my Alpine ARM image compile anything?
33
+ wheelgap -p 3.13 -t alpine-arm64
34
+
35
+ # Is my stack ready for free-threaded Python 3.14?
36
+ wheelgap -p 3.14t -t all
37
+
38
+ # Several Pythons at once, Linux on both architectures
39
+ wheelgap uv.lock -p 3.12,3.13,3.14 -t linux-x86_64,linux-aarch64
40
+
41
+ # No lockfile? Check packages directly (latest version, or pin one)
42
+ wheelgap psycopg2 uvloop numpy==2.3.4 -t windows,win-arm64
43
+
44
+ # Machine-readable, for scripts and CI
45
+ wheelgap --json > wheelgap.json
46
+ ```
47
+
48
+ Each cell in the table is one of:
49
+
50
+ | cell | meaning |
51
+ | --- | --- |
52
+ | `✓` | a compatible wheel exists, so the install is fast |
53
+ | `● source` | no wheel, only an sdist, so it compiles at install time (needs a compiler, headers, time) |
54
+ | `✗ missing` | no wheel and no sdist, so the install fails |
55
+ | `–` | not installed on this target (environment markers exclude it) |
56
+
57
+ Packages that are fine everywhere are hidden; `--all` shows them.
58
+
59
+ **Exit codes:** `0` no gaps, `1` gaps found, `2` bad input. `--exit-zero` always exits `0`.
60
+
61
+ ### Targets
62
+
63
+ | name | aliases | wheel tags it accepts |
64
+ | --- | --- | --- |
65
+ | `linux-x86_64` | `linux`, `linux-amd64` | `manylinux_2_*_x86_64` up to `--glibc` (default 2.36), legacy `manylinux2014`/`2010`/`1` |
66
+ | `linux-aarch64` | `linux-arm64` | `manylinux_2_*_aarch64` |
67
+ | `musl-x86_64` | `alpine`, `musl` | `musllinux_1_*_x86_64` up to `--musl` (default 1.2) |
68
+ | `musl-aarch64` | `alpine-arm64` | `musllinux_1_*_aarch64` |
69
+ | `macos-arm64` | `macos`, `mac` | `macosx_*_arm64` and `universal2` up to `--macos` (default 14.0) |
70
+ | `macos-x86_64` | `macos-intel` | `macosx_*_x86_64` and `universal2` |
71
+ | `win-amd64` | `windows`, `win` | `win_amd64` |
72
+ | `win-arm64` | `windows-arm64` | `win_arm64` |
73
+
74
+ `-t all` checks all eight. Python versions take a trailing `t` for free-threaded builds (`3.14t`), which correctly rejects `abi3` wheels. Without `-p`, wheelgap uses the Python it is running on.
75
+
76
+ ### In CI (GitHub Action)
77
+
78
+ ```yaml
79
+ - uses: actions/checkout@v7
80
+ - uses: patrickballard/wheelgap@v0.1.0
81
+ with:
82
+ args: uv.lock -p 3.13 -t linux-x86_64,linux-aarch64
83
+ ```
84
+
85
+ The job fails when a dependency would build from source on a target you ship, for example when a dependency bump drops ARM wheels.
86
+
87
+ ## How it works
88
+
89
+ - **Lockfiles are read offline.** `uv.lock`, `pylock.toml` and `poetry.lock` already record every file published for each pinned version. wheelgap parses the wheel filenames' tags with [`packaging`](https://packaging.pypa.io/) and checks them against the exact tag set each target accepts. That is the same logic pip and uv use to pick a wheel.
90
+ - **`requirements.txt` and bare package names** are looked up on the PyPI JSON API. Feed it a fully pinned file (from `uv pip compile`, `pip-compile` or `uv export`): wheelgap checks the lines you give it and doesn't resolve dependencies.
91
+ - **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.
92
+ - `--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.
93
+
94
+ ## Why this exists
95
+
96
+ Missing wheels show up late, in the most expensive place:
97
+
98
+ - 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.
99
+ - A build fails with `error: Microsoft Visual C++ 14.0 or greater is required` on a teammate's Snapdragon laptop.
100
+ - You try free-threaded Python 3.14t and half your stack quietly falls back to source builds, because `abi3` wheels don't apply there.
101
+
102
+ Sites like [pyreadiness.org](https://pyreadiness.org) and [free-threaded wheels](https://hugovk.github.io/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.
103
+
104
+ ## Roadmap
105
+
106
+ - [ ] Pyodide/WebAssembly, iOS and Android targets
107
+ - [ ] Read targets from `[tool.wheelgap]` in `pyproject.toml`
108
+ - [ ] Private indexes (`--index-url`, the simple API)
109
+ - [ ] Infer the target from a `Dockerfile` (`FROM python:3.13-alpine`, `--platform=linux/arm64`)
110
+ - [ ] "Closest version with a wheel" hints (e.g. upgrade `foo` 1.2 → 1.4 to get `musllinux` wheels)
111
+ - [ ] Publish to PyPI so it's just `uvx wheelgap`
112
+
113
+ Contributions welcome. Issues labelled [good first issue](https://github.com/patrickballard/wheelgap/labels/good%20first%20issue) are a good start, and [CONTRIBUTING.md](CONTRIBUTING.md) has the 3-command dev setup.
114
+
115
+ ## License
116
+
117
+ [MIT](LICENSE)
@@ -0,0 +1,25 @@
1
+ name: wheelgap
2
+ description: Fail CI when a Python dependency has no prebuilt wheel for the platforms you ship to.
3
+ author: patrickballard
4
+ branding:
5
+ icon: package
6
+ color: orange
7
+
8
+ inputs:
9
+ args:
10
+ description: >
11
+ Arguments passed to wheelgap, e.g. "uv.lock -p 3.13 -t linux-aarch64,musl-x86_64".
12
+ Pass -p explicitly: otherwise the runner's own Python version is assumed.
13
+ required: false
14
+ default: ""
15
+
16
+ runs:
17
+ using: composite
18
+ steps:
19
+ - name: Run wheelgap
20
+ shell: bash
21
+ env:
22
+ WHEELGAP_ARGS: ${{ inputs.args }}
23
+ FORCE_COLOR: "1"
24
+ # WHEELGAP_ARGS is deliberately unquoted so it splits into separate arguments.
25
+ run: pipx run --spec "$GITHUB_ACTION_PATH" wheelgap $WHEELGAP_ARGS
@@ -0,0 +1,15 @@
1
+ fastapi
2
+ uvicorn[standard]
3
+ uvloop
4
+ httptools
5
+ psycopg2
6
+ psycopg-binary
7
+ pydantic-core
8
+ numpy
9
+ pandas
10
+ orjson
11
+ lxml
12
+ grpcio
13
+ cryptography
14
+ pyyaml
15
+ regex
Binary file
Binary file
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "wheelgap"
3
+ version = "0.1.0"
4
+ description = "Find the Python dependencies that have no prebuilt wheel for your target platform, before your Docker build does."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Patrick Ballard" }]
9
+ requires-python = ">=3.11"
10
+ dependencies = ["packaging>=24.2"]
11
+ keywords = ["wheels", "packaging", "pypi", "uv", "lockfile", "docker", "alpine", "musllinux", "free-threading", "arm64"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Environment :: Console",
15
+ "Intended Audience :: Developers",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
+ "Topic :: Software Development :: Build Tools",
23
+ "Topic :: System :: Software Distribution",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/patrickballard/wheelgap"
28
+ Issues = "https://github.com/patrickballard/wheelgap/issues"
29
+ Changelog = "https://github.com/patrickballard/wheelgap/blob/main/CHANGELOG.md"
30
+
31
+ [project.scripts]
32
+ wheelgap = "wheelgap.cli:main"
33
+
34
+ [dependency-groups]
35
+ dev = ["pytest>=8", "ruff>=0.8"]
36
+
37
+ [build-system]
38
+ requires = ["hatchling>=1.27"]
39
+ build-backend = "hatchling.build"
40
+
41
+ [tool.ruff]
42
+ line-length = 120
43
+ target-version = "py311"
44
+
45
+ [tool.ruff.lint]
46
+ select = ["E", "F", "I", "UP", "B"]
47
+
48
+ [tool.pytest.ini_options]
49
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ """wheelgap: find the dependencies that have no prebuilt wheel for your target platform."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,134 @@
1
+ """Decide, per package and target, whether a prebuilt wheel exists."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import StrEnum
7
+
8
+ from packaging.markers import InvalidMarker, Marker, UndefinedComparison, UndefinedEnvironmentName
9
+ from packaging.tags import Tag
10
+ from packaging.utils import InvalidWheelFilename, canonicalize_name, parse_wheel_filename
11
+
12
+ from .sources import Edge, Package, Source
13
+ from .targets import Target
14
+
15
+
16
+ class Status(StrEnum):
17
+ WHEEL = "wheel" # a compatible wheel exists
18
+ SOURCE = "source" # no compatible wheel, but an sdist: installers will build it
19
+ MISSING = "missing" # no compatible wheel and no sdist: install fails
20
+ UNKNOWN = "unknown" # couldn't look the package up
21
+ NOT_NEEDED = "n/a" # markers say this package isn't installed on the target
22
+
23
+
24
+ SDIST_SUFFIXES = (".tar.gz", ".zip", ".tar.bz2", ".tgz", ".tar.xz")
25
+
26
+
27
+ @dataclass
28
+ class Row:
29
+ package: Package
30
+ results: dict[Target, Status] = field(default_factory=dict)
31
+
32
+ @property
33
+ def ok(self) -> bool:
34
+ return all(s in (Status.WHEEL, Status.NOT_NEEDED) for s in self.results.values())
35
+
36
+ @property
37
+ def needed(self) -> bool:
38
+ return any(s is not Status.NOT_NEEDED for s in self.results.values())
39
+
40
+
41
+ def wheel_tags(files: list[str]) -> list[frozenset[Tag]]:
42
+ out = []
43
+ for f in files:
44
+ if not f.endswith(".whl"):
45
+ continue
46
+ try:
47
+ out.append(parse_wheel_filename(f)[3])
48
+ except InvalidWheelFilename:
49
+ continue
50
+ return out
51
+
52
+
53
+ def has_sdist(files: list[str]) -> bool:
54
+ return any(f.endswith(SDIST_SUFFIXES) for f in files)
55
+
56
+
57
+ def status_for(files: list[str] | None, target: Target, _wheels: list[frozenset[Tag]] | None = None) -> Status:
58
+ if files is None:
59
+ return Status.UNKNOWN
60
+ wheels = _wheels if _wheels is not None else wheel_tags(files)
61
+ if any(target.accepts(t) for t in wheels):
62
+ return Status.WHEEL
63
+ return Status.SOURCE if has_sdist(files) else Status.MISSING
64
+
65
+
66
+ _marker_cache: dict[str, Marker | None] = {}
67
+
68
+
69
+ def marker_holds(marker: str | None, target: Target) -> bool:
70
+ if not marker:
71
+ return True
72
+ if marker not in _marker_cache:
73
+ try:
74
+ _marker_cache[marker] = Marker(marker)
75
+ except InvalidMarker:
76
+ _marker_cache[marker] = None
77
+ m = _marker_cache[marker]
78
+ if m is None:
79
+ return True # can't parse it: assume the package is needed rather than hide a gap
80
+ try:
81
+ return m.evaluate(target.marker_env)
82
+ except (UndefinedComparison, UndefinedEnvironmentName):
83
+ return True
84
+
85
+
86
+ def reachable(source: Source, target: Target) -> set[int]:
87
+ """Indexes of packages installed on `target`, walking the lockfile graph from its roots."""
88
+ by_name: dict[str, list[int]] = {}
89
+ for i, p in enumerate(source.packages):
90
+ by_name.setdefault(p.key, []).append(i)
91
+
92
+ def follow(edge: Edge) -> list[int]:
93
+ name, version, marker = edge
94
+ if not marker_holds(marker, target):
95
+ return []
96
+ candidates = by_name.get(canonicalize_name(name), [])
97
+ if version is not None:
98
+ candidates = [i for i in candidates if source.packages[i].version == version] or candidates
99
+ return candidates
100
+
101
+ seen = set(source.roots)
102
+ stack = list(source.roots)
103
+ for edge in source.root_edges:
104
+ stack.extend(follow(edge))
105
+ while stack:
106
+ i = stack.pop()
107
+ seen.add(i)
108
+ for edge in (source.edges or {}).get(i, []):
109
+ for j in follow(edge):
110
+ if j not in seen:
111
+ seen.add(j)
112
+ stack.append(j)
113
+ return seen
114
+
115
+
116
+ def applies(pkg: Package, target: Target) -> bool:
117
+ return pkg.markers is None or any(marker_holds(m, target) for m in pkg.markers)
118
+
119
+
120
+ def check(sources: list[Source], targets: list[Target]) -> list[Row]:
121
+ rows = []
122
+ for source in sources:
123
+ installed = {t: reachable(source, t) for t in targets} if source.edges is not None else None
124
+ for i, pkg in enumerate(source.packages):
125
+ if pkg.skip:
126
+ continue
127
+ files = None if pkg.error else pkg.files
128
+ wheels = wheel_tags(files) if files is not None else []
129
+ row = Row(pkg)
130
+ for t in targets:
131
+ needed = i in installed[t] if installed is not None else applies(pkg, t)
132
+ row.results[t] = status_for(files, t, wheels) if needed else Status.NOT_NEEDED
133
+ rows.append(row)
134
+ return rows