From aef2acb900fafe6e7e85155a31ef9a067deea617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 18:38:02 -0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(path):=20verify=20known=20im?= =?UTF-8?q?plementations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lookup accepted a PATH candidate named for PyPy even when it reported CPython. Check the reported implementation for known names while keeping unknown prefixes available as literal executable names. Fixes #201 --- docs/changelog/201.bugfix.rst | 2 ++ src/python_discovery/_discovery.py | 11 +++++----- tests/test_discovery.py | 33 +++++++++++++++++++++++++++++- 3 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 docs/changelog/201.bugfix.rst 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 5f82233..791523b 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: @@ -478,18 +478,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 0b69e59..0f69a76 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -13,7 +13,7 @@ import pytest -from python_discovery import DiskCache, PythonInfo, get_interpreter +from python_discovery import DiskCache, PythonInfo, get_interpreter, iter_interpreters from python_discovery._discovery import IS_WIN, LazyPathDump, get_paths if TYPE_CHECKING: @@ -85,6 +85,37 @@ 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: + version = f"{sys.version_info.major}.{sys.version_info.minor}" + (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: + version = f"{sys.version_info.major}.{sys.version_info.minor}" + 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)