Skip to content

Commit

Permalink
Fix PATH-based Python discovery on Windows
Browse files Browse the repository at this point in the history
  • Loading branch information
ofek committed Apr 27, 2024
1 parent 9eac8a6 commit ba38caa
Show file tree
Hide file tree
Showing 4 changed files with 41 additions and 9 deletions.
1 change: 1 addition & 0 deletions docs/changelog/2712.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
fix PATH-based Python discovery on Windows - by :user:`ofek`.
34 changes: 26 additions & 8 deletions src/virtualenv/discovery/builtin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pathlib import Path
from typing import TYPE_CHECKING, Callable

from virtualenv.info import IS_WIN
from virtualenv.info import IS_WIN, fs_path_id

from .discover import Discover
from .py_info import PythonInfo
Expand Down Expand Up @@ -92,14 +92,20 @@ def propose_interpreters( # noqa: C901, PLR0912
) -> Generator[tuple[PythonInfo, bool], None, None]:
# 0. try with first
env = os.environ if env is None else env
tested_exes: set[str] = set()
for py_exe in try_first_with:
path = os.path.abspath(py_exe)
try:
os.lstat(path) # Windows Store Python does not work with os.path.exists, but does for os.lstat
except OSError:
pass
else:
yield PythonInfo.from_exe(os.path.abspath(path), app_data, env=env), True
exe_raw = os.path.abspath(path)
exe_id = fs_path_id(exe_raw)
if exe_id in tested_exes:
continue
tested_exes.add(exe_id)
yield PythonInfo.from_exe(exe_raw, app_data, env=env), True

# 1. if it's a path and exists
if spec.path is not None:
Expand All @@ -109,7 +115,11 @@ def propose_interpreters( # noqa: C901, PLR0912
if spec.is_abs:
raise
else:
yield PythonInfo.from_exe(os.path.abspath(spec.path), app_data, env=env), True
exe_raw = os.path.abspath(spec.path)
exe_id = fs_path_id(exe_raw)
if exe_id not in tested_exes:
tested_exes.add(exe_id)
yield PythonInfo.from_exe(exe_raw, app_data, env=env), True
if spec.is_abs:
return
else:
Expand All @@ -121,17 +131,23 @@ def propose_interpreters( # noqa: C901, PLR0912
from .windows import propose_interpreters # noqa: PLC0415

for interpreter in propose_interpreters(spec, app_data, env):
exe_raw = str(interpreter.executable)
exe_id = fs_path_id(exe_raw)
if exe_id in tested_exes:
continue
tested_exes.add(exe_id)
yield interpreter, True
# finally just find on path, the path order matters (as the candidates are less easy to control by end user)
tested_exes = set()
find_candidates = path_exe_finder(spec)
for pos, path in enumerate(get_paths(env)):
logging.debug(LazyPathDump(pos, path, env))
for exe, impl_must_match in find_candidates(path):
if exe in tested_exes:
exe_raw = str(exe)
exe_id = fs_path_id(exe_raw)
if exe_id in tested_exes:
continue
tested_exes.add(exe)
interpreter = PathPythonInfo.from_exe(str(exe), app_data, raise_on_error=False, env=env)
tested_exes.add(exe_id)
interpreter = PathPythonInfo.from_exe(exe_raw, app_data, raise_on_error=False, env=env)
if interpreter is not None:
yield interpreter, impl_must_match

Expand Down Expand Up @@ -180,7 +196,9 @@ def path_exe_finder(spec: PythonSpec) -> Callable[[Path], Generator[tuple[Path,

def path_exes(path: Path) -> Generator[tuple[Path, bool], None, None]:
# 4. then maybe it's something exact on PATH - if it was direct lookup implementation no longer counts
yield (path / direct), False
if (direct_path := (path / direct)).exists():
yield direct_path, False

# 5. or from the spec we can deduce if a name on path matches
for exe in path.iterdir():
match = pat.fullmatch(exe.name)
Expand Down
10 changes: 9 additions & 1 deletion src/virtualenv/discovery/py_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,17 @@ def generate_re(self, *, windows: bool) -> re.Pattern:
)
impl = "python" if self.implementation is None else f"python|{re.escape(self.implementation)}"
suffix = r"\.exe" if windows else ""
version_conditional = (
"?"
# Windows Python executables are almost always unversioned
if windows
# Spec is an empty string, in which case the version part of the pattern will be: None
or self.major is None
else ""
)
# Try matching `direct` first, so the `direct` group is filled when possible.
return re.compile(
rf"(?P<impl>{impl})(?P<v>{version}){suffix}$",
rf"(?P<impl>{impl})(?P<v>{version}){version_conditional}{suffix}$",
flags=re.IGNORECASE,
)

Expand Down
5 changes: 5 additions & 0 deletions src/virtualenv/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ def fs_supports_symlink():
return _CAN_SYMLINK


def fs_path_id(path: str) -> str:
return path.casefold() if fs_is_case_sensitive() else path


__all__ = (
"IS_CPYTHON",
"IS_MAC_ARM64",
Expand All @@ -56,5 +60,6 @@ def fs_supports_symlink():
"IS_ZIPAPP",
"ROOT",
"fs_is_case_sensitive",
"fs_path_id",
"fs_supports_symlink",
)

0 comments on commit ba38caa

Please sign in to comment.