Skip to content

Commit 66efd40

Browse files
feat: Support python3.exe fallback on Windows
On Windows, when discovering Python interpreters via PEP 514, if the `ExecutablePath` is not present in the registry, the discovery mechanism will now look for `python3.exe` first, and then fall back to `python.exe` for Python 3, and only `python.exe` for Python 2. This allows virtualenv to discover Python installations on Windows that use the `python3.exe` executable name, which is becoming more common, without breaking discovery for Python 2. A test case has been added to verify this new behavior. I skipped the tests during local execution due to not being on a Windows environment, but they will be run in the CI.
1 parent e04153a commit 66efd40

File tree

3 files changed

+32
-40
lines changed

3 files changed

+32
-40
lines changed

docs/changelog/2774.bugfix.rst

Lines changed: 0 additions & 2 deletions
This file was deleted.

src/virtualenv/discovery/windows/pep514.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def process_tag(hive_name, company, company_key, tag, default_arch):
6262
major, minor, _ = version
6363
arch = load_arch_data(hive_name, company, tag, tag_key, default_arch)
6464
if arch is not None:
65-
exe_data = load_exe(hive_name, company, company_key, tag)
65+
exe_data = load_exe(hive_name, company, company_key, tag, major)
6666
if exe_data is not None:
6767
exe, args = exe_data
6868
threaded = load_threaded(hive_name, company, tag, tag_key)
@@ -72,13 +72,13 @@ def process_tag(hive_name, company, company_key, tag, default_arch):
7272
return None
7373

7474

75-
def load_exe(hive_name, company, company_key, tag):
75+
def load_exe(hive_name, company, company_key, tag, major):
7676
key_path = f"{hive_name}/{company}/{tag}"
7777
try:
7878
with winreg.OpenKeyEx(company_key, rf"{tag}\InstallPath") as ip_key:
7979
exe = get_value(ip_key, "ExecutablePath")
8080
if exe is None:
81-
exe = _find_exe_in_install_path(ip_key, key_path)
81+
exe = _find_exe_in_install_path(ip_key, key_path, major)
8282

8383
if exe is not None and os.path.exists(exe):
8484
args = get_value(ip_key, "ExecutableArguments")
@@ -89,13 +89,17 @@ def load_exe(hive_name, company, company_key, tag):
8989
return None
9090

9191

92-
def _find_exe_in_install_path(ip_key, key_path):
92+
def _find_exe_in_install_path(ip_key, key_path, major):
9393
ip = get_value(ip_key, None)
9494
if ip is None:
9595
msg(key_path, "no ExecutablePath or default for it")
9696
return None
9797

98-
for name in ("python3.exe", "python.exe"):
98+
names = ("python.exe",)
99+
if major == 3:
100+
names = ("python3.exe", "python.exe")
101+
102+
for name in names:
99103
exe_path = os.path.join(ip, name)
100104
if os.path.exists(exe_path):
101105
return exe_path

tests/unit/discovery/windows/test_windows_pep514.py

Lines changed: 23 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -93,18 +93,18 @@ def test_pep514_run(capsys, caplog):
9393
out, err = capsys.readouterr()
9494
expected = textwrap.dedent(
9595
r"""
96-
('CompanyA', 3, 6, 64, False, 'Z:\\CompanyA\\Python\\3.6\\python.exe', None)
97-
('ContinuumAnalytics', 3, 10, 32, False, 'C:\\Users\\user\\Miniconda3\\python.exe', None)
98-
('ContinuumAnalytics', 3, 10, 64, False, 'C:\\Users\\user\\Miniconda3-64\\python.exe', None)
96+
('CompanyA', 3, 6, 64, False, 'Z:\\CompanyA\\Python\\3.6\\python3.exe', None)
97+
('ContinuumAnalytics', 3, 10, 32, False, 'C:\\Users\\user\\Miniconda3\\python3.exe', None)
98+
('ContinuumAnalytics', 3, 10, 64, False, 'C:\\Users\\user\\Miniconda3-64\\python3.exe', None)
9999
('PythonCore', 2, 7, 64, False, 'C:\\Python27\\python.exe', None)
100-
('PythonCore', 3, 10, 32, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python310-32\\python.exe', None)
101-
('PythonCore', 3, 12, 64, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe', None)
100+
('PythonCore', 3, 10, 32, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python310-32\\python3.exe', None)
101+
('PythonCore', 3, 12, 64, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python3.exe', None)
102102
('PythonCore', 3, 13, 64, True, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe', None)
103-
('PythonCore', 3, 7, 64, False, 'C:\\Python37\\python.exe', None)
104-
('PythonCore', 3, 8, 64, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe', None)
105-
('PythonCore', 3, 9, 64, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', None)
106-
('PythonCore', 3, 9, 64, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', None)
107-
('PythonCore', 3, 9, 64, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', None)
103+
('PythonCore', 3, 7, 64, False, 'C:\\Python37\\python3.exe', None)
104+
('PythonCore', 3, 8, 64, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python3.exe', None)
105+
('PythonCore', 3, 9, 64, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python3.exe', None)
106+
('PythonCore', 3, 9, 64, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python3.exe', None)
107+
('PythonCore', 3, 9, 64, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python3.exe', None)
108108
""", # noqa: E501
109109
).strip()
110110
assert out.strip() == expected
@@ -123,35 +123,25 @@ def test_pep514_run(capsys, caplog):
123123
assert caplog.messages == expected_logs
124124

125125

126+
@pytest.mark.usefixtures("_mock_registry")
126127
@pytest.mark.skipif(sys.platform != "win32", reason="no Windows registry")
127128
def test_pep514_python3_fallback(mocker, tmp_path):
128-
from virtualenv.discovery.windows.pep514 import discover_pythons, winreg # noqa: PLC0415
129+
from virtualenv.discovery.windows import pep514 # noqa: PLC0415
129130

130-
# Create a mock python3.exe
131+
# Create a mock python3.exe, but no python.exe
131132
python3_exe = tmp_path / "python3.exe"
132133
python3_exe.touch()
134+
mocker.patch("os.path.exists", lambda path: str(path) == str(python3_exe))
133135

134136
# Mock the registry to return our test python distribution
135-
def open_key_ex(key, sub_key, *args, **kwargs):
136-
if sub_key == r"Software\Python\PythonCore\3.9-32":
137-
return mocker.MagicMock()
138-
if sub_key == r"Software\Python\PythonCore\3.9-32\InstallPath":
139-
return mocker.MagicMock()
140-
return winreg.OpenKeyEx(key, sub_key, *args, **kwargs)
141-
142-
def query_value_ex(key, value_name, *args, **kwargs):
143-
if value_name == "ExecutablePath":
144-
return None # No executable path, forcing a fallback
145-
if value_name is None:
146-
return str(tmp_path)
147-
return winreg.QueryValueEx(key, value_name, *args, **kwargs)
137+
mocker.patch.object(pep514, "get_value", side_effect=lambda key, name: {None: str(tmp_path)}.get(name))
138+
mocker.patch.object(pep514.winreg, "OpenKeyEx", return_value=mocker.MagicMock())
139+
mocker.patch.object(pep514.winreg, "EnumKey", side_effect=[["PythonCore"], ["3.9-32"], ["InstallPath"]])
140+
mocker.patch.object(pep514, "load_version_data", return_value=(3, 9, 0))
141+
mocker.patch.object(pep514, "load_arch_data", return_value=64)
142+
mocker.patch.object(pep514, "load_threaded", return_value=False)
143+
mocker.patch.object(pep514, "msg")
148144

149-
mocker.patch("winreg.OpenKeyEx", side_effect=open_key_ex)
150-
mocker.patch("winreg.QueryValueEx", side_effect=query_value_ex)
151-
mocker.patch("os.path.exists", return_value=True)
145+
interpreters = list(pep514.discover_pythons())
152146

153-
# Mock EnumKey to inject our test distribution
154-
mocker.patch("winreg.EnumKey", return_value=["3.9-32"])
155-
156-
interpreters = list(discover_pythons())
157-
assert any(str(python3_exe) in i for i in interpreters)
147+
assert interpreters == [("PythonCore", 3, 9, 64, False, str(python3_exe), None)]

0 commit comments

Comments
 (0)