diff --git a/install.ps1 b/install.ps1 index 7c2665d48..e8b9e6441 100644 --- a/install.ps1 +++ b/install.ps1 @@ -16,7 +16,7 @@ # and Invoke-WebRequest throws on a cut transfer before the hash is read: # the site's build rewrites this line there (scripts/sitebootstrap.py). $mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." - $defaultInstallSha256 = "8dd510a5dbc2468248a74e7bb53b791729b194a6ab97e4cc9d900ffa620fa2ba" + $defaultInstallSha256 = "6bd77b283f20cad55d53df8a2e7fe8ef177e821f4dfade37e2d12af2fa3f3852" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 2c3330387..974e1aa82 100644 --- a/install.py +++ b/install.py @@ -6215,6 +6215,20 @@ def _command_rank(option: tuple[str, str | None]) -> int: return _COMMAND_ORDER.index(flag) if flag in _COMMAND_ORDER else len(_COMMAND_ORDER) +def package_command(prefix: str) -> str: + """The command that ran the retrobios package, read from the environment + it runs in. uvx runs it from uv's cache (~/.cache/uv/archive-v0/ + with uv 0.12.9) and pipx run from pipx's (~/.cache/pipx/ with + pipx 1.4.3), and neither leaves a retrobios command behind. pip, pipx + install (pipx's venvs/) and uv tool install do.""" + parts = re.split(r"[\\/]", prefix) + if "archive-v0" in parts: + return "uvx retrobios" + if any(part in ("pipx", ".pipx") for part in parts) and "venvs" not in parts: + return "pipx run retrobios" + return "retrobios" + + class Invocation: """How this run was started, so that a hint gives the command back whole. @@ -6271,6 +6285,8 @@ class Invocation: return f"&([scriptblock]::Create((irm {BOOTSTRAP_URL}.ps1)))" if bootstrap == "sh": return f"curl -fsSL {BOOTSTRAP_URL} | {'sudo ' if elevated else ''}sh -s --" + if bootstrap == "pypi": + return f"{'sudo ' if elevated else ''}{package_command(sys.prefix)}" python = "python" if self.os_type == "windows" else "python3" return f"{'sudo ' if elevated else ''}{python} install.py" diff --git a/install.sh b/install.sh index 464425309..6cef9a276 100755 --- a/install.sh +++ b/install.sh @@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins # curl or wget stop on a cut transfer before the hash is read: the site's # build rewrites this line there (scripts/sitebootstrap.py). MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." -DEFAULT_INSTALL_SHA256="8dd510a5dbc2468248a74e7bb53b791729b194a6ab97e4cc9d900ffa620fa2ba" +DEFAULT_INSTALL_SHA256="6bd77b283f20cad55d53df8a2e7fe8ef177e821f4dfade37e2d12af2fa3f3852" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 5904a0e9b..e79aa82c2 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1125340, - "sha256": "8dd510a5dbc2468248a74e7bb53b791729b194a6ab97e4cc9d900ffa620fa2ba", + "size": 1126086, + "sha256": "6bd77b283f20cad55d53df8a2e7fe8ef177e821f4dfade37e2d12af2fa3f3852", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1178965, - "sha256": "beaf9d9caafd8e94953605a78fb878787a811646065764ed325b38223eb6a479" + "size": 1179711, + "sha256": "184146fc9785a1aee6b69f0fae3fac5100dc8cb5558a6f369a5cc1ad3301377d" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1125340, - "sha256": "8dd510a5dbc2468248a74e7bb53b791729b194a6ab97e4cc9d900ffa620fa2ba", + "size": 1126086, + "sha256": "6bd77b283f20cad55d53df8a2e7fe8ef177e821f4dfade37e2d12af2fa3f3852", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1125340, - "sha256": "8dd510a5dbc2468248a74e7bb53b791729b194a6ab97e4cc9d900ffa620fa2ba", + "size": 1126086, + "sha256": "6bd77b283f20cad55d53df8a2e7fe8ef177e821f4dfade37e2d12af2fa3f3852", "executable": false }, { diff --git a/scripts/build_pypi.py b/scripts/build_pypi.py index 93dbf262f..61f1f0b1d 100644 --- a/scripts/build_pypi.py +++ b/scripts/build_pypi.py @@ -44,6 +44,7 @@ MODULE = "retrobios" # 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 os import runpy from pathlib import Path @@ -51,6 +52,8 @@ __version__ = "{version}" def main() -> None: + # The hints give back the command that ran this, not python3 install.py. + os.environ["RETROBIOS_BOOTSTRAP"] = "pypi" runpy.run_path(str(Path(__file__).with_name("install.py")), run_name="__main__") ''' diff --git a/tests/test_install.py b/tests/test_install.py index 262a033ed..bb1ce4719 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -7624,5 +7624,32 @@ class TheListVersionIsSaidBeforeAndAfter(unittest.TestCase): self.assertNotIn("This list was built for", proc.stdout) +class APackageHintNamesThePackageCommand(unittest.TestCase): + """Run from the retrobios package, a hint said `python3 install.py`, + a file the user does not have.""" + + def test_the_environment_names_the_command(self): + for prefix, command in ( + ("/home/me/.cache/uv/archive-v0/UwL4i3xOlCEYSTnw", "uvx retrobios"), + ("C:\\Users\\me\\AppData\\Local\\uv\\cache\\archive-v0\\x1", "uvx retrobios"), + ("/home/me/.cache/pipx/539b8243c11ee7c", "pipx run retrobios"), + ("/home/me/.local/pipx/.cache/539b8243c11ee7c", "pipx run retrobios"), + ("/home/me/.local/share/pipx/venvs/retrobios", "retrobios"), + ("/home/me/.local/share/uv/tools/retrobios", "retrobios"), + ("/home/me/venv", "retrobios"), + ): + with self.subTest(prefix=prefix): + self.assertEqual(install.package_command(prefix), command) + + def test_the_launcher_is_the_package_command(self): + hint = install.Invocation() + with unittest.mock.patch.dict(os.environ, {"RETROBIOS_BOOTSTRAP": "pypi"}), \ + unittest.mock.patch.object(install.sys, "prefix", "/home/me/.cache/uv/archive-v0/a"): + self.assertEqual(hint.launcher(), "uvx retrobios") + self.assertEqual(hint.launcher(elevated=True), "sudo uvx retrobios") + self.assertTrue(hint.command("retroarch", "DIR").startswith( + "uvx retrobios --platform retroarch --dest DIR")) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_pypi_package.py b/tests/test_pypi_package.py index 4e49f5f69..2785257c9 100644 --- a/tests/test_pypi_package.py +++ b/tests/test_pypi_package.py @@ -186,8 +186,10 @@ class TheInstalledCommandIsTheInstaller(Built): def _same_answers(self, python: str, name: str) -> None: command = self._installed(python, name) - # `retrobios` and `python -m retrobios` both run install.py. + # `retrobios` and `python -m retrobios` both run install.py, whose + # examples then name the package command. launchers = ([str(command)], [str(command.with_name("python")), "-m", "retrobios"]) + as_package = {**os.environ, "RETROBIOS_BOOTSTRAP": "pypi"} for launcher in launchers: for args in (["--help"], ["--version"], ["--no-such-option"]): with self.subTest(python=name, launcher=launcher[-1], args=args): @@ -195,7 +197,7 @@ class TheInstalledCommandIsTheInstaller(Built): 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) + cwd=self.scratch, env=as_package) self.assertEqual( (packaged.returncode, packaged.stdout, packaged.stderr), (script.returncode, script.stdout, script.stderr)) @@ -203,6 +205,20 @@ class TheInstalledCommandIsTheInstaller(Built): def test_this_python(self): self._same_answers(sys.executable, "venv-current") + def test_a_hint_gives_back_the_package_command(self): + """Nothing detected, the example said `python3 install.py`.""" + command = self._installed(sys.executable, "venv-hint") + home = self.scratch / "empty-home" + home.mkdir() + env = {**os.environ, "HOME": str(home), "USERPROFILE": str(home), + "RETROBIOS_INSTALL_OFFLINE": "1"} + env.pop("RETROBIOS_BOOTSTRAP", None) + run = subprocess.run([str(command), "--yes"], capture_output=True, text=True, + check=False, cwd=self.scratch, env=env) + out = run.stdout + run.stderr + self.assertIn("retrobios --platform retroarch --dest", out) + self.assertNotIn("install.py --platform", out) + def test_python_38(self): python = _python38() if python is None: diff --git a/wiki/installer.md b/wiki/installer.md index e2557ee95..29a2e5f1d 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1891,7 +1891,7 @@ RetroArch folder that holds only `scph5501.bin`, in the order written: | `EXTERNAL_STORAGE` | Android only, the shared storage root RetroArch builds its default directories from. Defaults to `/storage/emulated/0` | | `RETROBIOS_INSTALL_URL` | Where the bootstrap fetches `install.py`. HTTPS only | | `RETROBIOS_INSTALL_SHA256` | The SHA-256 the bootstrap requires of that installer, 64 hex characters. When either of these two is set and the fingerprint does not match, the error names the variable instead of suggesting a retry | -| `RETROBIOS_BOOTSTRAP` | Set by `install.sh` (`sh`) and `install.ps1` (`ps1`) for the temporary installer they run, so that its hints give back the one-line command rather than `python3 install.py` | +| `RETROBIOS_BOOTSTRAP` | Set by `install.sh` (`sh`) and `install.ps1` (`ps1`) for the temporary installer they run, and by the `retrobios` package (`pypi`), so that its hints give back the command that ran it (the one-liner, `uvx retrobios`, `pipx run retrobios` or `retrobios`) rather than `python3 install.py` | | `NO_COLOR` | Set to any value but an empty one: no colour, even at a terminal. The full screen then marks the row in focus with `>` as well as reverse video. What else turns colour off is under [The full-screen interface](#the-full-screen-interface) | | `TERM`, `TERMINFO`, `TERMINFO_DIRS` | Decide whether the full screen opens, as [The full-screen interface](#the-full-screen-interface) says. The terminal's description is read where ncurses reads it: `TERMINFO`, then `~/.terminfo`, then each folder of `TERMINFO_DIRS`, then the system's own | | `FORCE_COLOR`, `CLICOLOR_FORCE` | Colour even when the output is a pipe or a file, set to any value but `0` or an empty one. `NO_COLOR` wins over both, and the progress line stays one line per file |