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 ADDED
@@ -0,0 +1,3 @@
1
+ """wheelgap: find the dependencies that have no prebuilt wheel for your target platform."""
2
+
3
+ __version__ = "0.1.0"
wheelgap/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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
+ [![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,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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ wheelgap = wheelgap.cli:main
@@ -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.