diff --git a/install.ps1 b/install.ps1 index c41c4003..d9b8fa97 100644 --- a/install.ps1 +++ b/install.ps1 @@ -64,24 +64,51 @@ $installer = $temporary } - $launcher = @() - $python = Get-Command py -ErrorAction SilentlyContinue - if ($python) { - $launcher = @("-3") - } else { - $python = Get-Command python3 -ErrorAction SilentlyContinue - if (-not $python) { - $python = Get-Command python -ErrorAction SilentlyContinue + # The py launcher first, then python3 and python. The Microsoft Store + # places a python.exe that only points at the Store: it fails the + # version check like a missing one. + $findPython = { + $found = Get-Command py -ErrorAction SilentlyContinue + if ($found) { return ,@($found, @("-3")) } + foreach ($name in @("python3", "python")) { + $found = Get-Command $name -ErrorAction SilentlyContinue + if ($found) { return ,@($found, @()) } } + return $null + } + $usable = { + param($candidate) + if (-not $candidate) { return $false } + $arguments = $candidate[1] + & $candidate[0].Source @arguments -c "import sys; raise SystemExit(sys.version_info < (3, 8))" | Out-Null + return ($LASTEXITCODE -eq 0) } $needPython = "Python 3.8 or newer is required. Install it with " + "'winget install Python.Python.3.12' or from https://www.python.org/downloads/ " + "(tick 'Add python.exe to PATH'), then open a new PowerShell window and run this command again." - if (-not $python) { - throw $needPython + $candidate = & $findPython + if (-not (& $usable $candidate)) { + # winget ships with Windows 10 and 11: at a console the bootstrap + # offers to run the install the message names, then carries on. + $winget = Get-Command winget -ErrorAction SilentlyContinue + if ($winget -and -not [Console]::IsInputRedirected) { + $answer = Read-Host "Python 3.8 or newer is required. Install Python 3.12 now with winget? [Y/n]" + if ($answer -notmatch '^\s*[nNqQ]') { + & $winget.Source install --id Python.Python.3.12 --exact --source winget ` + --accept-package-agreements --accept-source-agreements + # The installer adds Python to the PATH of new sessions: + # this one reads it again from the registry. + if ($env:OS -eq "Windows_NT") { + $env:Path = [Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + + [Environment]::GetEnvironmentVariable("Path", "User") + } + $candidate = & $findPython + } + } + if (-not (& $usable $candidate)) { throw $needPython } } - & $python.Source @launcher -c "import sys; raise SystemExit(sys.version_info < (3, 8))" - if ($LASTEXITCODE -ne 0) { throw $needPython } + $python = $candidate[0] + $launcher = $candidate[1] # The installer rebuilds this command in its hints, and the user has # no install.py of their own to run. $previousBootstrap = $env:RETROBIOS_BOOTSTRAP diff --git a/tests/test_install.py b/tests/test_install.py index 25567634..c16ca60e 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -2415,6 +2415,52 @@ class NoPythonNamesThisSystemsCommand(unittest.TestCase): self.assertNotIn("apt", proc.stderr) +@unittest.skipUnless(shutil.which("pwsh") and shutil.which("script"), "needs PowerShell and a terminal") +class WindowsWithoutPythonIsOfferedWinget(unittest.TestCase): + """A Windows machine without Python was told to install it, open a new + window and run the command again, while winget, present on Windows 10 + and 11, could do it from the bootstrap.""" + + def _folder(self) -> tuple[Path, Path]: + folder = Path(_tempdir(self)) + shutil.copy(REPO_ROOT / "install.ps1", folder / "install.ps1") + (folder / "install.py").write_text("print('installer ran')\n") + fake_bin = folder / "bin" + fake_bin.mkdir() + python = shutil.which("python3") or sys.executable + (fake_bin / "winget").write_text( + f"#!/bin/sh\necho \"winget $*\"\n{shutil.which('ln')} -s {shlex.quote(python)} " + f"{shlex.quote(str(fake_bin / 'python3'))}\n" + ) + (fake_bin / "winget").chmod(0o755) + return folder, fake_bin + + def _run(self, folder: Path, fake_bin: Path, answer: str) -> subprocess.CompletedProcess: + pwsh = shutil.which("pwsh") + command = (f"{shlex.quote(pwsh)} -NoProfile -File " + f"{shlex.quote(str(folder / 'install.ps1'))}") + return subprocess.run( + [shutil.which("script"), "-qec", command, "/dev/null"], input=answer, + env={**os.environ, "PATH": f"{fake_bin}{os.pathsep}/usr/bin/nonexistent"}, + capture_output=True, text=True, timeout=120, check=False, + ) + + def test_yes_installs_and_carries_on(self): + folder, fake_bin = self._folder() + proc = self._run(folder, fake_bin, "y\n") + self.assertIn("Install Python 3.12 now with winget?", proc.stdout) + self.assertIn("winget install --id Python.Python.3.12", proc.stdout) + self.assertIn("installer ran", proc.stdout) + self.assertEqual(proc.returncode, 0, proc.stdout) + + def test_no_names_the_command(self): + folder, fake_bin = self._folder() + proc = self._run(folder, fake_bin, "n\n") + self.assertNotIn("installer ran", proc.stdout) + self.assertIn("winget install Python.Python.3.12", proc.stdout) + self.assertFalse((fake_bin / "python3").exists()) + + class ThePowerShellWrapperPassesTheStatusOn(unittest.TestCase): """Every non-zero exit became a red "installer failed" exception and code 1: a --check that found work read as a crash, 2 and 130 were lost.""" diff --git a/wiki/installer.md b/wiki/installer.md index 05fe93c6..57dae073 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -62,8 +62,13 @@ the same way: waiting five minutes settles it. Any change to `install.py` therefore means recomputing that pin in both wrappers. They locate a Python 3.8 or newer interpreter (`python3` then `python` on -POSIX, `py -3` then `python3` then `python` on Windows) and refuse to continue -without one. `install.sh` needs `curl` or `wget` and `sha256sum` or `shasum`. +POSIX, `py -3` then `python3` then `python` on Windows). Without one, +`install.sh` names the command that installs it on the system it runs on +(`pkg install python` in Termux, which it offers to run since Termux installs +without root, `xcode-select --install` on macOS, apt, dnf, pacman, zypper or apk +from `/etc/os-release`), and `install.ps1` at a console offers to run `winget +install Python.Python.3.12`, then reads the new PATH and carries on. +`install.sh` needs `curl` or `wget` (GNU or BusyBox) and `sha256sum` or `shasum`. Windows PowerShell 5.1 still negotiates SSL 3.0 and TLS 1.0 by default and GitHub refuses both, so `install.ps1` raises the floor to TLS 1.2 before downloading, and `install.sh` pins the same floor with `curl --tlsv1.2`.