diff --git a/docs/changelog/201.bugfix.rst b/docs/changelog/201.bugfix.rst new file mode 100644 index 0000000..ea92fe3 --- /dev/null +++ b/docs/changelog/201.bugfix.rst @@ -0,0 +1,2 @@ +Check the interpreter's reported implementation when a spec names a known implementation, regardless of its filename on +``PATH``. diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index a85c51b..98530f5 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -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: @@ -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 diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 964e58b..49bab12 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -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: @@ -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)