mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-12 21:13:49 -06:00
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.
This commit is contained in:
1 parent
47593c58d7
commit
7ba899d467
6 files changed
+487
-2
No files matched your search
@@ -0,0 +1,212 @@
|
||||
#!/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)
|
||||
Reference in new issue
Block a user