Skip to content

Commit 477ce18

Browse files
Allow builtin interpreter discovery to find specific Python versions given a general spec (#2709)
Co-authored-by: Bernát Gábor <gaborjbernat@gmail.com>
1 parent 4a13deb commit 477ce18

File tree

6 files changed

+102
-75
lines changed

6 files changed

+102
-75
lines changed

docs/changelog/2709.bugfix.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
allow builtin discovery to discover specific interpreters (e.g. ``python3.12``) given an unspecific spec (e.g. ``python3``) - by :user:`flying-sheep`.

src/virtualenv/discovery/builtin.py

+63-43
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,35 @@
33
import logging
44
import os
55
import sys
6+
from pathlib import Path
7+
from typing import TYPE_CHECKING, Callable
68

79
from virtualenv.info import IS_WIN
810

911
from .discover import Discover
1012
from .py_info import PythonInfo
1113
from .py_spec import PythonSpec
1214

15+
if TYPE_CHECKING:
16+
from argparse import ArgumentParser
17+
from collections.abc import Generator, Iterable, Mapping, Sequence
18+
19+
from virtualenv.app_data.base import AppData
20+
1321

1422
class Builtin(Discover):
23+
python_spec: Sequence[str]
24+
app_data: AppData
25+
try_first_with: Sequence[str]
26+
1527
def __init__(self, options) -> None:
1628
super().__init__(options)
1729
self.python_spec = options.python or [sys.executable]
1830
self.app_data = options.app_data
1931
self.try_first_with = options.try_first_with
2032

2133
@classmethod
22-
def add_parser_arguments(cls, parser):
34+
def add_parser_arguments(cls, parser: ArgumentParser) -> None:
2335
parser.add_argument(
2436
"-p",
2537
"--python",
@@ -41,7 +53,7 @@ def add_parser_arguments(cls, parser):
4153
help="try first these interpreters before starting the discovery",
4254
)
4355

44-
def run(self):
56+
def run(self) -> PythonInfo | None:
4557
for python_spec in self.python_spec:
4658
result = get_interpreter(python_spec, self.try_first_with, self.app_data, self._env)
4759
if result is not None:
@@ -53,7 +65,9 @@ def __repr__(self) -> str:
5365
return f"{self.__class__.__name__} discover of python_spec={spec!r}"
5466

5567

56-
def get_interpreter(key, try_first_with, app_data=None, env=None):
68+
def get_interpreter(
69+
key, try_first_with: Iterable[str], app_data: AppData | None = None, env: Mapping[str, str] | None = None
70+
) -> PythonInfo | None:
5771
spec = PythonSpec.from_string_spec(key)
5872
logging.info("find interpreter for spec %r", spec)
5973
proposed_paths = set()
@@ -70,7 +84,12 @@ def get_interpreter(key, try_first_with, app_data=None, env=None):
7084
return None
7185

7286

73-
def propose_interpreters(spec, try_first_with, app_data, env=None): # noqa: C901, PLR0912
87+
def propose_interpreters( # noqa: C901, PLR0912
88+
spec: PythonSpec,
89+
try_first_with: Iterable[str],
90+
app_data: AppData | None = None,
91+
env: Mapping[str, str] | None = None,
92+
) -> Generator[tuple[PythonInfo, bool], None, None]:
7493
# 0. try with first
7594
env = os.environ if env is None else env
7695
for py_exe in try_first_with:
@@ -104,34 +123,35 @@ def propose_interpreters(spec, try_first_with, app_data, env=None): # noqa: C90
104123
for interpreter in propose_interpreters(spec, app_data, env):
105124
yield interpreter, True
106125
# finally just find on path, the path order matters (as the candidates are less easy to control by end user)
107-
paths = get_paths(env)
108126
tested_exes = set()
109-
for pos, path in enumerate(paths):
110-
path_str = str(path)
111-
logging.debug(LazyPathDump(pos, path_str, env))
112-
for candidate, match in possible_specs(spec):
113-
found = check_path(candidate, path_str)
114-
if found is not None:
115-
exe = os.path.abspath(found)
116-
if exe not in tested_exes:
117-
tested_exes.add(exe)
118-
interpreter = PathPythonInfo.from_exe(exe, app_data, raise_on_error=False, env=env)
119-
if interpreter is not None:
120-
yield interpreter, match
121-
122-
123-
def get_paths(env):
127+
find_candidates = path_exe_finder(spec)
128+
for pos, path in enumerate(get_paths(env)):
129+
logging.debug(LazyPathDump(pos, path, env))
130+
for exe, impl_must_match in find_candidates(path):
131+
if exe in tested_exes:
132+
continue
133+
tested_exes.add(exe)
134+
interpreter = PathPythonInfo.from_exe(str(exe), app_data, raise_on_error=False, env=env)
135+
if interpreter is not None:
136+
yield interpreter, impl_must_match
137+
138+
139+
def get_paths(env: Mapping[str, str]) -> Generator[Path, None, None]:
124140
path = env.get("PATH", None)
125141
if path is None:
126142
try:
127143
path = os.confstr("CS_PATH")
128144
except (AttributeError, ValueError):
129145
path = os.defpath
130-
return [] if not path else [p for p in path.split(os.pathsep) if os.path.exists(p)]
146+
if not path:
147+
return None
148+
for p in map(Path, path.split(os.pathsep)):
149+
if p.exists():
150+
yield p
131151

132152

133153
class LazyPathDump:
134-
def __init__(self, pos, path, env) -> None:
154+
def __init__(self, pos: int, path: Path, env: Mapping[str, str]) -> None:
135155
self.pos = pos
136156
self.path = path
137157
self.env = env
@@ -140,35 +160,35 @@ def __repr__(self) -> str:
140160
content = f"discover PATH[{self.pos}]={self.path}"
141161
if self.env.get("_VIRTUALENV_DEBUG"): # this is the over the board debug
142162
content += " with =>"
143-
for file_name in os.listdir(self.path):
163+
for file_path in self.path.iterdir():
144164
try:
145-
file_path = os.path.join(self.path, file_name)
146-
if os.path.isdir(file_path) or not os.access(file_path, os.X_OK):
165+
if file_path.is_dir() or not (file_path.stat().st_mode & os.X_OK):
147166
continue
148167
except OSError:
149168
pass
150169
content += " "
151-
content += file_name
170+
content += file_path.name
152171
return content
153172

154173

155-
def check_path(candidate, path):
156-
_, ext = os.path.splitext(candidate)
157-
if sys.platform == "win32" and ext != ".exe":
158-
candidate += ".exe"
159-
if os.path.isfile(candidate):
160-
return candidate
161-
candidate = os.path.join(path, candidate)
162-
if os.path.isfile(candidate):
163-
return candidate
164-
return None
165-
166-
167-
def possible_specs(spec):
168-
# 4. then maybe it's something exact on PATH - if it was direct lookup implementation no longer counts
169-
yield spec.str_spec, False
170-
# 5. or from the spec we can deduce a name on path that matches
171-
yield from spec.generate_names()
174+
def path_exe_finder(spec: PythonSpec) -> Callable[[Path], Generator[tuple[Path, bool], None, None]]:
175+
"""Given a spec, return a function that can be called on a path to find all matching files in it."""
176+
pat = spec.generate_re(windows=sys.platform == "win32")
177+
direct = spec.str_spec
178+
if sys.platform == "win32":
179+
direct = f"{direct}.exe"
180+
181+
def path_exes(path: Path) -> Generator[tuple[Path, bool], None, None]:
182+
# 4. then maybe it's something exact on PATH - if it was direct lookup implementation no longer counts
183+
yield (path / direct), False
184+
# 5. or from the spec we can deduce if a name on path matches
185+
for exe in path.iterdir():
186+
match = pat.fullmatch(exe.name)
187+
if match:
188+
# the implementation must match when we find “python[ver]”
189+
yield exe.absolute(), match["impl"] == "python"
190+
191+
return path_exes
172192

173193

174194
class PathPythonInfo(PythonInfo):

src/virtualenv/discovery/py_info.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ def current(cls, app_data=None):
344344
return cls._current
345345

346346
@classmethod
347-
def current_system(cls, app_data=None):
347+
def current_system(cls, app_data=None) -> PythonInfo:
348348
"""
349349
This locates the current host interpreter information. This might be different than what we run into in case
350350
the host python has been upgraded from underneath us.

src/virtualenv/discovery/py_spec.py

+23-26
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,25 @@
22

33
from __future__ import annotations
44

5-
import contextlib
65
import os
76
import re
8-
from collections import OrderedDict
9-
10-
from virtualenv.info import fs_is_case_sensitive
117

128
PATTERN = re.compile(r"^(?P<impl>[a-zA-Z]+)?(?P<version>[0-9.]+)?(?:-(?P<arch>32|64))?$")
139

1410

1511
class PythonSpec:
1612
"""Contains specification about a Python Interpreter."""
1713

18-
def __init__(self, str_spec, implementation, major, minor, micro, architecture, path) -> None: # noqa: PLR0913
14+
def __init__( # noqa: PLR0913
15+
self,
16+
str_spec: str,
17+
implementation: str | None,
18+
major: int | None,
19+
minor: int | None,
20+
micro: int | None,
21+
architecture: int | None,
22+
path: str | None,
23+
) -> None:
1924
self.str_spec = str_spec
2025
self.implementation = implementation
2126
self.major = major
@@ -25,7 +30,7 @@ def __init__(self, str_spec, implementation, major, minor, micro, architecture,
2530
self.path = path
2631

2732
@classmethod
28-
def from_string_spec(cls, string_spec): # noqa: C901, PLR0912
33+
def from_string_spec(cls, string_spec: str): # noqa: C901, PLR0912
2934
impl, major, minor, micro, arch, path = None, None, None, None, None, None
3035
if os.path.isabs(string_spec): # noqa: PLR1702
3136
path = string_spec
@@ -67,26 +72,18 @@ def _int_or_none(val):
6772

6873
return cls(string_spec, impl, major, minor, micro, arch, path)
6974

70-
def generate_names(self):
71-
impls = OrderedDict()
72-
if self.implementation:
73-
# first consider implementation as it is
74-
impls[self.implementation] = False
75-
if fs_is_case_sensitive():
76-
# for case sensitive file systems consider lower and upper case versions too
77-
# trivia: MacBooks and all pre 2018 Windows-es were case insensitive by default
78-
impls[self.implementation.lower()] = False
79-
impls[self.implementation.upper()] = False
80-
impls["python"] = True # finally consider python as alias, implementation must match now
81-
version = self.major, self.minor, self.micro
82-
with contextlib.suppress(ValueError):
83-
version = version[: version.index(None)]
84-
85-
for impl, match in impls.items():
86-
for at in range(len(version), -1, -1):
87-
cur_ver = version[0:at]
88-
spec = f"{impl}{'.'.join(str(i) for i in cur_ver)}"
89-
yield spec, match
75+
def generate_re(self, *, windows: bool) -> re.Pattern:
76+
"""Generate a regular expression for matching against a filename."""
77+
version = r"{}(\.{}(\.{})?)?".format(
78+
*(r"\d+" if v is None else v for v in (self.major, self.minor, self.micro))
79+
)
80+
impl = "python" if self.implementation is None else f"python|{re.escape(self.implementation)}"
81+
suffix = r"\.exe" if windows else ""
82+
# Try matching `direct` first, so the `direct` group is filled when possible.
83+
return re.compile(
84+
rf"(?P<impl>{impl})(?P<v>{version}){suffix}$",
85+
flags=re.IGNORECASE,
86+
)
9087

9188
@property
9289
def is_abs(self):

tests/unit/discovery/test_discovery.py

+12-3
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,25 @@
1616

1717
@pytest.mark.skipif(not fs_supports_symlink(), reason="symlink not supported")
1818
@pytest.mark.parametrize("case", ["mixed", "lower", "upper"])
19-
def test_discovery_via_path(monkeypatch, case, tmp_path, caplog, session_app_data):
19+
@pytest.mark.parametrize("specificity", ["more", "less"])
20+
def test_discovery_via_path(monkeypatch, case, specificity, tmp_path, caplog, session_app_data): # noqa: PLR0913
2021
caplog.set_level(logging.DEBUG)
2122
current = PythonInfo.current_system(session_app_data)
22-
core = f"somethingVeryCryptic{'.'.join(str(i) for i in current.version_info[0:3])}"
2323
name = "somethingVeryCryptic"
2424
if case == "lower":
2525
name = name.lower()
2626
elif case == "upper":
2727
name = name.upper()
28-
exe_name = f"{name}{current.version_info.major}{'.exe' if sys.platform == 'win32' else ''}"
28+
if specificity == "more":
29+
# e.g. spec: python3, exe: /bin/python3.12
30+
core_ver = current.version_info.major
31+
exe_ver = ".".join(str(i) for i in current.version_info[0:2])
32+
elif specificity == "less":
33+
# e.g. spec: python3.12.1, exe: /bin/python3
34+
core_ver = ".".join(str(i) for i in current.version_info[0:3])
35+
exe_ver = current.version_info.major
36+
core = f"somethingVeryCryptic{core_ver}"
37+
exe_name = f"{name}{exe_ver}{'.exe' if sys.platform == 'win32' else ''}"
2938
target = tmp_path / current.install_path("scripts")
3039
target.mkdir(parents=True)
3140
executable = target / exe_name

tox.ini

+2-2
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@ set_env =
3333
_COVERAGE_SRC = {envsitepackagesdir}/virtualenv
3434
commands =
3535
coverage erase
36-
coverage run -m pytest {posargs:--junitxml {toxworkdir}/junit.{envname}.xml tests --int}
36+
coverage run -m pytest {posargs:--junitxml "{toxworkdir}/junit.{envname}.xml" tests --int}
3737
coverage combine
3838
coverage report --skip-covered --show-missing
39-
coverage xml -o {toxworkdir}/coverage.{envname}.xml
39+
coverage xml -o "{toxworkdir}/coverage.{envname}.xml"
4040
coverage html -d {envtmpdir}/htmlcov --show-contexts --title virtualenv-{envname}-coverage
4141
uv_seed = true
4242

0 commit comments

Comments
 (0)