Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/changelog/201.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Check the interpreter's reported implementation when a spec names a known implementation, regardless of its filename on
``PATH``.
11 changes: 6 additions & 5 deletions src/python_discovery/_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from ._compat import fs_path_id
from ._py_info import PythonInfo
from ._py_spec import PythonSpec
from ._py_spec import KNOWN_IMPLEMENTATIONS, PythonSpec
from ._specifier import DC_KW, SimpleVersion

if TYPE_CHECKING:
Expand Down Expand Up @@ -494,18 +494,19 @@ def path_exe_finder(
"""Given a spec, return a function that can be called on a path to find all matching files in it."""
pat = spec.generate_re(windows=sys.platform == "win32", all_implementations=all_implementations)
direct = spec.str_spec
implementation = spec.implementation.lower() if spec.implementation is not None else None
impl_must_match = implementation in KNOWN_IMPLEMENTATIONS and implementation != "python"
if sys.platform == "win32": # pragma: win32 cover
direct = f"{direct}.exe"

def path_exes(path: Path) -> Generator[tuple[Path, bool], None, None]:
direct_path = path / direct
if direct_path.exists():
yield direct_path, False
yield direct_path, impl_must_match

for exe in path.iterdir():
match = pat.fullmatch(exe.name)
if match:
yield exe.absolute(), match["impl"] == "python"
if pat.fullmatch(exe.name):
yield exe.absolute(), impl_must_match

return path_exes

Expand Down
35 changes: 34 additions & 1 deletion tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

import pytest

from python_discovery import DiskCache, PythonInfo, PythonSpec, SimpleSpecifierSet, get_interpreter
from python_discovery import DiskCache, PythonInfo, PythonSpec, SimpleSpecifierSet, get_interpreter, iter_interpreters
from python_discovery._discovery import IS_WIN, LazyPathDump, get_paths, propose_interpreters

if TYPE_CHECKING:
Expand Down Expand Up @@ -85,6 +85,39 @@ def test_discovery_via_path_literal_symlink(tmp_path: Path) -> None:
assert interpreter.executable == str(link)


@pytest.mark.skipif(sys.implementation.name != "cpython", reason="requires CPython")
@pytest.mark.parametrize("name", [pytest.param("pypy", id="pypy"), pytest.param("Python", id="mixed-case-python")])
def test_pypy_spec_rejects_cpython_alias(name: str, tmp_path: Path, session_cache: DiskCache) -> None:
threaded = "t" if PythonInfo.current_system(session_cache).free_threaded else ""
version = f"{sys.version_info.major}.{sys.version_info.minor}{threaded}"
(tmp_path / f"{name}{version}{'.exe' if IS_WIN else ''}").symlink_to(sys.executable)
env = {"PATH": str(tmp_path), "UV_PYTHON_INSTALL_DIR": str(tmp_path / "missing")}

selected = get_interpreter(f"pypy{version}", [], cache=session_cache, env=env)
assert selected is None or selected.implementation == "PyPy"
assert all(
info.implementation == "PyPy" for info in iter_interpreters(f"pypy{version}", cache=session_cache, env=env)
)


@pytest.mark.skipif(sys.implementation.name != "cpython", reason="requires CPython")
def test_generic_python_spec_accepts_mixed_case_alias(tmp_path: Path, session_cache: DiskCache) -> None:
threaded = "t" if PythonInfo.current_system(session_cache).free_threaded else ""
version = f"{sys.version_info.major}.{sys.version_info.minor}{threaded}"
alias = tmp_path / f"Python{version}{'.exe' if IS_WIN else ''}"
alias.symlink_to(sys.executable)

selected = get_interpreter(
f"Python{version}",
[],
cache=session_cache,
env={"PATH": str(tmp_path), "UV_PYTHON_INSTALL_DIR": str(tmp_path / "missing")},
)

assert selected is not None
assert (selected.executable, selected.implementation) == (str(alias), "CPython")


def test_discovery_via_path_in_nonbrowseable_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
bad_perm = tmp_path / "bad_perm"
bad_perm.mkdir(mode=0o000)
Expand Down