diff --git a/desktop/app.py b/desktop/app.py index f3294a71d6..f44c3a67c1 100644 --- a/desktop/app.py +++ b/desktop/app.py @@ -828,31 +828,58 @@ def _bootstrap_python_platform(cache_file: Optional[Path]) -> str: def _known_good_python() -> Optional[str]: - """The pinned per-user python.org interpreter, if it is on disk and - passes the usual probe — the exact path `_winget_install_python` - installs to. - - Addressed by path, NOT by re-probing: `_bootstrap_python()` tries - `py` first, and the `py` launcher resolves to the NEWEST installed - interpreter, which in the case this exists to fix is precisely the - one that has no wheels. A re-probe would hand back the broken - interpreter and the retry would fail identically.""" + """The pinned python.org interpreter, if it is on disk and passes the + usual probe. + + Checks the exact per-user path ``_winget_install_python`` writes first, + then the Windows Registry (HKCU before HKLM) where the python.org + installer records the installation directory for every minor version it + manages. Both paths are exact filesystem paths — no py launcher, no + re-probing.""" if platform.system() != "Windows": return None - exe = (Path(os.environ.get("LOCALAPPDATA", "")) - / "Programs" / "Python" / KNOWN_GOOD_PYTHON_DIRNAME / "python.exe") - if not exe.exists(): - return None + candidates = [] + # Primary: the per-user path _winget_install_python writes. + per_user = (Path(os.environ.get("LOCALAPPDATA", "")) + / "Programs" / "Python" / KNOWN_GOOD_PYTHON_DIRNAME + / "python.exe") + if per_user.exists(): + candidates.append(str(per_user)) + # Secondary: the registered install path. The python.org installer + # writes SOFTWARE\Python\PythonCore\\InstallPath in HKCU + # (per-user install) and HKLM (system-wide install). Reading the + # registry directly gives the exact path without involving the py + # launcher or any re-probing. winreg is stdlib on Windows; the + # ImportError branch handles non-Windows test runners. try: - r = subprocess.run( - [str(exe), "-c", _PYTHON_PROBE], - capture_output=True, text=True, timeout=20, - env=_child_env(), stdin=subprocess.DEVNULL, - **_win_subprocess_kwargs(), - ) - return str(exe) if r.returncode == 0 else None - except Exception: - return None + import winreg # type: ignore[import] + reg_subkey = (rf"SOFTWARE\Python\PythonCore" + rf"\{KNOWN_GOOD_PYTHON_MINOR}\InstallPath") + for hive in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE): + try: + with winreg.OpenKey(hive, reg_subkey) as k: + install_dir, _ = winreg.QueryValueEx(k, "") + exe = Path(install_dir) / "python.exe" + path = str(exe) + if path not in candidates and exe.exists(): + candidates.append(path) + except OSError: + pass + except ImportError: + pass + for candidate in candidates: + try: + r = subprocess.run( + [candidate, "-c", _PYTHON_PROBE], + capture_output=True, text=True, timeout=20, + env=_child_env(), stdin=subprocess.DEVNULL, + **_win_subprocess_kwargs(), + ) + if r.returncode == 0: + return candidate + except Exception: + pass + return None def _winget_install_python(log: Callable[[str], None]) -> None: diff --git a/tests/test_desktop_bootstrap_resilience.py b/tests/test_desktop_bootstrap_resilience.py index 50cdc5254a..9ff1bca761 100644 --- a/tests/test_desktop_bootstrap_resilience.py +++ b/tests/test_desktop_bootstrap_resilience.py @@ -645,6 +645,76 @@ def test_interpreter_retry_is_windows_only(tmp_path, monkeypatch): 1, _NO_WHEEL_FOR_INTERPRETER) == (1, _NO_WHEEL_FOR_INTERPRETER) +def test_known_good_python_falls_back_to_registry_for_system_install( + tmp_path, monkeypatch): + """Python 3.12 installed system-wide (registered in HKLM, not at the + per-user LOCALAPPDATA path) is found via the Windows Registry and + returned as an exact filesystem path — no py launcher involved.""" + import types + _windows(monkeypatch) + monkeypatch.setenv("LOCALAPPDATA", "/no-such-localappdata") + monkeypatch.setattr(dapp, "_win_subprocess_kwargs", lambda: {}) + + system_exe = tmp_path / "python.exe" + system_exe.write_text("") + + # Inject a fake winreg that reports tmp_path as the HKLM install dir. + fake_winreg = types.ModuleType("winreg") + fake_winreg.HKEY_CURRENT_USER = 0x80000001 + fake_winreg.HKEY_LOCAL_MACHINE = 0x80000002 + + class _FakeKey: + def __enter__(self): return self + def __exit__(self, *a): return False + + def _fake_open(hive, path): + if hive == fake_winreg.HKEY_LOCAL_MACHINE: + return _FakeKey() + raise OSError("not found") + + def _fake_query(key, name): + # On Windows the registry stores the path with a trailing backslash; + # omit it here so Path(install_dir) / "python.exe" resolves correctly + # on the Linux CI runner (a backslash is not a separator on Linux). + return (str(tmp_path), 1) + + fake_winreg.OpenKey = _fake_open + fake_winreg.QueryValueEx = _fake_query + monkeypatch.setitem(sys.modules, "winreg", fake_winreg) + + import subprocess as _sp + + def fake_run(cmd, **kwargs): + if cmd[0] == str(system_exe): + return _sp.CompletedProcess(cmd, 0, "", "") + return _sp.CompletedProcess(cmd, 1, "", "") + + monkeypatch.setattr(dapp.subprocess, "run", fake_run) + + result = dapp._known_good_python() + assert result == str(system_exe), ( + "when the LOCALAPPDATA path is absent, _known_good_python() must " + "find a system-wide Python 3.12 via the Windows Registry" + ) + + +def test_known_good_python_returns_none_when_no_candidates(monkeypatch): + """When neither the per-user path nor any registry path provides a + usable Python 3.12, _known_good_python() returns None without spawning + any subprocess.""" + _windows(monkeypatch) + monkeypatch.setenv("LOCALAPPDATA", "/no-such-localappdata") + monkeypatch.setattr(dapp, "_win_subprocess_kwargs", lambda: {}) + # On Linux winreg is absent (ImportError caught inside the function), + # so no registry candidates are added. subprocess.run must not be called. + called = [] + monkeypatch.setattr(dapp.subprocess, "run", + lambda *a, **k: called.append(a) or None) + result = dapp._known_good_python() + assert result is None + assert not called, "subprocess.run must not be called when no candidates exist" + + def test_probe_caches_interpreter_version(tmp_path): cache = tmp_path / "bootstrap-python.json" py = dapp._bootstrap_python(cache)