|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Validate the exact contents and metadata of release distributions.""" |
| 3 | + |
| 4 | +from __future__ import annotations |
| 5 | + |
| 6 | +import sys |
| 7 | +import tarfile |
| 8 | +import zipfile |
| 9 | +from email.message import Message |
| 10 | +from email.parser import BytesParser |
| 11 | +from email.policy import default |
| 12 | +from pathlib import Path, PurePosixPath |
| 13 | +from typing import NoReturn |
| 14 | + |
| 15 | +ROOT = Path(__file__).resolve().parent.parent |
| 16 | +DIST = ROOT / "dist" |
| 17 | + |
| 18 | + |
| 19 | +def _fail(message: str) -> NoReturn: |
| 20 | + raise SystemExit(f"distribution check failed: {message}") |
| 21 | + |
| 22 | + |
| 23 | +def _validate_paths(names: set[str], archive: str) -> None: |
| 24 | + unsafe = sorted( |
| 25 | + name |
| 26 | + for name in names |
| 27 | + if PurePosixPath(name).is_absolute() or ".." in PurePosixPath(name).parts |
| 28 | + ) |
| 29 | + if unsafe: |
| 30 | + _fail(f"{archive} contains unsafe paths: {unsafe}") |
| 31 | + |
| 32 | + |
| 33 | +def _metadata(raw: bytes, archive: str) -> Message: |
| 34 | + message = BytesParser(policy=default).parsebytes(raw) |
| 35 | + required = { |
| 36 | + "Name": "rewardharness", |
| 37 | + "Requires-Python": ">=3.10", |
| 38 | + "License-Expression": "Apache-2.0", |
| 39 | + "Description-Content-Type": "text/markdown", |
| 40 | + } |
| 41 | + for field, expected in required.items(): |
| 42 | + if message[field] != expected: |
| 43 | + _fail(f"{archive} {field} is {message[field]!r}, expected {expected!r}") |
| 44 | + classifiers = set(message.get_all("Classifier", [])) |
| 45 | + if "Typing :: Typed" not in classifiers: |
| 46 | + _fail(f"{archive} does not declare inline typing support") |
| 47 | + project_urls = {value.split(",", 1)[0].strip() for value in message.get_all("Project-URL", [])} |
| 48 | + expected_urls = {"Homepage", "Documentation", "Repository", "Changelog", "Issues", "Paper"} |
| 49 | + if missing := expected_urls - project_urls: |
| 50 | + _fail(f"{archive} is missing project URLs: {sorted(missing)}") |
| 51 | + return message |
| 52 | + |
| 53 | + |
| 54 | +def main() -> int: |
| 55 | + sys.path.insert(0, str(ROOT)) |
| 56 | + from rewardharness.release import ReleaseIdentity |
| 57 | + |
| 58 | + identity = ReleaseIdentity.current() |
| 59 | + wheel = DIST / f"rewardharness-{identity.package_version}-py3-none-any.whl" |
| 60 | + sdist = DIST / f"rewardharness-{identity.package_version}.tar.gz" |
| 61 | + expected_artifacts = {wheel, sdist} |
| 62 | + actual_artifacts = ( |
| 63 | + {path for path in DIST.iterdir() if path.is_file()} if DIST.is_dir() else set() |
| 64 | + ) |
| 65 | + if actual_artifacts != expected_artifacts: |
| 66 | + unexpected = sorted(path.name for path in actual_artifacts - expected_artifacts) |
| 67 | + missing = sorted(path.name for path in expected_artifacts - actual_artifacts) |
| 68 | + _fail(f"artifact set mismatch; missing={missing}, unexpected={unexpected}") |
| 69 | + |
| 70 | + dist_info = f"rewardharness-{identity.package_version}.dist-info" |
| 71 | + with zipfile.ZipFile(wheel) as zip_archive: |
| 72 | + wheel_names = set(zip_archive.namelist()) |
| 73 | + _validate_paths(wheel_names, wheel.name) |
| 74 | + wheel_metadata = _metadata(zip_archive.read(f"{dist_info}/METADATA"), wheel.name) |
| 75 | + required_wheel = { |
| 76 | + "rewardharness/py.typed", |
| 77 | + "rewardharness/resources/library/registry.json", |
| 78 | + "rewardharness/resources/score_guidelines/template1_instruction_following.md", |
| 79 | + "rewardharness/resources/score_guidelines/template2_visual_quality.md", |
| 80 | + f"{dist_info}/entry_points.txt", |
| 81 | + } |
| 82 | + missing_wheel = required_wheel - wheel_names |
| 83 | + if missing_wheel: |
| 84 | + _fail(f"{wheel.name} is missing runtime files: {sorted(missing_wheel)}") |
| 85 | + forbidden_prefixes = ("tests/", "scripts/", "examples/", "vanilla/") |
| 86 | + leaked = sorted(name for name in wheel_names if name.startswith(forbidden_prefixes)) |
| 87 | + if leaked: |
| 88 | + _fail(f"{wheel.name} contains non-runtime files: {leaked}") |
| 89 | + |
| 90 | + sdist_root = f"rewardharness-{identity.package_version}" |
| 91 | + with tarfile.open(sdist, "r:gz") as tar_archive: |
| 92 | + sdist_names = {member.name for member in tar_archive.getmembers()} |
| 93 | + _validate_paths(sdist_names, sdist.name) |
| 94 | + member = tar_archive.getmember(f"{sdist_root}/PKG-INFO") |
| 95 | + extracted = tar_archive.extractfile(member) |
| 96 | + if extracted is None: |
| 97 | + _fail(f"{sdist.name} has an unreadable PKG-INFO") |
| 98 | + sdist_metadata = _metadata(extracted.read(), sdist.name) |
| 99 | + required_sdist = { |
| 100 | + f"{sdist_root}/README.md", |
| 101 | + f"{sdist_root}/CHANGELOG.md", |
| 102 | + f"{sdist_root}/CITATION.cff", |
| 103 | + f"{sdist_root}/LICENSE", |
| 104 | + f"{sdist_root}/rewardharness/py.typed", |
| 105 | + f"{sdist_root}/configs/default.yaml", |
| 106 | + } |
| 107 | + missing_sdist = required_sdist - sdist_names |
| 108 | + if missing_sdist: |
| 109 | + _fail(f"{sdist.name} is missing source files: {sorted(missing_sdist)}") |
| 110 | + |
| 111 | + for message, archive_name in ( |
| 112 | + (wheel_metadata, wheel.name), |
| 113 | + (sdist_metadata, sdist.name), |
| 114 | + ): |
| 115 | + if message["Version"] != identity.package_version: |
| 116 | + _fail( |
| 117 | + f"{archive_name} version is {message['Version']!r}, " |
| 118 | + f"expected {identity.package_version!r}" |
| 119 | + ) |
| 120 | + print(f"distribution artifacts: 2/2 valid for {identity.package_version}") |
| 121 | + return 0 |
| 122 | + |
| 123 | + |
| 124 | +if __name__ == "__main__": |
| 125 | + raise SystemExit(main()) |
0 commit comments