diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 56ec2698f..f1d763f40 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2798e727b..fae56bf3c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 ``` diff --git a/README.md b/README.md index 516f93170..229784b98 100644 --- a/README.md +++ b/README.md @@ -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. ![Recording of the one-line command in a terminal, on a demonstration card. The installer finds an ArkOS SD card with 6 games, plans 43 BIOS files (15.1 MB) for them, installs them and shows the summary.](docs_assets/demo/install.gif) diff --git a/packaging/pypi/README.md b/packaging/pypi/README.md new file mode 100644 index 000000000..f2c1a0f9e --- /dev/null +++ b/packaging/pypi/README.md @@ -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/ diff --git a/packaging/pypi/pyproject.toml b/packaging/pypi/pyproject.toml new file mode 100644 index 000000000..fe52886c3 --- /dev/null +++ b/packaging/pypi/pyproject.toml @@ -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" diff --git a/packaging/pypi/retrobios/__main__.py b/packaging/pypi/retrobios/__main__.py new file mode 100644 index 000000000..e73f79f0a --- /dev/null +++ b/packaging/pypi/retrobios/__main__.py @@ -0,0 +1,5 @@ +"""`python -m retrobios` runs the installer as the retrobios command does.""" + +from retrobios import main + +main() diff --git a/pyproject.toml b/pyproject.toml index bf5a325ab..ca2e08700 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/scripts/build_pypi.py b/scripts/build_pypi.py new file mode 100644 index 000000000..93dbf262f --- /dev/null +++ b/scripts/build_pypi.py @@ -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) diff --git a/scripts/generate_readme.py b/scripts/generate_readme.py index ccb75a42a..ef377d211 100644 --- a/scripts/generate_readme.py +++ b/scripts/generate_readme.py @@ -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.", diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 8bc0c8303..6a4cc5978 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -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)}", diff --git a/scripts/siteguides.py b/scripts/siteguides.py index f50cc8b85..20502183b 100644 --- a/scripts/siteguides.py +++ b/scripts/siteguides.py @@ -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"] diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index fd05ee777..0b58a1f26 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -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_|&\()") diff --git a/tests/test_pypi_package.py b/tests/test_pypi_package.py new file mode 100644 index 000000000..4e49f5f69 --- /dev/null +++ b/tests/test_pypi_package.py @@ -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() diff --git a/wiki/installer.md b/wiki/installer.md index bb03a6939..0834cfb52 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -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 diff --git a/wiki/release-process.md b/wiki/release-process.md index 1384801be..104eab64a 100644 --- a/wiki/release-process.md +++ b/wiki/release-process.md @@ -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 \ diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index a7f5362ca..866986ee0 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -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` |