Files
libretro/scripts/build_pypi.py
T
Abdessamad Derraz 7ba899d467 feat: build the installer as a PyPI package
scripts/build_pypi.py writes a wheel and an sdist from install.py, the
copy install.sh pins, and packaging/pypi. The version is the date of the
release in release.json. validate.yml now runs on packaging/ changes.
2026-11-09 07:07:54 +01:00

213 lines
8.5 KiB
Python

#!/usr/bin/env python3
"""Build the `retrobios` package PyPI serves: install.py as a command.
The wheel and the sdist hold install.py, byte for byte the copy the
bootstraps pin, a two-line launcher that runs it as a script, the metadata,
README.md and LICENSE. Nothing of the collection. The metadata comes from
packaging/pypi/pyproject.toml.
The version is the date of the release in release.json: v2026.10.10 gives
2026.10.10. A release writes release.json first, so the package published
with it carries its date.
Both archives are written here with the standard library rather than by a
build backend: the bytes then depend on the inputs alone, on any machine and
with no network. The sdist still names setuptools as its backend, for pip
and uv to build it.
Usage:
python scripts/build_pypi.py
python scripts/build_pypi.py --output-dir tmp/pypi-dist
"""
from __future__ import annotations
import argparse
import base64
import calendar
import gzip
import hashlib
import io
import json
import re
import tarfile
import zipfile
from pathlib import Path
from sitebootstrap import PIN, SH_NAME
ROOT = Path(__file__).resolve().parent.parent
PACKAGE_DIR = ROOT / "packaging" / "pypi"
DEFAULT_OUTPUT = ROOT / "tmp" / "pypi-dist"
MODULE = "retrobios"
# The launcher. runpy runs install.py as __main__, so its own __main__ block
# keeps the exit codes it gives a reader that left (141) and Ctrl+C (130).
LAUNCHER = '''"""The RetroBIOS installer, install.py, run as the retrobios command."""
import runpy
from pathlib import Path
__version__ = "{version}"
def main() -> None:
runpy.run_path(str(Path(__file__).with_name("install.py")), run_name="__main__")
'''
# The [project] keys the metadata below writes. Another key in pyproject.toml
# would reach a build from the sdist and not the wheel written here.
PROJECT_KEYS = frozenset({
"name", "dynamic", "description", "readme", "requires-python", "license",
"license-files", "dependencies", "classifiers", "urls", "scripts",
})
def package_version(release: dict) -> str:
"""The release date as a version, without the zeros PEP 440 drops."""
tag = str(release.get("tag", ""))
match = re.fullmatch(r"v(\d{4})\.(\d{2})\.(\d{2})", tag)
if match is None:
raise ValueError(f"release.json names no dated release tag: {tag!r}")
return ".".join(str(int(part)) for part in match.groups())
def load_project() -> dict:
import tomllib # noqa: PLC0415 # 3.11, only where a package is built
with (PACKAGE_DIR / "pyproject.toml").open("rb") as handle:
project = tomllib.load(handle)["project"]
unknown = set(project) - PROJECT_KEYS
if unknown:
raise ValueError(f"build_pypi.py writes no metadata for {sorted(unknown)}")
if project["dependencies"] or project["dynamic"] != ["version"]:
raise ValueError("the package has no dependencies and only its version is dynamic")
return project
def metadata(project: dict, version: str, readme: str) -> str:
"""Core metadata 2.4, as the wheel's METADATA and the sdist's PKG-INFO."""
lines = [
"Metadata-Version: 2.4",
f"Name: {project['name']}",
f"Version: {version}",
f"Summary: {project['description']}",
*(f"Project-URL: {label}, {url}" for label, url in project["urls"].items()),
f"License-Expression: {project['license']}",
*(f"License-File: {name}" for name in project["license-files"]),
*(f"Classifier: {name}" for name in project["classifiers"]),
f"Requires-Python: {project['requires-python']}",
"Description-Content-Type: text/markdown",
]
return "\n".join(lines) + "\n\n" + readme
def installer_bytes() -> bytes:
"""install.py, refused unless it is the one install.sh pins: the package
then runs the installer the one-line command runs, under the same id."""
data = (ROOT / "install.py").read_bytes()
pins = PIN[SH_NAME].findall((ROOT / "install.sh").read_text(encoding="utf-8"))
if pins != [hashlib.sha256(data).hexdigest()]:
raise ValueError("install.py is not the installer install.sh pins: recompute the pin")
return data
def package_files(version: str) -> tuple[dict, dict[str, bytes]]:
"""The project table, and the files both archives ship, by path."""
project = load_project()
readme = (PACKAGE_DIR / project["readme"]).read_text(encoding="utf-8")
files = {
f"{MODULE}/__init__.py": LAUNCHER.format(version=version).encode(),
f"{MODULE}/install.py": installer_bytes(),
"METADATA": metadata(project, version, readme).encode("utf-8"),
"README.md": readme.encode("utf-8"),
"pyproject.toml": (PACKAGE_DIR / "pyproject.toml").read_bytes(),
}
for name in project["license-files"]:
files[name] = (ROOT / name).read_bytes()
return project, files
def _record_line(path: str, data: bytes) -> str:
digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode()
return f"{path},sha256={digest},{len(data)}"
def write_wheel(path: Path, project: dict, files: dict[str, bytes], version: str,
stamp: tuple[int, int, int]) -> None:
info = f"{project['name']}-{version}.dist-info"
scripts = "".join(f"{name} = {target}\n" for name, target in project["scripts"].items())
members = {
f"{MODULE}/__init__.py": files[f"{MODULE}/__init__.py"],
f"{MODULE}/install.py": files[f"{MODULE}/install.py"],
f"{info}/METADATA": files["METADATA"],
f"{info}/WHEEL": (b"Wheel-Version: 1.0\nGenerator: retrobios build_pypi.py\n"
b"Root-Is-Purelib: true\nTag: py3-none-any\n"),
f"{info}/entry_points.txt": f"[console_scripts]\n{scripts}".encode(),
**{f"{info}/licenses/{name}": files[name] for name in project["license-files"]},
}
record = "".join(_record_line(name, data) + "\n" for name, data in members.items())
members[f"{info}/RECORD"] = (record + f"{info}/RECORD,,\n").encode()
with zipfile.ZipFile(path, "w") as archive:
for name, data in members.items():
entry = zipfile.ZipInfo(name, date_time=(*stamp, 0, 0, 0))
entry.compress_type = zipfile.ZIP_DEFLATED
entry.external_attr = 0o644 << 16
archive.writestr(entry, data)
def write_sdist(path: Path, project: dict, files: dict[str, bytes], version: str,
stamp: tuple[int, int, int]) -> None:
top = f"{project['name']}-{version}"
members = {
"PKG-INFO": files["METADATA"],
"pyproject.toml": files["pyproject.toml"],
"README.md": files["README.md"],
**{name: files[name] for name in project["license-files"]},
f"{MODULE}/__init__.py": files[f"{MODULE}/__init__.py"],
f"{MODULE}/install.py": files[f"{MODULE}/install.py"],
}
mtime = calendar.timegm((*stamp, 0, 0, 0))
raw = io.BytesIO()
with tarfile.open(fileobj=raw, mode="w", format=tarfile.PAX_FORMAT) as archive:
for name, data in members.items():
entry = tarfile.TarInfo(f"{top}/{name}")
entry.size, entry.mtime, entry.mode = len(data), mtime, 0o644
archive.addfile(entry, io.BytesIO(data))
with path.open("wb") as handle, gzip.GzipFile(fileobj=handle, mode="wb", filename="",
mtime=mtime) as packed:
packed.write(raw.getvalue())
def build(output_dir: Path) -> tuple[Path, Path]:
"""The wheel and the sdist in output_dir, replacing earlier builds there."""
version = package_version(json.loads((ROOT / "release.json").read_text(encoding="utf-8")))
year, month, day = (int(part) for part in version.split("."))
stamp = (year, month, day)
project, files = package_files(version)
output_dir.mkdir(parents=True, exist_ok=True)
for pattern in ("*.whl", "*.tar.gz"):
for old in output_dir.glob(f"{project['name']}-{pattern}"):
old.unlink()
wheel = output_dir / f"{project['name']}-{version}-py3-none-any.whl"
sdist = output_dir / f"{project['name']}-{version}.tar.gz"
write_wheel(wheel, project, files, version, stamp)
write_sdist(sdist, project, files, version, stamp)
return wheel, sdist
def main() -> int:
parser = argparse.ArgumentParser(allow_abbrev=False, description=__doc__.splitlines()[0])
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT,
help="where the wheel and the sdist go (default: tmp/pypi-dist)")
args = parser.parse_args()
for path in build(args.output_dir):
print(path)
return 0
if __name__ == "__main__":
from common import run_cli
run_cli(main)