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:
Abdessamad Derraz committed 2026-11-09 07:07:54 +01:00
1 parent 47593c58d7
commit 7ba899d467
6 files changed
+487 -2

No files matched your search

+3
View File
@@ -19,6 +19,8 @@ on:
- "install.ps1"
# The handheld port bundles install.py and its tests read both.
- "ports/**"
# The PyPI package is built from install.py and its tests build it.
- "packaging/**"
# Every input of the site build, so a page that would fail the deploy
# fails here first (tests/test_workflow_paths.py).
- "wiki/**"
@@ -50,6 +52,7 @@ on:
- "install.sh"
- "install.ps1"
- "ports/**"
- "packaging/**"
- "wiki/**"
- "docs_assets/**"
- "provenance/**"
+22
View File
@@ -0,0 +1,22 @@
# RetroBIOS
Puts the BIOS files your emulators need in the right folder.
It finds your setup and its BIOS folder, downloads only the missing files and
checks each one. It works with RetroArch, Batocera, EmuDeck, RetroBat,
Recalbox, RetroDECK, RomM and more.
Run it:
```bash
uvx retrobios
```
Or `pipx run retrobios`, or `pip install retrobios` and then `retrobios`.
Python 3.8 or newer.
Add `--check` to see what is missing without changing anything, and `--help`
for every option.
Guides, the list of setups and the files each one needs:
https://abdess.github.io/retrobios/
+37
View File
@@ -0,0 +1,37 @@
# The package PyPI serves as `retrobios`: install.py and the command that
# runs it, nothing else. The root pyproject.toml is the contributors'
# environment. scripts/build_pypi.py writes the wheel and the sdist from this
# file, README.md, LICENSE and install.py, and writes retrobios/__init__.py
# with the version. This file is the one a build from the sdist reads.
[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "retrobios"
dynamic = ["version"]
description = "Install the BIOS files your emulators need"
readme = "README.md"
requires-python = ">=3.8"
license = "MIT"
license-files = ["LICENSE"]
dependencies = []
classifiers = [
"Environment :: Console",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Topic :: System :: Emulators",
]
[project.urls]
Homepage = "https://abdess.github.io/retrobios/"
Source = "https://github.com/Abdess/retrobios"
[project.scripts]
retrobios = "retrobios:main"
[tool.setuptools]
packages = ["retrobios"]
[tool.setuptools.dynamic]
version = { attr = "retrobios.__version__" }
+212
View File
@@ -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)
+200
View File
@@ -0,0 +1,200 @@
"""The `retrobios` package on PyPI is install.py and the command that runs it.
`uvx retrobios`, `pipx run retrobios` and `pip install retrobios` reach the
installer the one-line command runs: the same bytes, so the same id, the
same file lists, cache and exit codes. scripts/build_pypi.py writes the
wheel and the sdist, and these tests read what it wrote and run it.
"""
from __future__ import annotations
import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache
import email.parser
import importlib.util
import os
import shutil
import subprocess
import sys
import tarfile
import tempfile
import unittest
import venv
import zipfile
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import build_pypi # noqa: E402
MODULE_FILES = {"retrobios/__init__.py", "retrobios/install.py"}
class Built(unittest.TestCase):
"""One build per class, in a scratch folder."""
_scratch: tempfile.TemporaryDirectory
scratch: Path
wheel: Path
sdist: Path
version: str
@classmethod
def setUpClass(cls) -> None:
cls._scratch = tempfile.TemporaryDirectory(prefix="pypi-")
cls.scratch = Path(cls._scratch.name)
cls.wheel, cls.sdist = build_pypi.build(cls.scratch / "dist")
cls.version = cls.wheel.name.split("-")[1]
@classmethod
def tearDownClass(cls) -> None:
cls._scratch.cleanup()
class TheArchivesHoldTheInstallerAndNothingElse(Built):
def test_wheel_members(self):
info = f"retrobios-{self.version}.dist-info"
with zipfile.ZipFile(self.wheel) as archive:
names = set(archive.namelist())
self.assertEqual(names, MODULE_FILES | {
f"{info}/{name}" for name in
("METADATA", "WHEEL", "entry_points.txt", "RECORD", "licenses/LICENSE")})
self.assertEqual(archive.read("retrobios/install.py"),
(REPO_ROOT / "install.py").read_bytes())
entry_points = archive.read(f"{info}/entry_points.txt").decode()
meta = email.parser.Parser().parsestr(archive.read(f"{info}/METADATA").decode())
self.assertEqual(entry_points, "[console_scripts]\nretrobios = retrobios:main\n")
self.assertEqual(meta["Name"], "retrobios")
self.assertEqual(meta["Version"], self.version)
self.assertEqual(meta["Requires-Python"], ">=3.8")
self.assertIsNone(meta["Requires-Dist"])
self.assertEqual(meta.get_payload(),
(REPO_ROOT / "packaging/pypi/README.md").read_text(encoding="utf-8"))
def test_sdist_members(self):
top = f"retrobios-{self.version}"
with tarfile.open(self.sdist) as archive:
names = {member.name for member in archive.getmembers()}
installer = archive.extractfile(f"{top}/retrobios/install.py").read()
self.assertEqual(names, {f"{top}/{name}" for name in (
*MODULE_FILES, "PKG-INFO", "pyproject.toml", "README.md", "LICENSE")})
self.assertEqual(installer, (REPO_ROOT / "install.py").read_bytes())
def test_no_collection_file(self):
with zipfile.ZipFile(self.wheel) as archive:
names = archive.namelist()
with tarfile.open(self.sdist) as archive:
names += [name.split("/", 1)[1] for name in archive.getnames()]
for top in ("bios/", "data/", "install/", "platforms/", "emulators/"):
self.assertEqual([name for name in names if name.startswith(top)], [], top)
def test_build_is_reproducible(self):
again = build_pypi.build(self.scratch / "again")
self.assertEqual([path.read_bytes() for path in again],
[self.wheel.read_bytes(), self.sdist.read_bytes()])
class TheVersionIsTheReleaseDate(unittest.TestCase):
def test_dated_tag(self):
self.assertEqual(build_pypi.package_version({"tag": "v2026.10.04"}), "2026.10.4")
def test_other_tag_refused(self):
for tag in ("large-files", "2026.10.04", "v2026.10"):
with self.assertRaises(ValueError):
build_pypi.package_version({"tag": tag})
def _python38() -> str | None:
found = shutil.which("python3.8")
if found is None and os.environ.get("CI"):
raise AssertionError("CI installs Python 3.8")
return found
class TheInstalledCommandIsTheInstaller(Built):
"""Installed in a fresh venv, `retrobios` answers as `python install.py`."""
def _installed(self, python: str, name: str) -> Path:
env = self.scratch / name
made = subprocess.run([python, "-m", "venv", str(env)], capture_output=True, text=True,
check=False)
self.assertEqual(made.returncode, 0, made.stderr[-2000:])
bindir = env / ("Scripts" if os.name == "nt" else "bin")
done = subprocess.run(
[str(bindir / "python"), "-m", "pip", "install", "--no-index", "--no-deps",
"--disable-pip-version-check", str(self.wheel)],
capture_output=True, text=True, check=False)
self.assertEqual(done.returncode, 0, done.stderr[-2000:])
return bindir / "retrobios"
def _same_answers(self, python: str, name: str) -> None:
command = self._installed(python, name)
for args in (["--help"], ["--version"], ["--no-such-option"]):
with self.subTest(python=name, args=args):
packaged = subprocess.run([str(command), *args], capture_output=True,
text=True, check=False, cwd=self.scratch)
script = subprocess.run([python, str(REPO_ROOT / "install.py"), *args],
capture_output=True, text=True, check=False,
cwd=self.scratch)
self.assertEqual(
(packaged.returncode, packaged.stdout, packaged.stderr),
(script.returncode, script.stdout, script.stderr))
def test_this_python(self):
self._same_answers(sys.executable, "venv-current")
def test_python_38(self):
python = _python38()
if python is None:
self.skipTest("python3.8 is not on PATH")
self._same_answers(python, "venv-38")
def _setuptools_builds_the_sdist() -> bool:
"""setuptools 77 reads the license expression the sdist declares."""
if importlib.util.find_spec("setuptools") is None:
return False
import setuptools # noqa: PLC0415
return int(setuptools.__version__.split(".")[0]) >= 77
class TheSdistBuildsTheSameWheel(Built):
"""pip and uv build the sdist with setuptools: the files must be those the
wheel written here holds."""
def test_setuptools_wheel(self):
if not _setuptools_builds_the_sdist():
if os.environ.get("CI"):
self.fail("CI installs setuptools 77 or newer")
self.skipTest("setuptools 77 or newer is not installed")
out = self.scratch / "from-sdist"
work = self.scratch / "unpacked"
with tarfile.open(self.sdist) as archive:
archive.extractall(work, filter="data")
done = subprocess.run(
[sys.executable, "-m", "pip", "wheel", "--no-build-isolation", "--no-deps",
"--no-index", "--disable-pip-version-check", "-w", str(out),
str(work / f"retrobios-{self.version}")],
capture_output=True, text=True, check=False)
self.assertEqual(done.returncode, 0, done.stderr[-2000:])
(built,) = out.glob("*.whl")
info = f"retrobios-{self.version}.dist-info"
with zipfile.ZipFile(built) as theirs, zipfile.ZipFile(self.wheel) as ours:
self.assertEqual(set(theirs.namelist()) - {f"{info}/top_level.txt"},
set(ours.namelist()))
for name in (*MODULE_FILES, f"{info}/entry_points.txt"):
self.assertEqual(theirs.read(name), ours.read(name), name)
fields = ("Name", "Version", "Summary", "License-Expression", "Requires-Python")
theirs_meta, ours_meta = (
email.parser.Parser().parsestr(archive.read(f"{info}/METADATA").decode())
for archive in (theirs, ours))
for field in fields:
self.assertEqual(theirs_meta[field], ours_meta[field], field)
if __name__ == "__main__":
unittest.main()
+13 -2
View File
@@ -110,8 +110,8 @@ scripts stay Python.
`platforms/**`, `emulators/**`, `schemas/**`, `scripts/**`, `tests/**`,
`install.py`, `install.sh` or `install.ps1` (the bootstraps pin the
installer's SHA-256, so editing one without the other fails the run),
`ports/**` (the handheld port bundles the installer), or any input of the site
build. The path lists are spelled out once per event because the workflow
`ports/**` (the handheld port bundles the installer), `packaging/**` (the PyPI
package is built from it), or any input of the site build. The path lists are spelled out once per event because the workflow
parser reads no YAML anchor, and a test holds the two equal.
**Concurrency.** Per-PR group on a pull request, per-ref on a push. A pull
@@ -312,6 +312,17 @@ git status --short
git add release.json README.md install/*.json install/targets/*.json
git commit -m "chore: record release v${DATE}" && git push
# 5b. The PyPI package, `uvx retrobios`: install.py and its command. The
# version is the date of the tag release.json now names (v2026.10.04
# gives 2026.10.4), so a release is what bumps it. The build replaces
# any earlier one in tmp/pypi-dist. Build, run the wheel, then upload
# the two files. The token stays in its file: never in the
# repository, a script or a log.
python scripts/build_pypi.py --output-dir tmp/pypi-dist
python -m unittest tests.test_pypi_package
uvx --from tmp/pypi-dist/retrobios-*.whl retrobios --help
uv publish --token "$(cat ~/.config/retrobios/pypi-token)" tmp/pypi-dist/*
# 6. Keep only the new release plus large-files: an older pack carries hashes
# the platforms no longer check, so it misleads more than it helps
gh release list --json tagName,createdAt \