Skip to content

Commit cbbf465

Browse files
authored
Fix PATH-based Python discovery on Windows (#2712)
1 parent 9eac8a6 commit cbbf465

File tree

6 files changed

+61
-15
lines changed

6 files changed

+61
-15
lines changed

.github/workflows/check.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,9 @@ jobs:
2828
- "3.10"
2929
- "3.9"
3030
- "3.8"
31-
- "3.7"
3231
- pypy-3.10
3332
- pypy-3.9
3433
- pypy-3.8
35-
- pypy-3.7
3634
os:
3735
- ubuntu-latest
3836
- macos-latest
@@ -42,6 +40,12 @@ jobs:
4240
- { os: macos-latest, py: "[email protected]" }
4341
- { os: macos-latest, py: "[email protected]" }
4442
- { os: macos-latest, py: "[email protected]" }
43+
- { os: ubuntu-latest, py: "3.7" }
44+
- { os: windows-latest, py: "3.7" }
45+
- { os: macos-12, py: "3.7" }
46+
- { os: ubuntu-latest, py: "pypy-3.7" }
47+
- { os: windows-latest, py: "pypy-3.7" }
48+
- { os: macos-12, py: "pypy-3.7" }
4549
steps:
4650
- uses: taiki-e/install-action@cargo-binstall
4751
- name: Install OS dependencies

docs/changelog/2712.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
fix PATH-based Python discovery on Windows - by :user:`ofek`.

src/virtualenv/discovery/builtin.py

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pathlib import Path
77
from typing import TYPE_CHECKING, Callable
88

9-
from virtualenv.info import IS_WIN
9+
from virtualenv.info import IS_WIN, fs_path_id
1010

1111
from .discover import Discover
1212
from .py_info import PythonInfo
@@ -84,22 +84,28 @@ def get_interpreter(
8484
return None
8585

8686

87-
def propose_interpreters( # noqa: C901, PLR0912
87+
def propose_interpreters( # noqa: C901, PLR0912, PLR0915
8888
spec: PythonSpec,
8989
try_first_with: Iterable[str],
9090
app_data: AppData | None = None,
9191
env: Mapping[str, str] | None = None,
9292
) -> Generator[tuple[PythonInfo, bool], None, None]:
9393
# 0. try with first
9494
env = os.environ if env is None else env
95+
tested_exes: set[str] = set()
9596
for py_exe in try_first_with:
9697
path = os.path.abspath(py_exe)
9798
try:
9899
os.lstat(path) # Windows Store Python does not work with os.path.exists, but does for os.lstat
99100
except OSError:
100101
pass
101102
else:
102-
yield PythonInfo.from_exe(os.path.abspath(path), app_data, env=env), True
103+
exe_raw = os.path.abspath(path)
104+
exe_id = fs_path_id(exe_raw)
105+
if exe_id in tested_exes:
106+
continue
107+
tested_exes.add(exe_id)
108+
yield PythonInfo.from_exe(exe_raw, app_data, env=env), True
103109

104110
# 1. if it's a path and exists
105111
if spec.path is not None:
@@ -109,29 +115,44 @@ def propose_interpreters( # noqa: C901, PLR0912
109115
if spec.is_abs:
110116
raise
111117
else:
112-
yield PythonInfo.from_exe(os.path.abspath(spec.path), app_data, env=env), True
118+
exe_raw = os.path.abspath(spec.path)
119+
exe_id = fs_path_id(exe_raw)
120+
if exe_id not in tested_exes:
121+
tested_exes.add(exe_id)
122+
yield PythonInfo.from_exe(exe_raw, app_data, env=env), True
113123
if spec.is_abs:
114124
return
115125
else:
116126
# 2. otherwise try with the current
117-
yield PythonInfo.current_system(app_data), True
127+
current_python = PythonInfo.current_system(app_data)
128+
exe_raw = str(current_python.executable)
129+
exe_id = fs_path_id(exe_raw)
130+
if exe_id not in tested_exes:
131+
tested_exes.add(exe_id)
132+
yield current_python, True
118133

119134
# 3. otherwise fallback to platform default logic
120135
if IS_WIN:
121136
from .windows import propose_interpreters # noqa: PLC0415
122137

123138
for interpreter in propose_interpreters(spec, app_data, env):
139+
exe_raw = str(interpreter.executable)
140+
exe_id = fs_path_id(exe_raw)
141+
if exe_id in tested_exes:
142+
continue
143+
tested_exes.add(exe_id)
124144
yield interpreter, True
125145
# finally just find on path, the path order matters (as the candidates are less easy to control by end user)
126-
tested_exes = set()
127146
find_candidates = path_exe_finder(spec)
128147
for pos, path in enumerate(get_paths(env)):
129148
logging.debug(LazyPathDump(pos, path, env))
130149
for exe, impl_must_match in find_candidates(path):
131-
if exe in tested_exes:
150+
exe_raw = str(exe)
151+
exe_id = fs_path_id(exe_raw)
152+
if exe_id in tested_exes:
132153
continue
133-
tested_exes.add(exe)
134-
interpreter = PathPythonInfo.from_exe(str(exe), app_data, raise_on_error=False, env=env)
154+
tested_exes.add(exe_id)
155+
interpreter = PathPythonInfo.from_exe(exe_raw, app_data, raise_on_error=False, env=env)
135156
if interpreter is not None:
136157
yield interpreter, impl_must_match
137158

@@ -180,7 +201,10 @@ def path_exe_finder(spec: PythonSpec) -> Callable[[Path], Generator[tuple[Path,
180201

181202
def path_exes(path: Path) -> Generator[tuple[Path, bool], None, None]:
182203
# 4. then maybe it's something exact on PATH - if it was direct lookup implementation no longer counts
183-
yield (path / direct), False
204+
direct_path = path / direct
205+
if direct_path.exists():
206+
yield direct_path, False
207+
184208
# 5. or from the spec we can deduce if a name on path matches
185209
for exe in path.iterdir():
186210
match = pat.fullmatch(exe.name)

src/virtualenv/discovery/py_spec.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,17 @@ def generate_re(self, *, windows: bool) -> re.Pattern:
7979
)
8080
impl = "python" if self.implementation is None else f"python|{re.escape(self.implementation)}"
8181
suffix = r"\.exe" if windows else ""
82+
version_conditional = (
83+
"?"
84+
# Windows Python executables are almost always unversioned
85+
if windows
86+
# Spec is an empty string
87+
or self.major is None
88+
else ""
89+
)
8290
# Try matching `direct` first, so the `direct` group is filled when possible.
8391
return re.compile(
84-
rf"(?P<impl>{impl})(?P<v>{version}){suffix}$",
92+
rf"(?P<impl>{impl})(?P<v>{version}){version_conditional}{suffix}$",
8593
flags=re.IGNORECASE,
8694
)
8795

src/virtualenv/info.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ def fs_supports_symlink():
4848
return _CAN_SYMLINK
4949

5050

51+
def fs_path_id(path: str) -> str:
52+
return path.casefold() if fs_is_case_sensitive() else path
53+
54+
5155
__all__ = (
5256
"IS_CPYTHON",
5357
"IS_MAC_ARM64",
@@ -56,5 +60,6 @@ def fs_supports_symlink():
5660
"IS_ZIPAPP",
5761
"ROOT",
5862
"fs_is_case_sensitive",
63+
"fs_path_id",
5964
"fs_supports_symlink",
6065
)

tests/unit/discovery/test_discovery.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
@pytest.mark.skipif(not fs_supports_symlink(), reason="symlink not supported")
1818
@pytest.mark.parametrize("case", ["mixed", "lower", "upper"])
19-
@pytest.mark.parametrize("specificity", ["more", "less"])
19+
@pytest.mark.parametrize("specificity", ["more", "less", "none"])
2020
def test_discovery_via_path(monkeypatch, case, specificity, tmp_path, caplog, session_app_data): # noqa: PLR0913
2121
caplog.set_level(logging.DEBUG)
2222
current = PythonInfo.current_system(session_app_data)
@@ -33,7 +33,11 @@ def test_discovery_via_path(monkeypatch, case, specificity, tmp_path, caplog, se
3333
# e.g. spec: python3.12.1, exe: /bin/python3
3434
core_ver = ".".join(str(i) for i in current.version_info[0:3])
3535
exe_ver = current.version_info.major
36-
core = f"somethingVeryCryptic{core_ver}"
36+
elif specificity == "none":
37+
# e.g. spec: python3.12.1, exe: /bin/python
38+
core_ver = ".".join(str(i) for i in current.version_info[0:3])
39+
exe_ver = ""
40+
core = "" if specificity == "none" else f"{name}{core_ver}"
3741
exe_name = f"{name}{exe_ver}{'.exe' if sys.platform == 'win32' else ''}"
3842
target = tmp_path / current.install_path("scripts")
3943
target.mkdir(parents=True)

0 commit comments

Comments
 (0)