diff --git a/changelog.d/1723.bugfix.md b/changelog.d/1723.bugfix.md new file mode 100644 index 0000000000..a9fd4abb09 --- /dev/null +++ b/changelog.d/1723.bugfix.md @@ -0,0 +1,3 @@ +Fixed `find_py_launcher_python` to verify that the path returned by the Windows `py` launcher actually exists before returning it. + +The `py` launcher can return stale registry entries for Python versions that have been uninstalled or moved, causing pipx to fail with "did not find executable at path" errors. diff --git a/src/pipx/interpreter.py b/src/pipx/interpreter.py index e7d3ddb3b9..4cafbe861d 100644 --- a/src/pipx/interpreter.py +++ b/src/pipx/interpreter.py @@ -121,7 +121,9 @@ def find_python_interpreter(python_version: str, fetch_missing_python: bool = Fa def find_py_launcher_python(python_version: Optional[str] = None) -> Optional[str]: py = shutil.which("py") - if py and python_version: + if not py: + return None + if python_version: python_semver = python_version if python_version.startswith("python"): logger.warning( @@ -134,6 +136,11 @@ def find_py_launcher_python(python_version: Optional[str] = None) -> Optional[st text=True, check=True, ).stdout.strip() + # Verify the path returned by py launcher actually exists + # (it may return stale registry entries for uninstalled Python versions) + if not Path(py).is_file(): + logger.info(f"py launcher returned non-existent path: {py}") + return None return py diff --git a/tests/test_interpreter.py b/tests/test_interpreter.py index c1f76b7509..5beddcdd22 100644 --- a/tests/test_interpreter.py +++ b/tests/test_interpreter.py @@ -199,3 +199,33 @@ def which(name): else: assert python_path.endswith("python3") subprocess.run([python_path, "-c", "import sys; print(sys.executable)"], check=True) + + +def test_py_launcher_stale_path_returns_none(monkeypatch): + """Test that find_py_launcher_python returns None when py launcher returns a non-existent path. + + This can happen when Python is uninstalled but registry entries remain, + causing the py launcher to return stale paths. + """ + from pipx.interpreter import find_py_launcher_python + + def which(name): + if name == "py": + return "py" + return None + + class MockCompletedProcess: + def __init__(self, stdout): + self.stdout = stdout + + # Simulate py launcher returning a non-existent path + non_existent_path = "/nonexistent/path/to/python.exe" + monkeypatch.setattr(shutil, "which", which) + monkeypatch.setattr( + subprocess, + "run", + lambda *args, **kwargs: MockCompletedProcess(non_existent_path), + ) + + result = find_py_launcher_python("3.14") + assert result is None