Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/1723.bugfix.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 8 additions & 1 deletion src/pipx/interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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


Expand Down
30 changes: 30 additions & 0 deletions tests/test_interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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