wheelgap 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- wheelgap/__main__.py +5 -0
- wheelgap/check.py +134 -0
- wheelgap/cli.py +248 -0
- wheelgap/pypi.py +61 -0
- wheelgap/sources.py +241 -0
- wheelgap/targets.py +207 -0
- wheelgap-0.1.0.dist-info/METADATA +143 -0
- wheelgap-0.1.0.dist-info/RECORD +12 -0
- wheelgap-0.1.0.dist-info/WHEEL +4 -0
- wheelgap-0.1.0.dist-info/entry_points.txt +2 -0
- wheelgap-0.1.0.dist-info/licenses/LICENSE +21 -0
wheelgap/__init__.py
ADDED
wheelgap/__main__.py
ADDED
wheelgap/check.py
ADDED
|
@@ -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
|
wheelgap/cli.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Command-line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import sysconfig
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .check import Row, Status, check
|
|
13
|
+
from .pypi import fill_all
|
|
14
|
+
from .sources import SourceError, load_inputs
|
|
15
|
+
from .targets import DEFAULT_PLATFORMS, PLATFORMS, Target, TargetError, build_targets
|
|
16
|
+
|
|
17
|
+
EXAMPLES = """\
|
|
18
|
+
examples:
|
|
19
|
+
wheelgap check the lockfile in the current directory
|
|
20
|
+
wheelgap uv.lock -t musl-aarch64 will my Alpine ARM image build from source?
|
|
21
|
+
wheelgap -p 3.14t -t all is my stack ready for free-threaded 3.14?
|
|
22
|
+
wheelgap psycopg2 uvloop -t win-amd64 check packages without a lockfile
|
|
23
|
+
wheelgap requirements.txt --json machine-readable output for CI
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def running_python() -> str:
|
|
28
|
+
ft = "t" if sysconfig.get_config_var("Py_GIL_DISABLED") else ""
|
|
29
|
+
return f"{sys.version_info.major}.{sys.version_info.minor}{ft}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|
33
|
+
p = argparse.ArgumentParser(
|
|
34
|
+
prog="wheelgap",
|
|
35
|
+
description="Find the dependencies that have no prebuilt wheel for your target platform, "
|
|
36
|
+
"so they'd be compiled from source (slow) or fail to install.",
|
|
37
|
+
epilog=EXAMPLES,
|
|
38
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
39
|
+
)
|
|
40
|
+
p.add_argument(
|
|
41
|
+
"inputs",
|
|
42
|
+
nargs="*",
|
|
43
|
+
metavar="INPUT",
|
|
44
|
+
help="uv.lock, pylock.toml, poetry.lock, requirements.txt, a directory, or package specs (default: .)",
|
|
45
|
+
)
|
|
46
|
+
p.add_argument(
|
|
47
|
+
"-p",
|
|
48
|
+
"--python",
|
|
49
|
+
action="append",
|
|
50
|
+
default=[],
|
|
51
|
+
metavar="VER",
|
|
52
|
+
help=f"target Python, e.g. 3.13 or 3.14t (repeatable; default: {running_python()})",
|
|
53
|
+
)
|
|
54
|
+
p.add_argument(
|
|
55
|
+
"-t",
|
|
56
|
+
"--target",
|
|
57
|
+
action="append",
|
|
58
|
+
default=[],
|
|
59
|
+
metavar="PLATFORM",
|
|
60
|
+
help=f"target platform (repeatable, or 'all'; default: {','.join(DEFAULT_PLATFORMS)})",
|
|
61
|
+
)
|
|
62
|
+
p.add_argument("--glibc", default="2.36", metavar="X.Y", help="glibc version for linux-* targets (default: 2.36)")
|
|
63
|
+
p.add_argument("--musl", default="1.2", metavar="X.Y", help="musl version for musl-* targets (default: 1.2)")
|
|
64
|
+
p.add_argument("--macos", default="14.0", metavar="X.Y", help="minimum macOS for macos-* targets (default: 14.0)")
|
|
65
|
+
p.add_argument(
|
|
66
|
+
"--online", action="store_true", help="re-check lockfile packages against PyPI instead of trusting the lockfile"
|
|
67
|
+
)
|
|
68
|
+
p.add_argument("--all", action="store_true", help="show every package, not just the ones with gaps")
|
|
69
|
+
p.add_argument("--json", action="store_true", help="print JSON instead of a table")
|
|
70
|
+
p.add_argument("--exit-zero", action="store_true", help="exit 0 even when gaps are found")
|
|
71
|
+
p.add_argument("--color", choices=["auto", "always", "never"], default="auto")
|
|
72
|
+
p.add_argument("--list-targets", action="store_true", help="list platform names and exit")
|
|
73
|
+
p.add_argument("-V", "--version", action="version", version=f"wheelgap {__version__}")
|
|
74
|
+
return p.parse_args(argv)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Style:
|
|
78
|
+
def __init__(self, enabled: bool):
|
|
79
|
+
self.enabled = enabled
|
|
80
|
+
|
|
81
|
+
def __call__(self, text: str, code: str) -> str:
|
|
82
|
+
return f"\033[{code}m{text}\033[0m" if self.enabled else text
|
|
83
|
+
|
|
84
|
+
def green(self, t: str) -> str:
|
|
85
|
+
return self(t, "32")
|
|
86
|
+
|
|
87
|
+
def yellow(self, t: str) -> str:
|
|
88
|
+
return self(t, "33")
|
|
89
|
+
|
|
90
|
+
def red(self, t: str) -> str:
|
|
91
|
+
return self(t, "31")
|
|
92
|
+
|
|
93
|
+
def dim(self, t: str) -> str:
|
|
94
|
+
return self(t, "2")
|
|
95
|
+
|
|
96
|
+
def bold(self, t: str) -> str:
|
|
97
|
+
return self(t, "1")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def use_color(mode: str) -> bool:
|
|
101
|
+
if mode != "auto":
|
|
102
|
+
return mode == "always"
|
|
103
|
+
if os.environ.get("NO_COLOR"):
|
|
104
|
+
return False
|
|
105
|
+
if os.environ.get("FORCE_COLOR"):
|
|
106
|
+
return True
|
|
107
|
+
return sys.stdout.isatty()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
CELL = {
|
|
111
|
+
Status.WHEEL: ("✓", "green"),
|
|
112
|
+
Status.SOURCE: ("● source", "yellow"),
|
|
113
|
+
Status.MISSING: ("✗ missing", "red"),
|
|
114
|
+
Status.UNKNOWN: ("? unknown", "dim"),
|
|
115
|
+
Status.NOT_NEEDED: ("–", "dim"),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def render_table(rows: list[Row], targets: list[Target], style: Style) -> list[str]:
|
|
120
|
+
multi_py = len({t.python for t in targets}) > 1
|
|
121
|
+
headers = ["package", "version"] + [t.label(multi_py) for t in targets]
|
|
122
|
+
body = []
|
|
123
|
+
for r in rows:
|
|
124
|
+
cells = [r.package.name, r.package.version or "?"]
|
|
125
|
+
cells += [CELL[r.results[t]][0] for t in targets]
|
|
126
|
+
body.append(cells)
|
|
127
|
+
widths = [max(len(h), *(len(c[i]) for c in body)) for i, h in enumerate(headers)]
|
|
128
|
+
|
|
129
|
+
def line(cells: list[str], colors: list[str] | None = None) -> str:
|
|
130
|
+
parts = []
|
|
131
|
+
for i, c in enumerate(cells):
|
|
132
|
+
padded = c.ljust(widths[i])
|
|
133
|
+
if colors and colors[i]:
|
|
134
|
+
padded = getattr(style, colors[i])(padded)
|
|
135
|
+
parts.append(padded)
|
|
136
|
+
return " ".join(parts).rstrip()
|
|
137
|
+
|
|
138
|
+
out = [style.bold(line(headers))]
|
|
139
|
+
for r, cells in zip(rows, body, strict=True):
|
|
140
|
+
colors = ["", "dim"] + [CELL[r.results[t]][1] for t in targets]
|
|
141
|
+
out.append(line(cells, colors))
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def report_json(rows: list[Row], targets: list[Target], sources: list[str], skipped: list) -> str:
|
|
146
|
+
multi_py = len({t.python for t in targets}) > 1
|
|
147
|
+
gaps = [r for r in rows if not r.ok]
|
|
148
|
+
return json.dumps(
|
|
149
|
+
{
|
|
150
|
+
"version": __version__,
|
|
151
|
+
"sources": sources,
|
|
152
|
+
"targets": [{"label": t.label(multi_py), "python": t.python, "platform": t.platform} for t in targets],
|
|
153
|
+
"packages": [
|
|
154
|
+
{
|
|
155
|
+
"name": r.package.name,
|
|
156
|
+
"version": r.package.version,
|
|
157
|
+
"error": r.package.error,
|
|
158
|
+
"results": {t.label(multi_py): r.results[t].value for t in targets},
|
|
159
|
+
}
|
|
160
|
+
for r in rows
|
|
161
|
+
],
|
|
162
|
+
"skipped": [{"name": p.name, "reason": p.skip} for p in skipped],
|
|
163
|
+
"summary": {"packages": len(rows), "with_gaps": len(gaps)},
|
|
164
|
+
},
|
|
165
|
+
indent=2,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main(argv: list[str] | None = None) -> int:
|
|
170
|
+
for stream in (sys.stdout, sys.stderr):
|
|
171
|
+
# Don't crash printing ✓ to a non-UTF-8 pipe (e.g. Windows cp1252).
|
|
172
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
173
|
+
if reconfigure:
|
|
174
|
+
reconfigure(errors="replace")
|
|
175
|
+
args = parse_args(argv)
|
|
176
|
+
if args.list_targets:
|
|
177
|
+
for name, (family, arch) in PLATFORMS.items():
|
|
178
|
+
print(f"{name:<14} {family} {arch}")
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
targets = build_targets(args.python or [running_python()], args.target, args.glibc, args.musl, args.macos)
|
|
183
|
+
sources = load_inputs(args.inputs or ["."])
|
|
184
|
+
except (TargetError, SourceError, OSError) as e:
|
|
185
|
+
print(f"wheelgap: error: {e}", file=sys.stderr)
|
|
186
|
+
return 2
|
|
187
|
+
except Exception as e: # malformed TOML and friends
|
|
188
|
+
print(f"wheelgap: error: {type(e).__name__}: {e}", file=sys.stderr)
|
|
189
|
+
return 2
|
|
190
|
+
|
|
191
|
+
packages = [p for s in sources for p in s.packages]
|
|
192
|
+
fill_all(packages, refresh=args.online)
|
|
193
|
+
skipped = [p for p in packages if p.skip]
|
|
194
|
+
rows = [r for r in check(sources, targets) if r.needed]
|
|
195
|
+
rows.sort(key=lambda r: r.package.key)
|
|
196
|
+
gaps = [r for r in rows if not r.ok]
|
|
197
|
+
source_labels = [s.label for s in sources]
|
|
198
|
+
|
|
199
|
+
if args.json:
|
|
200
|
+
print(report_json(rows, targets, source_labels, skipped))
|
|
201
|
+
return 0 if args.exit_zero or not gaps else 1
|
|
202
|
+
|
|
203
|
+
style = Style(use_color(args.color))
|
|
204
|
+
pys = ", ".join(dict.fromkeys(t.python for t in targets))
|
|
205
|
+
print(style.dim(f"wheelgap {__version__} · {' + '.join(source_labels)} · {len(rows)} packages · Python {pys}"))
|
|
206
|
+
print()
|
|
207
|
+
|
|
208
|
+
shown = rows if args.all else gaps
|
|
209
|
+
if shown:
|
|
210
|
+
for ln in render_table(shown, targets, style):
|
|
211
|
+
print(ln)
|
|
212
|
+
print()
|
|
213
|
+
hidden = len(rows) - len(shown)
|
|
214
|
+
if hidden:
|
|
215
|
+
noun = "package" if hidden == 1 else "packages"
|
|
216
|
+
print(style.dim(f"{hidden} {noun} with wheels for every target hidden (--all to show)"))
|
|
217
|
+
|
|
218
|
+
if skipped:
|
|
219
|
+
names = ", ".join(f"{p.name} ({p.skip})" for p in skipped)
|
|
220
|
+
print(style.dim(f"skipped {len(skipped)} non-index package{'s' * (len(skipped) != 1)}: {names}"))
|
|
221
|
+
|
|
222
|
+
errors = [r for r in rows if r.package.error]
|
|
223
|
+
for r in errors:
|
|
224
|
+
print(style.dim(f"could not check {r.package.name}: {r.package.error}"))
|
|
225
|
+
|
|
226
|
+
n_t = len(targets)
|
|
227
|
+
if not gaps:
|
|
228
|
+
what = "The 1 package has" if len(rows) == 1 else f"All {len(rows)} packages have"
|
|
229
|
+
print(style.green(f"✓ {what} a wheel for {'every one of ' if n_t > 1 else ''}{n_t} target{'s' * (n_t > 1)}."))
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
source = sum(any(s is Status.SOURCE for s in r.results.values()) for r in gaps)
|
|
233
|
+
missing = sum(any(s is Status.MISSING for s in r.results.values()) for r in gaps)
|
|
234
|
+
parts = []
|
|
235
|
+
if source:
|
|
236
|
+
parts.append(style.yellow(f"{source} will build from source"))
|
|
237
|
+
if missing:
|
|
238
|
+
parts.append(style.red(f"{missing} can't install at all"))
|
|
239
|
+
unknown = len(gaps) - source - missing
|
|
240
|
+
if unknown > 0:
|
|
241
|
+
parts.append(style.dim(f"{unknown} unknown"))
|
|
242
|
+
verb = "has" if len(gaps) == 1 else "have"
|
|
243
|
+
print(f"{style.bold(f'{len(gaps)} of {len(rows)} packages')} {verb} gaps: " + ", ".join(parts) + ".")
|
|
244
|
+
return 0 if args.exit_zero else 1
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__": # pragma: no cover
|
|
248
|
+
sys.exit(main())
|
wheelgap/pypi.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Fill in distribution filenames from the PyPI JSON API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .sources import Package
|
|
14
|
+
|
|
15
|
+
DEFAULT_INDEX = "https://pypi.org/pypi"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def index_url() -> str:
|
|
19
|
+
return os.environ.get("WHEELGAP_INDEX_URL", DEFAULT_INDEX).rstrip("/")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fetch_json(url: str, timeout: float = 20.0) -> dict:
|
|
23
|
+
req = urllib.request.Request(url, headers={"User-Agent": f"wheelgap/{__version__}", "Accept": "application/json"})
|
|
24
|
+
last: Exception | None = None
|
|
25
|
+
for _ in range(3):
|
|
26
|
+
try:
|
|
27
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
28
|
+
return json.load(resp)
|
|
29
|
+
except urllib.error.HTTPError as e:
|
|
30
|
+
if e.code == 404:
|
|
31
|
+
raise
|
|
32
|
+
last = e
|
|
33
|
+
except (urllib.error.URLError, TimeoutError) as e:
|
|
34
|
+
last = e
|
|
35
|
+
raise last # type: ignore[misc]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def fill(pkg: Package) -> Package:
|
|
39
|
+
base = index_url()
|
|
40
|
+
name = quote(pkg.key)
|
|
41
|
+
url = f"{base}/{name}/{quote(pkg.version)}/json" if pkg.version else f"{base}/{name}/json"
|
|
42
|
+
try:
|
|
43
|
+
data = fetch_json(url)
|
|
44
|
+
except urllib.error.HTTPError as e:
|
|
45
|
+
pkg.error = "not on index" if e.code == 404 else f"HTTP {e.code}"
|
|
46
|
+
return pkg
|
|
47
|
+
except Exception as e: # network trouble shouldn't kill the whole report
|
|
48
|
+
pkg.error = f"lookup failed ({type(e).__name__})"
|
|
49
|
+
return pkg
|
|
50
|
+
if not pkg.version:
|
|
51
|
+
pkg.version = data.get("info", {}).get("version")
|
|
52
|
+
pkg.files = [u["filename"] for u in data.get("urls", []) if not u.get("yanked")]
|
|
53
|
+
return pkg
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def fill_all(packages: list[Package], refresh: bool = False, workers: int = 16) -> None:
|
|
57
|
+
todo = [p for p in packages if not p.skip and (refresh or p.files is None)]
|
|
58
|
+
if not todo:
|
|
59
|
+
return
|
|
60
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
61
|
+
list(pool.map(fill, todo))
|
wheelgap/sources.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Read packages (and, where the lockfile records them, their distribution files) from inputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import unquote, urlparse
|
|
9
|
+
|
|
10
|
+
from packaging.requirements import InvalidRequirement, Requirement
|
|
11
|
+
from packaging.utils import canonicalize_name
|
|
12
|
+
|
|
13
|
+
LOCKFILES = ("uv.lock", "pylock.toml", "poetry.lock", "requirements.txt")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SourceError(ValueError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Package:
|
|
22
|
+
name: str
|
|
23
|
+
version: str | None
|
|
24
|
+
# Distribution filenames. None means "unknown, ask the index".
|
|
25
|
+
files: list[str] | None = None
|
|
26
|
+
# Set for packages that don't come from an index (editable, git, path, ...).
|
|
27
|
+
skip: str | None = None
|
|
28
|
+
error: str | None = None
|
|
29
|
+
origin: str = ""
|
|
30
|
+
# Environment markers; the package is installed if any of them is true. None means always.
|
|
31
|
+
markers: list[str] | None = None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def key(self) -> str:
|
|
35
|
+
return canonicalize_name(self.name)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# A dependency edge in a lockfile graph: (name, version or None, marker or None)
|
|
39
|
+
Edge = tuple[str, str | None, str | None]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Source:
|
|
44
|
+
label: str
|
|
45
|
+
packages: list[Package] = field(default_factory=list)
|
|
46
|
+
# uv.lock only: which packages each package depends on, and where the walk starts.
|
|
47
|
+
# When set, a package applies to a target only if it's reachable along edges whose markers hold.
|
|
48
|
+
edges: dict[int, list[Edge]] | None = None
|
|
49
|
+
roots: list[int] = field(default_factory=list)
|
|
50
|
+
root_edges: list[Edge] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _filename(entry: dict) -> str | None:
|
|
54
|
+
for k in ("filename", "file", "name"):
|
|
55
|
+
if isinstance(entry.get(k), str) and entry[k].endswith((".whl", ".tar.gz", ".zip", ".tar.bz2", ".tgz")):
|
|
56
|
+
return entry[k]
|
|
57
|
+
url = entry.get("url")
|
|
58
|
+
if isinstance(url, str):
|
|
59
|
+
return unquote(Path(urlparse(url).path).name)
|
|
60
|
+
path = entry.get("path")
|
|
61
|
+
if isinstance(path, str):
|
|
62
|
+
return Path(path).name
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _uv_edges(p: dict) -> list[Edge]:
|
|
67
|
+
groups = [p.get("dependencies", [])]
|
|
68
|
+
for table in ("optional-dependencies", "dev-dependencies"):
|
|
69
|
+
groups.extend(p.get(table, {}).values())
|
|
70
|
+
return [(d["name"], d.get("version"), d.get("marker")) for g in groups for d in g]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_uv_lock(path: Path) -> Source:
|
|
74
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
75
|
+
manifest = data.get("manifest", {})
|
|
76
|
+
members = {canonicalize_name(m) for m in manifest.get("members", [])}
|
|
77
|
+
source = Source(path.name, edges={})
|
|
78
|
+
for p in data.get("package", []):
|
|
79
|
+
src = p.get("source", {})
|
|
80
|
+
pkg = Package(p["name"], p.get("version"), origin=path.name)
|
|
81
|
+
kind = next((k for k in ("editable", "virtual", "git", "path", "directory", "url") if k in src), None)
|
|
82
|
+
if kind and "registry" not in src:
|
|
83
|
+
pkg.skip = kind
|
|
84
|
+
else:
|
|
85
|
+
files = [f for f in (_filename(w) for w in p.get("wheels", [])) if f]
|
|
86
|
+
if p.get("sdist"):
|
|
87
|
+
files.append(_filename(p["sdist"]) or f"{p['name']}-{p.get('version')}.tar.gz")
|
|
88
|
+
pkg.files = files
|
|
89
|
+
i = len(source.packages)
|
|
90
|
+
source.packages.append(pkg)
|
|
91
|
+
source.edges[i] = _uv_edges(p)
|
|
92
|
+
if kind in ("editable", "virtual", "directory") or pkg.key in members:
|
|
93
|
+
source.roots.append(i)
|
|
94
|
+
for group in manifest.get("dependency-groups", {}).values():
|
|
95
|
+
source.root_edges.extend((d["name"], None, d.get("marker")) for d in group)
|
|
96
|
+
source.root_edges.extend((d["name"], None, d.get("marker")) for d in manifest.get("requirements", []))
|
|
97
|
+
if not source.roots and not source.root_edges:
|
|
98
|
+
source.edges = None # no idea where the graph starts; treat every package as needed
|
|
99
|
+
return source
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_pylock(path: Path) -> Source:
|
|
103
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
104
|
+
pkgs = []
|
|
105
|
+
for p in data.get("packages", []):
|
|
106
|
+
pkg = Package(p["name"], p.get("version"), origin=path.name)
|
|
107
|
+
if p.get("marker"):
|
|
108
|
+
pkg.markers = [p["marker"]]
|
|
109
|
+
if any(k in p for k in ("vcs", "directory", "archive")) and "wheels" not in p and "sdist" not in p:
|
|
110
|
+
pkg.skip = next(k for k in ("vcs", "directory", "archive") if k in p)
|
|
111
|
+
else:
|
|
112
|
+
files = [f for f in (_filename(w) for w in p.get("wheels", [])) if f]
|
|
113
|
+
if p.get("sdist"):
|
|
114
|
+
files.append(_filename(p["sdist"]) or f"{p['name']}-{p.get('version')}.tar.gz")
|
|
115
|
+
pkg.files = files
|
|
116
|
+
pkgs.append(pkg)
|
|
117
|
+
return Source(path.name, pkgs)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_poetry_lock(path: Path) -> Source:
|
|
121
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
122
|
+
legacy_files = data.get("metadata", {}).get("files", {})
|
|
123
|
+
pkgs = []
|
|
124
|
+
for p in data.get("package", []):
|
|
125
|
+
pkg = Package(p["name"], p.get("version"), origin=path.name)
|
|
126
|
+
markers = p.get("markers")
|
|
127
|
+
if isinstance(markers, str):
|
|
128
|
+
pkg.markers = [markers]
|
|
129
|
+
elif isinstance(markers, dict):
|
|
130
|
+
pkg.markers = [m for m in markers.values() if isinstance(m, str)] or None
|
|
131
|
+
src_type = p.get("source", {}).get("type")
|
|
132
|
+
if src_type in ("git", "directory", "file", "url"):
|
|
133
|
+
pkg.skip = src_type
|
|
134
|
+
else:
|
|
135
|
+
entries = p.get("files")
|
|
136
|
+
if entries is None:
|
|
137
|
+
entries = legacy_files.get(p["name"], [])
|
|
138
|
+
pkg.files = [f for f in (_filename(e) for e in entries) if f]
|
|
139
|
+
pkgs.append(pkg)
|
|
140
|
+
return Source(path.name, pkgs)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _pinned_version(req: Requirement) -> str | None:
|
|
144
|
+
specs = list(req.specifier)
|
|
145
|
+
if len(specs) == 1 and specs[0].operator in ("==", "===") and "*" not in specs[0].version:
|
|
146
|
+
return specs[0].version
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def requirement_to_package(text: str, origin: str) -> Package:
|
|
151
|
+
try:
|
|
152
|
+
req = Requirement(text)
|
|
153
|
+
except InvalidRequirement as e:
|
|
154
|
+
raise SourceError(f"{origin}: can't parse requirement {text!r}: {e}") from None
|
|
155
|
+
markers = [str(req.marker)] if req.marker else None
|
|
156
|
+
if req.url:
|
|
157
|
+
return Package(req.name, None, skip="url", origin=origin, markers=markers)
|
|
158
|
+
return Package(req.name, _pinned_version(req), origin=origin, markers=markers)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def parse_requirements(path: Path, _seen: set[Path] | None = None) -> Source:
|
|
162
|
+
seen = _seen if _seen is not None else set()
|
|
163
|
+
path = path.resolve()
|
|
164
|
+
if path in seen:
|
|
165
|
+
return Source(path.name)
|
|
166
|
+
seen.add(path)
|
|
167
|
+
pkgs: list[Package] = []
|
|
168
|
+
lines = path.read_text(encoding="utf-8").replace("\\\n", " ").splitlines()
|
|
169
|
+
for raw in lines:
|
|
170
|
+
line = raw.split(" #", 1)[0].strip()
|
|
171
|
+
if not line or line.startswith("#"):
|
|
172
|
+
continue
|
|
173
|
+
if line.startswith(("-r ", "--requirement")):
|
|
174
|
+
ref = line.split(None, 1)[1].strip() if " " in line else line.split("=", 1)[1]
|
|
175
|
+
pkgs.extend(parse_requirements(path.parent / ref, seen).packages)
|
|
176
|
+
continue
|
|
177
|
+
if line.startswith("-e ") or line.startswith("--editable"):
|
|
178
|
+
name = line.split("#egg=", 1)[1] if "#egg=" in line else line.split(None, 1)[-1]
|
|
179
|
+
pkgs.append(Package(name, None, skip="editable", origin=path.name))
|
|
180
|
+
continue
|
|
181
|
+
if line.startswith("-"):
|
|
182
|
+
continue # pip options: --index-url, -c, --hash continuation, ...
|
|
183
|
+
line = " ".join(t for t in line.split() if not t.startswith("--hash"))
|
|
184
|
+
if line.startswith((".", "/")):
|
|
185
|
+
pkgs.append(Package(line, None, skip="path", origin=path.name))
|
|
186
|
+
continue
|
|
187
|
+
if "://" in line and "@" not in line:
|
|
188
|
+
pkgs.append(Package(line, None, skip="url", origin=path.name))
|
|
189
|
+
continue
|
|
190
|
+
pkgs.append(requirement_to_package(line, path.name))
|
|
191
|
+
return Source(path.name, pkgs)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
PARSERS = {
|
|
195
|
+
"uv.lock": parse_uv_lock,
|
|
196
|
+
"pylock.toml": parse_pylock,
|
|
197
|
+
"poetry.lock": parse_poetry_lock,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def load_file(path: Path) -> Source:
|
|
202
|
+
name = path.name
|
|
203
|
+
if name in PARSERS:
|
|
204
|
+
return PARSERS[name](path)
|
|
205
|
+
if name.startswith("pylock.") and name.endswith(".toml"):
|
|
206
|
+
return parse_pylock(path)
|
|
207
|
+
if name.endswith(".lock") and "uv" in name:
|
|
208
|
+
return parse_uv_lock(path)
|
|
209
|
+
if name.endswith((".txt", ".in")):
|
|
210
|
+
return parse_requirements(path)
|
|
211
|
+
raise SourceError(f"don't know how to read {path} (expected uv.lock, pylock.toml, poetry.lock or requirements.txt)")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def find_lockfile(directory: Path) -> Path:
|
|
215
|
+
for name in LOCKFILES:
|
|
216
|
+
candidate = directory / name
|
|
217
|
+
if candidate.is_file():
|
|
218
|
+
return candidate
|
|
219
|
+
raise SourceError(
|
|
220
|
+
f"no lockfile found in {directory} (looked for {', '.join(LOCKFILES)}). "
|
|
221
|
+
"Pass a file, or package names like `wheelgap numpy psycopg2`."
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def load_inputs(inputs: list[str]) -> list[Source]:
|
|
226
|
+
"""Each input is a file, a directory (lockfile auto-detected) or a requirement like 'numpy==2.3.4'."""
|
|
227
|
+
sources: list[Source] = []
|
|
228
|
+
adhoc = Source("command line")
|
|
229
|
+
for item in inputs:
|
|
230
|
+
p = Path(item)
|
|
231
|
+
if p.is_dir():
|
|
232
|
+
sources.append(load_file(find_lockfile(p)))
|
|
233
|
+
elif p.is_file():
|
|
234
|
+
sources.append(load_file(p))
|
|
235
|
+
elif "/" in item or item.endswith((".lock", ".txt", ".toml")):
|
|
236
|
+
raise SourceError(f"{item}: no such file")
|
|
237
|
+
else:
|
|
238
|
+
adhoc.packages.append(requirement_to_package(item, "command line"))
|
|
239
|
+
if adhoc.packages:
|
|
240
|
+
sources.append(adhoc)
|
|
241
|
+
return sources
|
wheelgap/targets.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Install targets: a Python version on a platform, expanded to the wheel tags it accepts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from functools import cached_property
|
|
8
|
+
|
|
9
|
+
from packaging import tags
|
|
10
|
+
|
|
11
|
+
# Short platform name -> (family, arch)
|
|
12
|
+
PLATFORMS: dict[str, tuple[str, str]] = {
|
|
13
|
+
"linux-x86_64": ("manylinux", "x86_64"),
|
|
14
|
+
"linux-aarch64": ("manylinux", "aarch64"),
|
|
15
|
+
"musl-x86_64": ("musllinux", "x86_64"),
|
|
16
|
+
"musl-aarch64": ("musllinux", "aarch64"),
|
|
17
|
+
"macos-arm64": ("macos", "arm64"),
|
|
18
|
+
"macos-x86_64": ("macos", "x86_64"),
|
|
19
|
+
"win-amd64": ("windows", "win_amd64"),
|
|
20
|
+
"win-arm64": ("windows", "win_arm64"),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
ALIASES: dict[str, str] = {
|
|
24
|
+
"linux": "linux-x86_64",
|
|
25
|
+
"linux-amd64": "linux-x86_64",
|
|
26
|
+
"linux-arm64": "linux-aarch64",
|
|
27
|
+
"manylinux-x86_64": "linux-x86_64",
|
|
28
|
+
"manylinux-aarch64": "linux-aarch64",
|
|
29
|
+
"alpine": "musl-x86_64",
|
|
30
|
+
"musl": "musl-x86_64",
|
|
31
|
+
"musl-arm64": "musl-aarch64",
|
|
32
|
+
"musllinux-x86_64": "musl-x86_64",
|
|
33
|
+
"musllinux-aarch64": "musl-aarch64",
|
|
34
|
+
"alpine-arm64": "musl-aarch64",
|
|
35
|
+
"alpine-aarch64": "musl-aarch64",
|
|
36
|
+
"mac": "macos-arm64",
|
|
37
|
+
"macos": "macos-arm64",
|
|
38
|
+
"macos-aarch64": "macos-arm64",
|
|
39
|
+
"macos-intel": "macos-x86_64",
|
|
40
|
+
"windows": "win-amd64",
|
|
41
|
+
"win": "win-amd64",
|
|
42
|
+
"windows-amd64": "win-amd64",
|
|
43
|
+
"windows-x86_64": "win-amd64",
|
|
44
|
+
"win-x86_64": "win-amd64",
|
|
45
|
+
"windows-arm64": "win-arm64",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# family -> (sys_platform, platform_system, os_name); arch -> platform_machine
|
|
49
|
+
_MARKER_OS = {
|
|
50
|
+
"manylinux": ("linux", "Linux", "posix"),
|
|
51
|
+
"musllinux": ("linux", "Linux", "posix"),
|
|
52
|
+
"macos": ("darwin", "Darwin", "posix"),
|
|
53
|
+
"windows": ("win32", "Windows", "nt"),
|
|
54
|
+
}
|
|
55
|
+
_MARKER_MACHINE = {"win_amd64": "AMD64", "win_arm64": "ARM64"}
|
|
56
|
+
|
|
57
|
+
DEFAULT_PLATFORMS = ["linux-x86_64", "linux-aarch64", "macos-arm64", "win-amd64"]
|
|
58
|
+
|
|
59
|
+
# Legacy manylinux aliases (PEP 513/571/599) and the glibc minor they map to.
|
|
60
|
+
_LEGACY_MANYLINUX = {17: "manylinux2014", 12: "manylinux2010", 5: "manylinux1"}
|
|
61
|
+
_LEGACY_ARCHES = {
|
|
62
|
+
"manylinux1": {"x86_64", "i686"},
|
|
63
|
+
"manylinux2010": {"x86_64", "i686"},
|
|
64
|
+
"manylinux2014": {"x86_64", "i686", "aarch64", "armv7l", "ppc64", "ppc64le", "s390x"},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_PY_RE = re.compile(r"^(?:cp|py)?(\d)\.?(\d{1,2})(t?)$")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TargetError(ValueError):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _norm(name: str) -> str:
|
|
75
|
+
return name.strip().lower().replace("_", "-")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_LOOKUP = {_norm(k): k for k in PLATFORMS} | {_norm(k): v for k, v in ALIASES.items()}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resolve_platform(name: str) -> str:
|
|
82
|
+
try:
|
|
83
|
+
return _LOOKUP[_norm(name)]
|
|
84
|
+
except KeyError:
|
|
85
|
+
known = ", ".join(PLATFORMS)
|
|
86
|
+
raise TargetError(f"unknown target {name!r} (known: {known})") from None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_python(spec: str) -> tuple[int, int, bool]:
|
|
90
|
+
"""'3.14' -> (3, 14, False); '3.14t' / 'cp314t' -> (3, 14, True)."""
|
|
91
|
+
m = _PY_RE.match(spec.strip().lower())
|
|
92
|
+
if not m:
|
|
93
|
+
raise TargetError(f"can't parse Python version {spec!r} (try 3.13 or 3.14t)")
|
|
94
|
+
return int(m.group(1)), int(m.group(2)), bool(m.group(3))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_version_pair(spec: str, what: str) -> tuple[int, int]:
|
|
98
|
+
try:
|
|
99
|
+
major, minor = (int(p) for p in spec.split(".")[:2])
|
|
100
|
+
except ValueError:
|
|
101
|
+
raise TargetError(f"can't parse {what} version {spec!r} (expected e.g. 2.36)") from None
|
|
102
|
+
return major, minor
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def manylinux_platforms(arch: str, glibc: tuple[int, int]) -> list[str]:
|
|
106
|
+
major, minor = glibc
|
|
107
|
+
out = []
|
|
108
|
+
for m in range(minor, 4, -1):
|
|
109
|
+
out.append(f"manylinux_{major}_{m}_{arch}")
|
|
110
|
+
legacy = _LEGACY_MANYLINUX.get(m)
|
|
111
|
+
if major == 2 and legacy and arch in _LEGACY_ARCHES[legacy]:
|
|
112
|
+
out.append(f"{legacy}_{arch}")
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def musllinux_platforms(arch: str, musl: tuple[int, int]) -> list[str]:
|
|
117
|
+
major, minor = musl
|
|
118
|
+
return [f"musllinux_{major}_{m}_{arch}" for m in range(minor, -1, -1)]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class Target:
|
|
123
|
+
major: int
|
|
124
|
+
minor: int
|
|
125
|
+
freethreaded: bool
|
|
126
|
+
platform: str
|
|
127
|
+
glibc: tuple[int, int] = (2, 36)
|
|
128
|
+
musl: tuple[int, int] = (1, 2)
|
|
129
|
+
macos: tuple[int, int] = (14, 0)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def python(self) -> str:
|
|
133
|
+
return f"{self.major}.{self.minor}{'t' if self.freethreaded else ''}"
|
|
134
|
+
|
|
135
|
+
def label(self, with_python: bool) -> str:
|
|
136
|
+
return f"{self.python} {self.platform}" if with_python else self.platform
|
|
137
|
+
|
|
138
|
+
def platform_tags(self) -> list[str]:
|
|
139
|
+
family, arch = PLATFORMS[self.platform]
|
|
140
|
+
if family == "manylinux":
|
|
141
|
+
return manylinux_platforms(arch, self.glibc)
|
|
142
|
+
if family == "musllinux":
|
|
143
|
+
return musllinux_platforms(arch, self.musl)
|
|
144
|
+
if family == "macos":
|
|
145
|
+
return list(tags.mac_platforms(self.macos, arch))
|
|
146
|
+
return [arch]
|
|
147
|
+
|
|
148
|
+
@cached_property
|
|
149
|
+
def marker_env(self) -> dict[str, str]:
|
|
150
|
+
"""PEP 508 environment for evaluating markers like `sys_platform == 'win32'`."""
|
|
151
|
+
family, arch = PLATFORMS[self.platform]
|
|
152
|
+
sys_platform, system, os_name = _MARKER_OS[family]
|
|
153
|
+
version = f"{self.major}.{self.minor}"
|
|
154
|
+
return {
|
|
155
|
+
"implementation_name": "cpython",
|
|
156
|
+
"implementation_version": f"{version}.0",
|
|
157
|
+
"os_name": os_name,
|
|
158
|
+
"platform_machine": _MARKER_MACHINE.get(arch, arch),
|
|
159
|
+
"platform_python_implementation": "CPython",
|
|
160
|
+
"platform_release": "",
|
|
161
|
+
"platform_system": system,
|
|
162
|
+
"platform_version": "",
|
|
163
|
+
"python_full_version": f"{version}.0",
|
|
164
|
+
"python_version": version,
|
|
165
|
+
"sys_platform": sys_platform,
|
|
166
|
+
"extra": "",
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
@cached_property
|
|
170
|
+
def tags(self) -> frozenset[tags.Tag]:
|
|
171
|
+
version = (self.major, self.minor)
|
|
172
|
+
interp = f"cp{self.major}{self.minor}"
|
|
173
|
+
abi = interp + ("t" if self.freethreaded else "")
|
|
174
|
+
plats = self.platform_tags()
|
|
175
|
+
supported = set(tags.cpython_tags(version, [abi], plats))
|
|
176
|
+
supported.update(tags.compatible_tags(version, interp, plats))
|
|
177
|
+
return frozenset(supported)
|
|
178
|
+
|
|
179
|
+
def accepts(self, wheel_tags: frozenset[tags.Tag]) -> bool:
|
|
180
|
+
return not self.tags.isdisjoint(wheel_tags)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def build_targets(
|
|
184
|
+
pythons: list[str],
|
|
185
|
+
platforms: list[str],
|
|
186
|
+
glibc: str = "2.36",
|
|
187
|
+
musl: str = "1.2",
|
|
188
|
+
macos: str = "14.0",
|
|
189
|
+
) -> list[Target]:
|
|
190
|
+
plats: list[str] = []
|
|
191
|
+
for item in platforms:
|
|
192
|
+
for name in item.split(","):
|
|
193
|
+
if not name.strip():
|
|
194
|
+
continue
|
|
195
|
+
if name.strip().lower() == "all":
|
|
196
|
+
plats.extend(PLATFORMS)
|
|
197
|
+
else:
|
|
198
|
+
plats.append(resolve_platform(name))
|
|
199
|
+
plats = list(dict.fromkeys(plats)) or list(DEFAULT_PLATFORMS)
|
|
200
|
+
|
|
201
|
+
pys = list(dict.fromkeys(parse_python(p) for item in pythons for p in item.split(",") if p.strip()))
|
|
202
|
+
opts = dict(
|
|
203
|
+
glibc=parse_version_pair(glibc, "glibc"),
|
|
204
|
+
musl=parse_version_pair(musl, "musl"),
|
|
205
|
+
macos=parse_version_pair(macos, "macOS"),
|
|
206
|
+
)
|
|
207
|
+
return [Target(ma, mi, ft, plat, **opts) for (ma, mi, ft) in pys for plat in plats]
|
|
@@ -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)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
wheelgap/__init__.py,sha256=aZ0JdyHx07vuIxKePYOpVEcMf9epq5kwg0U6Qdp8ixg,115
|
|
2
|
+
wheelgap/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
|
|
3
|
+
wheelgap/check.py,sha256=Dj-CNHcvYm811mj-pA1pnVtGSzG2VLMIAVhmPlCmPnM,4539
|
|
4
|
+
wheelgap/cli.py,sha256=Ma7-ByVpYLs88sIzM8Ab4827baQ_NIeTvYEnWvX085g,9393
|
|
5
|
+
wheelgap/pypi.py,sha256=N-TKUHGF7J-qmNZ7IysziIshqyJ31inhN7bO080aBOk,2020
|
|
6
|
+
wheelgap/sources.py,sha256=W4biwxH5lHcZhhwmqm5YjywMzqIcDfGEAEXTRmkNc7Y,9454
|
|
7
|
+
wheelgap/targets.py,sha256=kttn0IqJO7md8hOb09hXFdTnDAF1u_oP6T5jIcb0Cj8,7020
|
|
8
|
+
wheelgap-0.1.0.dist-info/METADATA,sha256=lChUowMbE1edQmFTSfD141uLWv4Fbug-ToHeHnLCuWs,7024
|
|
9
|
+
wheelgap-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
10
|
+
wheelgap-0.1.0.dist-info/entry_points.txt,sha256=brFpw-P8-gNlf3-Jrlxc0VK8dazuAUPXaL2fWt6jLzk,47
|
|
11
|
+
wheelgap-0.1.0.dist-info/licenses/LICENSE,sha256=LhIJM-PJ5BaE37PoE77nlizKBwzARAbBZw1lqpA7L3A,1072
|
|
12
|
+
wheelgap-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|