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.
- wheelgap-0.1.0/.github/workflows/ci.yml +50 -0
- wheelgap-0.1.0/.github/workflows/publish.yml +43 -0
- wheelgap-0.1.0/.gitignore +8 -0
- wheelgap-0.1.0/CHANGELOG.md +11 -0
- wheelgap-0.1.0/CONTRIBUTING.md +14 -0
- wheelgap-0.1.0/LICENSE +21 -0
- wheelgap-0.1.0/PKG-INFO +143 -0
- wheelgap-0.1.0/README.md +117 -0
- wheelgap-0.1.0/action.yml +25 -0
- wheelgap-0.1.0/docs/demo/requirements.txt +15 -0
- wheelgap-0.1.0/docs/demo.gif +0 -0
- wheelgap-0.1.0/docs/social-preview.png +0 -0
- wheelgap-0.1.0/pyproject.toml +49 -0
- wheelgap-0.1.0/src/wheelgap/__init__.py +3 -0
- wheelgap-0.1.0/src/wheelgap/__main__.py +5 -0
- wheelgap-0.1.0/src/wheelgap/check.py +134 -0
- wheelgap-0.1.0/src/wheelgap/cli.py +248 -0
- wheelgap-0.1.0/src/wheelgap/pypi.py +61 -0
- wheelgap-0.1.0/src/wheelgap/sources.py +241 -0
- wheelgap-0.1.0/src/wheelgap/targets.py +207 -0
- wheelgap-0.1.0/tests/conftest.py +44 -0
- wheelgap-0.1.0/tests/fixtures/empty/.keep +0 -0
- wheelgap-0.1.0/tests/fixtures/poetryproj/poetry.lock +43 -0
- wheelgap-0.1.0/tests/fixtures/pylockproj/pylock.toml +15 -0
- wheelgap-0.1.0/tests/fixtures/reqproj/extra.txt +1 -0
- wheelgap-0.1.0/tests/fixtures/reqproj/requirements.txt +8 -0
- wheelgap-0.1.0/tests/fixtures/uvproj/uv.lock +79 -0
- wheelgap-0.1.0/tests/test_check.py +104 -0
- wheelgap-0.1.0/tests/test_cli.py +67 -0
- wheelgap-0.1.0/tests/test_sources.py +78 -0
- wheelgap-0.1.0/tests/test_targets.py +127 -0
- wheelgap-0.1.0/uv.lock +112 -0
|
@@ -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,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.
|
wheelgap-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://github.com/patrickballard/wheelgap/actions/workflows/ci.yml)
|
|
30
|
+
[](LICENSE)
|
|
31
|
+

|
|
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
|
+

|
|
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)
|
wheelgap-0.1.0/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# wheelgap
|
|
2
|
+
|
|
3
|
+
[](https://github.com/patrickballard/wheelgap/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+

|
|
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
|
+

|
|
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
|
|
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,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
|