Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix PATH-based Python discovery on Windows #2712

Merged
merged 5 commits into from
Apr 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,9 @@ jobs:
- "3.10"
- "3.9"
- "3.8"
gaborbernat marked this conversation as resolved.
Show resolved Hide resolved
- "3.7"
- pypy-3.10
- pypy-3.9
- pypy-3.8
- pypy-3.7
os:
- ubuntu-latest
- macos-latest
Expand All @@ -42,6 +40,12 @@ jobs:
- { os: macos-latest, py: "brew@3.10" }
- { os: macos-latest, py: "brew@3.9" }
- { os: macos-latest, py: "brew@3.8" }
- { os: ubuntu-latest, py: "3.7" }
- { os: windows-latest, py: "3.7" }
- { os: macos-12, py: "3.7" }
- { os: ubuntu-latest, py: "pypy-3.7" }
- { os: windows-latest, py: "pypy-3.7" }
- { os: macos-12, py: "pypy-3.7" }
steps:
- uses: taiki-e/install-action@cargo-binstall
- name: Install OS dependencies
Expand Down
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`.
44 changes: 34 additions & 10 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 @@ -84,22 +84,28 @@ def get_interpreter(
return None


def propose_interpreters( # noqa: C901, PLR0912
def propose_interpreters( # noqa: C901, PLR0912, PLR0915
spec: PythonSpec,
try_first_with: Iterable[str],
app_data: AppData | None = None,
env: Mapping[str, str] | None = None,
) -> 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,29 +115,44 @@ 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:
# 2. otherwise try with the current
yield PythonInfo.current_system(app_data), True
current_python = PythonInfo.current_system(app_data)
exe_raw = str(current_python.executable)
exe_id = fs_path_id(exe_raw)
if exe_id not in tested_exes:
tested_exes.add(exe_id)
yield current_python, True

# 3. otherwise fallback to platform default logic
if IS_WIN:
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 +201,10 @@ 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
direct_path = path / direct
if direct_path.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
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
Copy link
Sponsor Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine but FYI in future if absolutely necessary the more proper and less performant way would be to stat the file and use the tuple: (stat.st_dev, stat.st_ino)



__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",
)
8 changes: 6 additions & 2 deletions tests/unit/discovery/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

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