Skip to content

Commit b65b835

Browse files
committed
ci/python: Add wheel metadata publishing validation
Signed-off-by: Ryan Northey <ryan@synca.io>
1 parent bb718b3 commit b65b835

5 files changed

Lines changed: 134 additions & 281 deletions

File tree

.github/workflows/py.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ jobs:
136136
named-caches-hash: "${{ hashFiles('pants*toml') }}"
137137
- name: Run pants package
138138
run: "pants --colors package ::"
139+
- name: Verify wheel METADATA matches setup.cfg
140+
run: |
141+
python -m pip install --quiet packaging
142+
python py/tools/publish_check/check_wheel_metadata.py
139143
- name: Archive wheels
140144
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
141145
with:

py/tools/publish_check/BUILD

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,3 @@ python_tests(
3434
"//py/pytest-patches:build_artefacts",
3535
],
3636
)
37-
38-
python_tests(
39-
name="publish_wheel_metadata_check",
40-
sources=["test_publish_wheel_metadata.py"],
41-
skip_mypy=True,
42-
runtime_package_dependencies=["//py/_test_publish_pkg:package"],
43-
dependencies=[
44-
"//py/deps:reqs#packaging",
45-
"//py/deps:reqs#pytest",
46-
"//py/deps:reqs#pytest-asyncio",
47-
],
48-
)

py/tools/publish_check/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
`check_wheel_metadata.py` is a post-build verification script for CI.
2+
3+
It runs after `pants package ::` and validates built `dist/*.whl` metadata
4+
against each package's `py/*/setup.cfg` `install_requires`.
5+
6+
This check intentionally runs outside the pants test graph because it operates
7+
on already-built wheel artifacts in `dist/` rather than on source targets.
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Verify built wheels in dist/ have Requires-Dist matching setup.cfg.
2+
3+
Runs after `pants package ::` in CI. Walks dist/*.whl, parses METADATA,
4+
locates the package's setup.cfg, and asserts:
5+
6+
set(Requires-Dist without `; extra == ...`)
7+
== set(install_requires from setup.cfg)
8+
9+
Exits non-zero with a per-package diff on mismatch.
10+
"""
11+
12+
import glob
13+
import pathlib
14+
import re
15+
import sys
16+
import zipfile
17+
from collections import defaultdict
18+
from email.parser import Parser
19+
20+
from packaging.requirements import Requirement
21+
from packaging.utils import canonicalize_name
22+
23+
24+
def _norm(req_str: str) -> tuple[str, str]:
25+
r = Requirement(req_str)
26+
return canonicalize_name(r.name), str(r.specifier)
27+
28+
29+
def _setup_cfg_install_requires(path: pathlib.Path) -> list[str]:
30+
reqs, in_options, in_ir = [], False, False
31+
for raw in path.read_text().splitlines():
32+
s = raw.strip()
33+
if s.startswith("[") and s.endswith("]"):
34+
in_options = s == "[options]"
35+
in_ir = False
36+
continue
37+
if not in_options:
38+
continue
39+
if s.startswith("install_requires"):
40+
in_ir = True
41+
if "=" in raw:
42+
v = raw.split("=", 1)[1].strip()
43+
if v:
44+
reqs.append(v)
45+
continue
46+
if in_ir:
47+
if raw and not raw[0].isspace():
48+
in_ir = False
49+
continue
50+
if s and not s.startswith("#"):
51+
reqs.append(s)
52+
return reqs
53+
54+
55+
def _wheel_runtime_requires(whl: pathlib.Path) -> list[str]:
56+
with zipfile.ZipFile(whl) as zf:
57+
meta = next(n for n in zf.namelist() if n.endswith(".dist-info/METADATA"))
58+
msg = Parser().parsestr(zf.read(meta).decode())
59+
out = []
60+
for rd in msg.get_all("Requires-Dist") or []:
61+
if ";" in rd and "extra" in rd.split(";", 1)[1]:
62+
continue
63+
out.append(rd.split(";", 1)[0].strip())
64+
return out
65+
66+
67+
def _pkg_name_from_wheel(whl: pathlib.Path) -> str:
68+
return whl.name.split("-")[0]
69+
70+
71+
def _setup_cfg_for(dist_name: str) -> pathlib.Path | None:
72+
for cfg in pathlib.Path("py").glob("*/setup.cfg"):
73+
for line in cfg.read_text().splitlines():
74+
m = re.match(r"\s*name\s*=\s*(\S+)", line)
75+
if m and canonicalize_name(m.group(1)) == canonicalize_name(dist_name):
76+
return cfg
77+
return None
78+
79+
80+
def main() -> int:
81+
failures: dict[str, list[str]] = defaultdict(list)
82+
wheels = sorted(pathlib.Path(p) for p in glob.glob("dist/*.whl"))
83+
if not wheels:
84+
print("ERROR: no wheels found in dist/", file=sys.stderr)
85+
return 2
86+
87+
for whl in wheels:
88+
dist_name = _pkg_name_from_wheel(whl)
89+
cfg = _setup_cfg_for(dist_name)
90+
if cfg is None:
91+
failures[whl.name].append(
92+
f"no matching py/*/setup.cfg for dist name {dist_name!r}"
93+
)
94+
continue
95+
expected = {_norm(r) for r in _setup_cfg_install_requires(cfg)}
96+
actual = {_norm(r) for r in _wheel_runtime_requires(whl)}
97+
if expected == actual:
98+
print(f"OK {whl.name}")
99+
continue
100+
extra = actual - expected
101+
missing = expected - actual
102+
if extra:
103+
failures[whl.name].append(
104+
f"unexpected Requires-Dist (leaked from pants/deps?): {sorted(extra)}"
105+
)
106+
if missing:
107+
failures[whl.name].append(
108+
f"missing Requires-Dist (in setup.cfg but not in wheel): {sorted(missing)}"
109+
)
110+
111+
if failures:
112+
print("\nFAIL: wheel METADATA does not match setup.cfg", file=sys.stderr)
113+
for whl, errs in failures.items():
114+
print(f" {whl}", file=sys.stderr)
115+
for e in errs:
116+
print(f" - {e}", file=sys.stderr)
117+
return 1
118+
print(f"\nAll {len(wheels)} wheels match their setup.cfg install_requires.")
119+
return 0
120+
121+
122+
if __name__ == "__main__":
123+
raise SystemExit(main())

0 commit comments

Comments
 (0)