mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-11 12:33:53 -06:00
chore: merge origin/main
This commit is contained in:
commit
025fd08a72
16 files changed
+484
-9
No files matched your search
@@ -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/**"
|
||||
@@ -241,9 +244,10 @@ jobs:
|
||||
# heading guard's premise, and skipped on every run without it. pyte
|
||||
# reads the installer's full screen back in its end-to-end tests,
|
||||
# hypothesis fuzzes its key decoder, and vermin holds install.py to 3.8.
|
||||
# setuptools reads pyproject.toml for the packaging guard.
|
||||
# setuptools reads pyproject.toml for the packaging guard, and uv builds
|
||||
# the PyPI package the way a release builds it.
|
||||
- name: Install dependencies
|
||||
run: pip install pyyaml "jsonschema[format-nongpl]==4.26.0" "markdown==3.10.2" "pyte==0.8.2" "hypothesis==6.170.8" "vermin==1.9.2" "setuptools==80.9.0"
|
||||
run: pip install pyyaml "jsonschema[format-nongpl]==4.26.0" "markdown==3.10.2" "pyte==0.8.2" "hypothesis==6.170.8" "vermin==1.9.2" "setuptools==80.9.0" "uv==0.12.9"
|
||||
|
||||
# The full screen also runs under the 3.8 floor: the end-to-end tests
|
||||
# start install.py with the interpreter that runs them.
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@ validation above is its check. For code, profiles or platform files, these are
|
||||
the checks CI runs on a pull request (Python 3.12 or newer):
|
||||
|
||||
```bash
|
||||
pip install pyyaml "jsonschema[format-nongpl]==4.26.0" "markdown==3.10.2" "pyte==0.8.2" "hypothesis==6.170.8" "vermin==1.9.2" "setuptools==80.9.0"
|
||||
pip install pyyaml "jsonschema[format-nongpl]==4.26.0" "markdown==3.10.2" "pyte==0.8.2" "hypothesis==6.170.8" "vermin==1.9.2" "setuptools==80.9.0" "uv==0.12.9"
|
||||
python scripts/pr_checks.py
|
||||
```
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ irm https://abdess.github.io/retrobios/i.ps1 | iex
|
||||
curl -fsSL https://abdess.github.io/retrobios/i | sh
|
||||
```
|
||||
|
||||
**With Python 3.8 or newer**: `uvx retrobios` runs the same installer, and so does `pipx run retrobios`.
|
||||
|
||||
**A handheld, an SD card, or not sure?** [Pick the device](https://abdess.github.io/retrobios/which-pack/): its page gives the folder and the command. A muOS, Onion, spruceOS, MinUI, KNULLI, ROCKNIX, Lakka or MiSTer SD card plugged into the computer is found by itself.
|
||||
|
||||

|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# RetroBIOS
|
||||
|
||||
Puts the BIOS files emulators need in the right folder, and checks them.
|
||||
|
||||
It finds the 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/
|
||||
@@ -0,0 +1,51 @@
|
||||
# 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 stages this file, README.md, LICENSE,
|
||||
# retrobios/__main__.py and install.py into a build folder, writes
|
||||
# retrobios/__init__.py with the version, and runs `uv build` there.
|
||||
[build-system]
|
||||
# Pinned: the same backend writes the same bytes from the same files. 3.12
|
||||
# writes core metadata 2.4, the version this package was checked under
|
||||
# (twine check --strict). 4.x writes 2.5.
|
||||
requires = ["flit_core==3.12.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
|
||||
[project]
|
||||
name = "retrobios"
|
||||
dynamic = ["version"]
|
||||
description = "Install the BIOS files emulators need, checked"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
license = "MIT"
|
||||
license-files = ["LICENSE"]
|
||||
dependencies = []
|
||||
keywords = [
|
||||
"bios", "firmware", "emulation", "emulator", "retrogaming", "installer",
|
||||
"retroarch", "batocera", "emudeck", "retrobat", "recalbox", "retrodeck",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Topic :: System :: Emulators",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://abdess.github.io/retrobios/"
|
||||
Documentation = "https://abdess.github.io/retrobios/wiki/installer/"
|
||||
Source = "https://github.com/Abdess/retrobios"
|
||||
Issues = "https://github.com/Abdess/retrobios/issues"
|
||||
Releases = "https://github.com/Abdess/retrobios/releases"
|
||||
|
||||
[project.scripts]
|
||||
retrobios = "retrobios:main"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""`python -m retrobios` runs the installer as the retrobios command does."""
|
||||
|
||||
from retrobios import main
|
||||
|
||||
main()
|
||||
+6
-1
@@ -10,7 +10,10 @@ packages = []
|
||||
py-modules = []
|
||||
|
||||
[project]
|
||||
name = "retrobios"
|
||||
# Not the package PyPI serves (packaging/pypi/ builds that one, named
|
||||
# retrobios): a contributor's `pip install -e ".[dev]"` and `pip install
|
||||
# retrobios` in one environment would otherwise replace each other.
|
||||
name = "retrobios-dev"
|
||||
version = "1.0.0"
|
||||
description = "BIOS collection manager for retrogaming platforms"
|
||||
# install.py is the only entry point a user runs before cloning, and both
|
||||
@@ -38,6 +41,8 @@ dev = [
|
||||
# installs only in its isolated build environment and 3.12 no longer
|
||||
# bundles: without it the guard skipped on every CI run.
|
||||
"setuptools>=61",
|
||||
# scripts/build_pypi.py runs `uv build` for the PyPI package and its test.
|
||||
"uv==0.12.9",
|
||||
# The type check CI runs, pinned: another mypy reports other errors
|
||||
# than the ones mypy-baseline.txt holds.
|
||||
"mypy==1.18.2",
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/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 launcher that runs it as a script (`retrobios` and
|
||||
`python -m retrobios`), the metadata, README.md and LICENSE. Nothing of the
|
||||
collection. packaging/pypi/ holds the project file, the README and
|
||||
`__main__.py`; this script stages them with install.py and LICENSE into a
|
||||
build folder, writes `__init__.py` with the version, and runs `uv build`
|
||||
there with the flit_core backend pinned in the project file.
|
||||
|
||||
The version is the UTC date of the last commit that changed install.py,
|
||||
with the zeros PEP 440 drops left out: 2026-11-09 gives 2026.11.9. When
|
||||
install.py changed several times that day, the newest gets .N, its rank in
|
||||
the day: the third change gives 2026.11.9.3. The same install.py always
|
||||
gives the same version, so PyPI refuses a republish of unchanged code.
|
||||
SOURCE_DATE_EPOCH is that commit's time, so two builds of one commit give
|
||||
the same bytes.
|
||||
|
||||
Usage:
|
||||
python scripts/build_pypi.py
|
||||
python scripts/build_pypi.py --output-dir tmp/pypi-dist
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
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__")
|
||||
'''
|
||||
|
||||
|
||||
def package_version(stamps: list[int]) -> str:
|
||||
"""The version of install.py from its commit times, newest first (UTC)."""
|
||||
if not stamps:
|
||||
raise ValueError("install.py has no commit to date the package by")
|
||||
day = datetime.datetime.fromtimestamp(stamps[0], datetime.timezone.utc).date()
|
||||
rank = sum(1 for stamp in stamps
|
||||
if datetime.datetime.fromtimestamp(stamp, datetime.timezone.utc).date() == day)
|
||||
base = f"{day.year}.{day.month}.{day.day}"
|
||||
return base if rank == 1 else f"{base}.{rank}"
|
||||
|
||||
|
||||
def install_py_commits() -> list[int]:
|
||||
"""Commit times of install.py, newest first, from git."""
|
||||
out = subprocess.run(["git", "log", "--format=%ct", "--", "install.py"], cwd=ROOT,
|
||||
capture_output=True, text=True, check=True).stdout
|
||||
return [int(line) for line in out.split()]
|
||||
|
||||
|
||||
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 stage(folder: Path, version: str) -> None:
|
||||
"""The project as the backend reads it."""
|
||||
module = folder / MODULE
|
||||
module.mkdir(parents=True)
|
||||
for name in ("pyproject.toml", "README.md"):
|
||||
shutil.copyfile(PACKAGE_DIR / name, folder / name)
|
||||
shutil.copyfile(ROOT / "LICENSE", folder / "LICENSE")
|
||||
shutil.copyfile(PACKAGE_DIR / MODULE / "__main__.py", module / "__main__.py")
|
||||
(module / "__init__.py").write_text(LAUNCHER.format(version=version), encoding="utf-8")
|
||||
(module / "install.py").write_bytes(installer_bytes())
|
||||
|
||||
|
||||
def build(output_dir: Path) -> tuple[Path, Path]:
|
||||
"""The wheel and the sdist in output_dir, replacing earlier builds there."""
|
||||
stamps = install_py_commits()
|
||||
version = package_version(stamps)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
for pattern in ("*.whl", "*.tar.gz"):
|
||||
for old in output_dir.glob(f"{MODULE}-{pattern}"):
|
||||
old.unlink()
|
||||
scratch = ROOT / "tmp"
|
||||
scratch.mkdir(exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="pypi-stage-", dir=scratch) as name:
|
||||
folder = Path(name) / "retrobios"
|
||||
stage(folder, version)
|
||||
# Without a flag, uv builds the sdist, then the wheel from that sdist:
|
||||
# what a user building from the sdist gets is what is published.
|
||||
subprocess.run(
|
||||
["uv", "build", "--quiet", "--out-dir", str(output_dir.resolve()), str(folder)],
|
||||
check=True, env={**os.environ, "SOURCE_DATE_EPOCH": str(stamps[0])})
|
||||
wheel = output_dir / f"{MODULE}-{version}-py3-none-any.whl"
|
||||
sdist = output_dir / f"{MODULE}-{version}.tar.gz"
|
||||
for path in (wheel, sdist):
|
||||
if not path.is_file():
|
||||
raise RuntimeError(f"uv build wrote no {path.name}")
|
||||
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)
|
||||
@@ -39,7 +39,7 @@ from common import (
|
||||
)
|
||||
from nativemode import check_label
|
||||
import release_record
|
||||
from siteguides import install_command
|
||||
from siteguides import PACKAGE_COMMAND, install_command
|
||||
from validate_pr import LARGE_FILE_ISSUE_URL
|
||||
from largefiles import load_registered_paths
|
||||
import pr_checks
|
||||
@@ -649,6 +649,9 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json"
|
||||
install_command("sh"),
|
||||
"```",
|
||||
"",
|
||||
f"**With Python 3.8 or newer**: `{PACKAGE_COMMAND}` runs the same installer, and so"
|
||||
" does `pipx run retrobios`.",
|
||||
"",
|
||||
f"**A handheld, an SD card, or not sure?** [Pick the device]({SITE_URL}which-pack/):"
|
||||
" its page gives the folder and the command. A muOS, Onion, spruceOS, MinUI, KNULLI,"
|
||||
" ROCKNIX, Lakka or MiSTer SD card plugged into the computer is found by itself.",
|
||||
|
||||
@@ -112,6 +112,7 @@ from siteguides import (
|
||||
INSTALL_BASE,
|
||||
INSTALL_PS1,
|
||||
INSTALL_SH,
|
||||
PACKAGE_COMMAND,
|
||||
TERMINALS,
|
||||
TRUST_ITEMS,
|
||||
detected_systems as _detected_systems,
|
||||
@@ -11951,6 +11952,11 @@ def generate_which_pack() -> str:
|
||||
# handheld recording (sitesteps).
|
||||
box_tabs = (*all_tabs, "device")
|
||||
box_steps, box_demos = sitesteps.install_box(box_tabs)
|
||||
# Python is on most Linux and macOS machines: the package runs the same
|
||||
# installer there without the bootstrap.
|
||||
for tab in ("macos", "linux"):
|
||||
box_demos[tab] = [*box_demos[tab],
|
||||
f"With Python 3.8 or newer, `{PACKAGE_COMMAND}` does the same.", ""]
|
||||
lines = [
|
||||
"---",
|
||||
f"terms: {', '.join(IOS_TERMS)}",
|
||||
|
||||
@@ -393,6 +393,8 @@ INSTALL_BASE: str = _LOADED["install_base"]
|
||||
# The bootstraps the site serves (sitebootstrap.py writes them).
|
||||
INSTALL_SH: str = f"{INSTALL_BASE}/{SH_NAME}"
|
||||
INSTALL_PS1: str = f"{INSTALL_BASE}/{PS1_NAME}"
|
||||
# The same installer from PyPI (scripts/build_pypi.py builds the package).
|
||||
PACKAGE_COMMAND = "uvx retrobios"
|
||||
DEVICE_GROUPS: dict[str, tuple[str, str]] = _LOADED["device_groups"]
|
||||
GROUP_ANCHORS: dict[str, str] = _LOADED["group_anchors"]
|
||||
ALSO_RUNS: dict[str, tuple[tuple[str, str, str, str, str], ...]] = _LOADED["also_runs"]
|
||||
|
||||
@@ -1564,7 +1564,7 @@ def _literals() -> str:
|
||||
VALUE = re.compile(r"^(?:\S*[\d/\\~<>{}=]\S*|--?[\w-]+\S*|\S*[A-Z]{2,}\S*|\.\.\.|"
|
||||
r"\S*\.\w{2,4}[,.:;)]?|[A-Z][a-z]+[A-Z]\S*|\(\S*)$")
|
||||
# A code span that is a command, a path or a shape, not a message.
|
||||
NOT_A_MESSAGE = re.compile(r"^(?:\{|-|\.|/|~|<|\$|%|python|py |curl|irm|sh |pkg |sudo|ls |"
|
||||
NOT_A_MESSAGE = re.compile(r"^(?:\{|-|\.|/|~|<|\$|%|python|py |curl|irm|uvx |pipx |pip |sh |pkg |sudo|ls |"
|
||||
r"xcode|winget|termux|install\.py|scripts/|generate_|&\()")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""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 stages the
|
||||
project and builds it with `uv build` and the flit_core backend, 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 configparser
|
||||
import email.parser
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
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/__main__.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:
|
||||
if shutil.which("uv") is None:
|
||||
if os.environ.get("CI"):
|
||||
raise AssertionError("CI installs uv")
|
||||
raise unittest.SkipTest("uv is not installed")
|
||||
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())
|
||||
scripts = configparser.ConfigParser()
|
||||
scripts.read_string(entry_points)
|
||||
self.assertEqual(dict(scripts["console_scripts"]), {"retrobios": "retrobios:main"})
|
||||
self.assertEqual(meta["Name"], "retrobios")
|
||||
self.assertEqual(meta["Version"], self.version)
|
||||
self.assertEqual(meta["Requires-Python"], ">=3.8")
|
||||
self.assertIsNone(meta["Requires-Dist"])
|
||||
# The backend ends the body with one more newline than the file.
|
||||
self.assertEqual(meta.get_payload().rstrip("\n"),
|
||||
(REPO_ROOT / "packaging/pypi/README.md").read_text(
|
||||
encoding="utf-8").rstrip("\n"))
|
||||
|
||||
def test_metadata(self):
|
||||
info = f"retrobios-{self.version}.dist-info"
|
||||
with zipfile.ZipFile(self.wheel) as archive:
|
||||
meta = email.parser.Parser().parsestr(archive.read(f"{info}/METADATA").decode())
|
||||
self.assertEqual(meta["Metadata-Version"], "2.4")
|
||||
self.assertEqual(meta["License-Expression"], "MIT")
|
||||
classifiers = meta.get_all("Classifier")
|
||||
# PEP 639: the license is the expression, never a classifier as well.
|
||||
self.assertEqual([name for name in classifiers if name.startswith("License ::")], [])
|
||||
self.assertEqual(
|
||||
[name for name in classifiers if name.startswith("Programming Language :: Python :: 3.")],
|
||||
[f"Programming Language :: Python :: 3.{minor}" for minor in range(8, 15)])
|
||||
for name in ("Programming Language :: Python :: 3 :: Only",
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: End Users/Desktop", "Environment :: Console",
|
||||
"Operating System :: OS Independent", "Topic :: System :: Emulators"):
|
||||
self.assertIn(name, classifiers)
|
||||
self.assertTrue(meta["Keywords"])
|
||||
self.assertEqual(sorted(url.split(",")[0] for url in meta.get_all("Project-URL")),
|
||||
["Documentation", "Homepage", "Issues", "Releases", "Source"])
|
||||
|
||||
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):
|
||||
# SOURCE_DATE_EPOCH is install.py's commit time, so the clock moving
|
||||
# between the two builds changes no byte.
|
||||
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 TheVersionIsInstallPyCommitDate(unittest.TestCase):
|
||||
"""The release date put November code under an October version: the
|
||||
version now follows the commits of install.py itself."""
|
||||
|
||||
def test_one_change_that_day(self):
|
||||
# 2026-11-09 12:00 UTC, then a change the day before.
|
||||
self.assertEqual(build_pypi.package_version([1794225600, 1794139200]), "2026.11.9")
|
||||
|
||||
def test_the_rank_in_the_day(self):
|
||||
self.assertEqual(build_pypi.package_version([1794229200, 1794225600, 1794222000]),
|
||||
"2026.11.9.3")
|
||||
|
||||
def test_no_commit_refused(self):
|
||||
with self.assertRaises(ValueError):
|
||||
build_pypi.package_version([])
|
||||
|
||||
|
||||
class AnUnpinnedInstallerIsRefused(unittest.TestCase):
|
||||
"""The package runs the installer the one-line command runs, or none."""
|
||||
|
||||
def test_pin_mismatch(self):
|
||||
with tempfile.TemporaryDirectory(prefix="pypi-pin-") as name:
|
||||
root = Path(name)
|
||||
(root / "install.py").write_bytes(b"print('another')\n")
|
||||
shutil.copyfile(REPO_ROOT / "install.sh", root / "install.sh")
|
||||
original = build_pypi.ROOT
|
||||
build_pypi.ROOT = root
|
||||
try:
|
||||
with self.assertRaises(ValueError):
|
||||
build_pypi.installer_bytes()
|
||||
finally:
|
||||
build_pypi.ROOT = original
|
||||
|
||||
|
||||
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)
|
||||
# `retrobios` and `python -m retrobios` both run install.py.
|
||||
launchers = ([str(command)], [str(command.with_name("python")), "-m", "retrobios"])
|
||||
for launcher in launchers:
|
||||
for args in (["--help"], ["--version"], ["--no-such-option"]):
|
||||
with self.subTest(python=name, launcher=launcher[-1], args=args):
|
||||
packaged = subprocess.run([*launcher, *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")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -40,6 +40,15 @@ irm https://abdess.github.io/retrobios/i.ps1 | iex
|
||||
Always type the `https://`: without it the first request goes out in clear
|
||||
text, and anyone on the way could answer with another script.
|
||||
|
||||
With Python 3.8 or newer, the same installer is a package:
|
||||
|
||||
```bash
|
||||
uvx retrobios
|
||||
```
|
||||
|
||||
`pipx run retrobios` works too, or `pip install retrobios` and then
|
||||
`retrobios`. Options follow the command: `uvx retrobios --check`.
|
||||
|
||||
`i` and `i.ps1` are `install.sh` and `install.ps1` as of the commit the site
|
||||
was built from, set to download `install.py` from that same commit. The
|
||||
fingerprint they check and the installer they fetch never come from two
|
||||
|
||||
+17
-2
@@ -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,21 @@ 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 UTC date of the last commit that changed install.py
|
||||
# (2026-11-09 gives 2026.11.9, the third change that day 2026.11.9.3),
|
||||
# so an unchanged install.py keeps its version and PyPI refuses it
|
||||
# again. Publishing is not tied to a release: any push that changes
|
||||
# install.py can be published. The build replaces any earlier one in
|
||||
# tmp/pypi-dist. `uv build` builds it with the flit_core backend the
|
||||
# project pins, dated by that commit (SOURCE_DATE_EPOCH), so a rebuild
|
||||
# gives the same bytes. 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 \
|
||||
|
||||
@@ -59,7 +59,7 @@ Every module in `tests/`, by the area it guards.
|
||||
| Packs and manifests | synthetic + real packs | `test_artifact_lock`, `test_atomic_artifacts`, `test_batocera_cores`, `test_core_data_trees`, `test_deterministic_zip`, `test_emulator_pack_counts`, `test_finalize_pack_names`, `test_large_file_cache`, `test_libretro_layout`, `test_manifest_entry`, `test_minui_platform`, `test_no_game_data`, `test_pack_conformance_dirs`, `test_pack_core_extras`, `test_pack_counts`, `test_pack_destinations`, `test_pack_failures`, `test_pack_file_names`, `test_pack_grouping`, `test_pack_integrity`, `test_pack_member_identity`, `test_pack_readme`, `test_platform_inheritance`, `test_platform_keys`, `test_release_packs`, `test_required_only_archives`, `test_required_only_twins`, `test_save_root`, `test_split_extras`, `test_split_names`, `test_split_pack`, `test_system_pack_extras`, `test_system_pack_parity`, `test_torrentzip`, `test_which_pack` |
|
||||
| Slots and regions | synthetic + profiles | `test_region`, `test_region_agnostic_scan`, `test_region_groups`, `test_region_held`, `test_region_platform_index`, `test_region_required_only`, `test_slot`, `test_slots`, `test_slots_shipped_file` |
|
||||
| Hardware targets and systems | synthetic + target files | `test_amberelec_targets`, `test_emuelec_targets`, `test_extract_targets`, `test_lakka_targets`, `test_libretro_slugs`, `test_system_ids`, `test_system_selection`, `test_target_names`, `test_target_scrapers`, `test_target_systems`, `test_target_verify` |
|
||||
| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_app`, `test_install_from`, `test_install_games`, `test_install_offline`, `test_install_selection`, `test_install_tui` |
|
||||
| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_app`, `test_install_from`, `test_install_games`, `test_install_offline`, `test_install_selection`, `test_install_tui`, `test_pypi_package` |
|
||||
| Handheld launchers | a pty as the console, fake installer and firmware tools, stubbed PortMaster dialogs | `test_handheld_launchers`, `test_port` |
|
||||
| Running cores | parsers on fixed inputs, a libretro core built from C in the test when a compiler exists | `test_core_probe` |
|
||||
| Measurement records | synthetic record files, the committed `measurements/` against HEAD, the **Proof** line naming the BIOS a core ran, and that no page shows a Tested block, a region outcome or a time per frame | `test_measurements` |
|
||||
|
||||
Reference in new issue
Block a user