Skip to content

Commit 5a53614

Browse files
Support Windows CPython interpreters distributed by non-standard orgs (#2504)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 8cef45f commit 5a53614

File tree

6 files changed

+222
-191
lines changed

6 files changed

+222
-191
lines changed

docs/changelog/2504.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Discover CPython implementations distributed on Windows by any organization - by :user:`faph`.

src/virtualenv/discovery/windows/__init__.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
from ..py_spec import PythonSpec
33
from .pep514 import discover_pythons
44

5+
# Map of well-known organizations (as per PEP 514 Company Windows Registry key part) versus Python implementation
6+
_IMPLEMENTATION_BY_ORG = {
7+
"ContinuumAnalytics": "CPython",
8+
"PythonCore": "CPython",
9+
}
10+
511

612
class Pep514PythonInfo(PythonInfo):
713
"""A Python information acquired from PEP-514"""
@@ -19,13 +25,17 @@ def propose_interpreters(spec, cache_dir, env):
1925
)
2026

2127
for name, major, minor, arch, exe, _ in existing:
22-
# pre-filter
23-
if name in ("PythonCore", "ContinuumAnalytics"):
24-
name = "CPython"
25-
registry_spec = PythonSpec(None, name, major, minor, None, arch, exe)
26-
if registry_spec.satisfies(spec):
28+
# Map well-known/most common organizations to a Python implementation, use the org name as a fallback for
29+
# backwards compatibility.
30+
implementation = _IMPLEMENTATION_BY_ORG.get(name, name)
31+
32+
# Pre-filtering based on Windows Registry metadata, for CPython only
33+
skip_pre_filter = implementation.lower() != "cpython"
34+
registry_spec = PythonSpec(None, implementation, major, minor, None, arch, exe)
35+
if skip_pre_filter or registry_spec.satisfies(spec):
2736
interpreter = Pep514PythonInfo.from_exe(exe, cache_dir, env=env, raise_on_error=False)
2837
if interpreter is not None:
38+
# Final filtering/matching using interpreter metadata
2939
if interpreter.satisfies(spec, impl_must_match=True):
3040
yield interpreter
3141

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from contextlib import contextmanager
2+
from pathlib import Path
3+
4+
import pytest
5+
6+
7+
@pytest.fixture()
8+
def _mock_registry(mocker):
9+
from virtualenv.discovery.windows.pep514 import winreg
10+
11+
loc, glob = {}, {}
12+
mock_value_str = (Path(__file__).parent / "winreg-mock-values.py").read_text()
13+
exec(mock_value_str, glob, loc)
14+
enum_collect = loc["enum_collect"]
15+
value_collect = loc["value_collect"]
16+
key_open = loc["key_open"]
17+
hive_open = loc["hive_open"]
18+
19+
def _enum_key(key, at):
20+
key_id = key.value if isinstance(key, Key) else key
21+
result = enum_collect[key_id][at]
22+
if isinstance(result, OSError):
23+
raise result
24+
return result
25+
26+
mocker.patch.object(winreg, "EnumKey", side_effect=_enum_key)
27+
28+
def _query_value_ex(key, value_name):
29+
key_id = key.value if isinstance(key, Key) else key
30+
result = value_collect[key_id][value_name]
31+
if isinstance(result, OSError):
32+
raise result
33+
return result
34+
35+
mocker.patch.object(winreg, "QueryValueEx", side_effect=_query_value_ex)
36+
37+
class Key:
38+
def __init__(self, value):
39+
self.value = value
40+
41+
def __enter__(self):
42+
return self
43+
44+
def __exit__(self, exc_type, exc_val, exc_tb): # noqa: U100
45+
return None
46+
47+
@contextmanager
48+
def _open_key_ex(*args):
49+
if len(args) == 2:
50+
key, value = args
51+
key_id = key.value if isinstance(key, Key) else key
52+
result = Key(key_open[key_id][value]) # this needs to be something that can be with-ed, so let's wrap it
53+
elif len(args) == 4:
54+
result = hive_open[args]
55+
else:
56+
raise RuntimeError
57+
value = result.value if isinstance(result, Key) else result
58+
if isinstance(value, OSError):
59+
raise value
60+
yield result
61+
62+
mocker.patch.object(winreg, "OpenKeyEx", side_effect=_open_key_ex)
63+
mocker.patch("os.path.exists", return_value=True)
64+
65+
66+
def _mock_pyinfo(major, minor, arch, exe):
67+
"""Return PythonInfo objects with essential metadata set for the given args"""
68+
from virtualenv.discovery.py_info import PythonInfo, VersionInfo
69+
70+
info = PythonInfo()
71+
info.base_prefix = str(Path(exe).parent)
72+
info.executable = info.original_executable = info.system_executable = exe
73+
info.implementation = "CPython"
74+
info.architecture = arch
75+
info.version_info = VersionInfo(major, minor, 0, "final", 0)
76+
return info
77+
78+
79+
@pytest.fixture()
80+
def _populate_pyinfo_cache(monkeypatch):
81+
"""Add metadata to virtualenv.discovery.cached_py_info._CACHE for all (mocked) registry entries"""
82+
import virtualenv.discovery.cached_py_info
83+
84+
# Data matches _mock_registry fixture
85+
interpreters = [
86+
("ContinuumAnalytics", 3, 10, 32, "C:\\Users\\user\\Miniconda3\\python.exe", None),
87+
("ContinuumAnalytics", 3, 10, 64, "C:\\Users\\user\\Miniconda3-64\\python.exe", None),
88+
("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe", None),
89+
("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe", None),
90+
("PythonCore", 3, 5, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python35\\python.exe", None),
91+
("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe", None),
92+
("PythonCore", 3, 7, 32, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python37-32\\python.exe", None),
93+
("PythonCore", 3, 12, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", None),
94+
("PythonCore", 2, 7, 64, "C:\\Python27\\python.exe", None),
95+
("PythonCore", 3, 4, 64, "C:\\Python34\\python.exe", None),
96+
("CompanyA", 3, 6, 64, "Z:\\CompanyA\\Python\\3.6\\python.exe", None),
97+
]
98+
for _, major, minor, arch, exe, _ in interpreters:
99+
info = _mock_pyinfo(major, minor, arch, exe)
100+
monkeypatch.setitem(virtualenv.discovery.cached_py_info._CACHE, Path(info.executable), info)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import sys
2+
3+
import pytest
4+
5+
from virtualenv.discovery.py_spec import PythonSpec
6+
7+
8+
@pytest.mark.skipif(sys.platform != "win32", reason="no Windows registry")
9+
@pytest.mark.usefixtures("_mock_registry")
10+
@pytest.mark.usefixtures("_populate_pyinfo_cache")
11+
@pytest.mark.parametrize(
12+
("string_spec", "expected_exe"),
13+
[
14+
# 64-bit over 32-bit
15+
("python3.10", "C:\\Users\\user\\Miniconda3-64\\python.exe"),
16+
("cpython3.10", "C:\\Users\\user\\Miniconda3-64\\python.exe"),
17+
# 1 installation of 3.9 available
18+
("python3.12", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"),
19+
("cpython3.12", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"),
20+
# resolves to highest available version
21+
("python", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"),
22+
("cpython", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"),
23+
# Non-standard org name
24+
("python3.6", "Z:\\CompanyA\\Python\\3.6\\python.exe"),
25+
("cpython3.6", "Z:\\CompanyA\\Python\\3.6\\python.exe"),
26+
],
27+
)
28+
def test_propose_interpreters(string_spec, expected_exe):
29+
from virtualenv.discovery.windows import propose_interpreters
30+
31+
spec = PythonSpec.from_string_spec(string_spec)
32+
interpreter = next(propose_interpreters(spec=spec, cache_dir=None, env=None))
33+
assert interpreter.executable == expected_exe
Lines changed: 22 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
import sys
22
import textwrap
3-
from collections import defaultdict
4-
from contextlib import contextmanager
5-
from pathlib import Path
63

74
import pytest
85

@@ -14,16 +11,17 @@ def test_pep514():
1411

1512
interpreters = list(discover_pythons())
1613
assert interpreters == [
17-
("ContinuumAnalytics", 3, 7, 32, "C:\\Users\\user\\Miniconda3\\python.exe", None),
18-
("ContinuumAnalytics", 3, 7, 64, "C:\\Users\\user\\Miniconda3-64\\python.exe", None),
19-
("PythonCore", 3, 6, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe", None),
20-
("PythonCore", 3, 6, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe", None),
21-
("PythonCore", 3, 5, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python35\\python.exe", None),
22-
("PythonCore", 3, 6, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe", None),
23-
("PythonCore", 3, 7, 32, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python37-32\\python.exe", None),
24-
("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe", None),
14+
("ContinuumAnalytics", 3, 10, 32, "C:\\Users\\user\\Miniconda3\\python.exe", None),
15+
("ContinuumAnalytics", 3, 10, 64, "C:\\Users\\user\\Miniconda3-64\\python.exe", None),
16+
("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", None),
17+
("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", None),
18+
("PythonCore", 3, 8, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe", None),
19+
("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", None),
20+
("PythonCore", 3, 10, 32, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python310-32\\python.exe", None),
21+
("PythonCore", 3, 12, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", None),
22+
("CompanyA", 3, 6, 64, "Z:\\CompanyA\\Python\\3.6\\python.exe", None),
2523
("PythonCore", 2, 7, 64, "C:\\Python27\\python.exe", None),
26-
("PythonCore", 3, 4, 64, "C:\\Python34\\python.exe", None),
24+
("PythonCore", 3, 7, 64, "C:\\Python37\\python.exe", None),
2725
]
2826

2927

@@ -36,16 +34,17 @@ def test_pep514_run(capsys, caplog):
3634
out, err = capsys.readouterr()
3735
expected = textwrap.dedent(
3836
r"""
39-
('ContinuumAnalytics', 3, 7, 32, 'C:\\Users\\user\\Miniconda3\\python.exe', None)
40-
('ContinuumAnalytics', 3, 7, 64, 'C:\\Users\\user\\Miniconda3-64\\python.exe', None)
37+
('CompanyA', 3, 6, 64, 'Z:\\CompanyA\\Python\\3.6\\python.exe', None)
38+
('ContinuumAnalytics', 3, 10, 32, 'C:\\Users\\user\\Miniconda3\\python.exe', None)
39+
('ContinuumAnalytics', 3, 10, 64, 'C:\\Users\\user\\Miniconda3-64\\python.exe', None)
4140
('PythonCore', 2, 7, 64, 'C:\\Python27\\python.exe', None)
42-
('PythonCore', 3, 4, 64, 'C:\\Python34\\python.exe', None)
43-
('PythonCore', 3, 5, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python35\\python.exe', None)
44-
('PythonCore', 3, 6, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe', None)
45-
('PythonCore', 3, 6, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe', None)
46-
('PythonCore', 3, 6, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe', None)
47-
('PythonCore', 3, 7, 32, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python37-32\\python.exe', None)
48-
('PythonCore', 3, 9, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe', None)
41+
('PythonCore', 3, 10, 32, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python310-32\\python.exe', None)
42+
('PythonCore', 3, 12, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe', None)
43+
('PythonCore', 3, 7, 64, 'C:\\Python37\\python.exe', None)
44+
('PythonCore', 3, 8, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe', None)
45+
('PythonCore', 3, 9, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', None)
46+
('PythonCore', 3, 9, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', None)
47+
('PythonCore', 3, 9, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', None)
4948
""",
5049
).strip()
5150
assert out.strip() == expected
@@ -56,136 +55,9 @@ def test_pep514_run(capsys, caplog):
5655
f"{prefix}HKEY_CURRENT_USER/PythonCore/3.2/SysArchitecture error: arch is not string: 100",
5756
f"{prefix}HKEY_CURRENT_USER/PythonCore/3.3 error: no ExecutablePath or default for it",
5857
f"{prefix}HKEY_CURRENT_USER/PythonCore/3.3 error: could not load exe with value None",
59-
f"{prefix}HKEY_CURRENT_USER/PythonCore/3.8/InstallPath error: missing",
60-
f"{prefix}HKEY_CURRENT_USER/PythonCore/3.9/SysVersion error: invalid format magic",
58+
f"{prefix}HKEY_CURRENT_USER/PythonCore/3.11/InstallPath error: missing",
59+
f"{prefix}HKEY_CURRENT_USER/PythonCore/3.12/SysVersion error: invalid format magic",
6160
f"{prefix}HKEY_CURRENT_USER/PythonCore/3.X/SysVersion error: version is not string: 2778",
6261
f"{prefix}HKEY_CURRENT_USER/PythonCore/3.X error: invalid format 3.X",
6362
]
6463
assert caplog.messages == expected_logs
65-
66-
67-
@pytest.fixture()
68-
def _mock_registry(mocker):
69-
from virtualenv.discovery.windows.pep514 import winreg
70-
71-
loc, glob = {}, {}
72-
mock_value_str = (Path(__file__).parent / "winreg-mock-values.py").read_text()
73-
exec(mock_value_str, glob, loc)
74-
enum_collect = loc["enum_collect"]
75-
value_collect = loc["value_collect"]
76-
key_open = loc["key_open"]
77-
hive_open = loc["hive_open"]
78-
79-
def _e(key, at):
80-
key_id = key.value if isinstance(key, Key) else key
81-
result = enum_collect[key_id][at]
82-
if isinstance(result, OSError):
83-
raise result
84-
return result
85-
86-
mocker.patch.object(winreg, "EnumKey", side_effect=_e)
87-
88-
def _v(key, value_name):
89-
key_id = key.value if isinstance(key, Key) else key
90-
result = value_collect[key_id][value_name]
91-
if isinstance(result, OSError):
92-
raise result
93-
return result
94-
95-
mocker.patch.object(winreg, "QueryValueEx", side_effect=_v)
96-
97-
class Key:
98-
def __init__(self, value):
99-
self.value = value
100-
101-
def __enter__(self):
102-
return self
103-
104-
def __exit__(self, exc_type, exc_val, exc_tb): # noqa: U100
105-
return None
106-
107-
@contextmanager
108-
def _o(*args):
109-
if len(args) == 2:
110-
key, value = args
111-
key_id = key.value if isinstance(key, Key) else key
112-
result = Key(key_open[key_id][value]) # this needs to be something that can be with-ed, so let's wrap it
113-
elif len(args) == 4:
114-
result = hive_open[args]
115-
else:
116-
raise RuntimeError
117-
value = result.value if isinstance(result, Key) else result
118-
if isinstance(value, OSError):
119-
raise value
120-
yield result
121-
122-
mocker.patch.object(winreg, "OpenKeyEx", side_effect=_o)
123-
mocker.patch("os.path.exists", return_value=True)
124-
125-
126-
@pytest.fixture()
127-
def _collect_winreg_access(mocker):
128-
# noinspection PyUnresolvedReferences
129-
from winreg import EnumKey, OpenKeyEx, QueryValueEx
130-
131-
from virtualenv.discovery.windows.pep514 import winreg
132-
133-
hive_open = {}
134-
key_open = defaultdict(dict)
135-
136-
@contextmanager
137-
def _c(*args):
138-
res = None
139-
key_id = id(args[0]) if len(args) == 2 else None
140-
try:
141-
with OpenKeyEx(*args) as c:
142-
res = id(c)
143-
yield c
144-
except Exception as exception:
145-
res = exception
146-
raise exception
147-
finally:
148-
if len(args) == 4:
149-
hive_open[args] = res
150-
elif len(args) == 2:
151-
key_open[key_id][args[1]] = res
152-
153-
enum_collect = defaultdict(list)
154-
155-
def _e(key, at):
156-
result = None
157-
key_id = id(key)
158-
try:
159-
result = EnumKey(key, at)
160-
return result
161-
except Exception as exception:
162-
result = exception
163-
raise result
164-
finally:
165-
enum_collect[key_id].append(result)
166-
167-
value_collect = defaultdict(dict)
168-
169-
def _v(key, value_name):
170-
result = None
171-
key_id = id(key)
172-
try:
173-
result = QueryValueEx(key, value_name)
174-
return result
175-
except Exception as exception:
176-
result = exception
177-
raise result
178-
finally:
179-
value_collect[key_id][value_name] = result
180-
181-
mocker.patch.object(winreg, "EnumKey", side_effect=_e)
182-
mocker.patch.object(winreg, "QueryValueEx", side_effect=_v)
183-
mocker.patch.object(winreg, "OpenKeyEx", side_effect=_c)
184-
185-
yield
186-
187-
print("")
188-
print(f"hive_open = {hive_open}")
189-
print(f"key_open = {dict(key_open.items())}")
190-
print(f"value_collect = {dict(value_collect.items())}")
191-
print(f"enum_collect = {dict(enum_collect.items())}")

0 commit comments

Comments
 (0)