From 7903ee80cb42c4bd3ee98fc781b0ce683e9ff77f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 16:58:15 -0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20fix(discovery):=20accept=20l?= =?UTF-8?q?iteral=20symlink=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolving a Python symlink can replace its recorded original executable with the system binary. Match the executable found on PATH so a literal lookup keeps the symlink name. --- docs/changelog/188.bugfix.rst | 1 + src/python_discovery/_py_info.py | 2 +- tests/py_info/test_py_info.py | 4 ++-- tests/test_discovery.py | 11 +++++++++++ tests/test_py_info_extra.py | 2 +- tests/windows/test_windows.py | 17 ++++++++++++++++- 6 files changed, 32 insertions(+), 5 deletions(-) create mode 100644 docs/changelog/188.bugfix.rst diff --git a/docs/changelog/188.bugfix.rst b/docs/changelog/188.bugfix.rst new file mode 100644 index 0000000..66de408 --- /dev/null +++ b/docs/changelog/188.bugfix.rst @@ -0,0 +1 @@ +Match literal interpreter names against the executable found on ``PATH``, including symlink names. diff --git a/src/python_discovery/_py_info.py b/src/python_discovery/_py_info.py index 09733de..fe8189f 100644 --- a/src/python_discovery/_py_info.py +++ b/src/python_discovery/_py_info.py @@ -283,7 +283,7 @@ def _satisfies_path(self, spec: PythonSpec) -> bool: return True if spec.is_abs: return True - basename = os.path.basename(self.original_executable) + basename = os.path.basename(self.executable) spec_path = spec.path if sys.platform == "win32": basename, suffix = os.path.splitext(basename) diff --git a/tests/py_info/test_py_info.py b/tests/py_info/test_py_info.py index b078617..9b0b802 100644 --- a/tests/py_info/test_py_info.py +++ b/tests/py_info/test_py_info.py @@ -304,9 +304,9 @@ def test_py_info_ignores_distutils_config(monkeypatch: pytest.MonkeyPatch, tmp_p def test_discover_exe_on_path_non_spec_name_match(mocker: MockerFixture) -> None: suffixed_name = f"python{CURRENT.version_info.major}.{CURRENT.version_info.minor}m" if sys.platform == "win32": # pragma: win32 cover - suffixed_name += Path(CURRENT.original_executable).suffix + suffixed_name += Path(CURRENT.executable).suffix spec = PythonSpec.from_string_spec(suffixed_name) - mocker.patch.object(CURRENT, "original_executable", str(Path(CURRENT.executable).parent / suffixed_name)) + mocker.patch.object(CURRENT, "executable", str(Path(CURRENT.executable).parent / suffixed_name)) assert CURRENT.satisfies(spec, impl_must_match=True) is True diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 2440a03..7d48ac3 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -74,6 +74,17 @@ def test_discovery_via_path_not_found(tmp_path: Path, monkeypatch: pytest.Monkey assert interpreter is None +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink names") +def test_discovery_via_path_literal_symlink(tmp_path: Path) -> None: + link = tmp_path / "python_custom" + link.symlink_to(sys.executable) + + interpreter = get_interpreter("python_custom", [], env={"PATH": str(tmp_path)}) + + assert interpreter is not None + assert interpreter.executable == str(link) + + 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) diff --git a/tests/test_py_info_extra.py b/tests/test_py_info_extra.py index 016636a..6e357b4 100644 --- a/tests/test_py_info_extra.py +++ b/tests/test_py_info_extra.py @@ -277,7 +277,7 @@ def test_satisfies_path_not_abs_basename_no_match() -> None: def test_satisfies_path_win32(mocker: MockerFixture) -> None: info = copy.deepcopy(CURRENT) mocker.patch.object(sys, "platform", "win32") - info.original_executable = "/some/path/python.exe" + info.executable = "/some/path/python.exe" spec = PythonSpec.from_string_spec("python") spec.path = "python" assert info.satisfies(spec, impl_must_match=False) is True diff --git a/tests/windows/test_windows.py b/tests/windows/test_windows.py index a61aedd..027ce39 100644 --- a/tests/windows/test_windows.py +++ b/tests/windows/test_windows.py @@ -1,10 +1,25 @@ from __future__ import annotations import sys +from typing import TYPE_CHECKING import pytest -from python_discovery import PythonSpec +from python_discovery import PythonSpec, get_interpreter + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows executable alias") +def test_discovery_via_path_literal_symlink(tmp_path: Path) -> None: + link = tmp_path / "python_custom.exe" + link.symlink_to(sys.executable) + + interpreter = get_interpreter("python_custom", [], env={"PATH": str(tmp_path)}) + + assert interpreter is not None + assert interpreter.executable == str(link) @pytest.mark.skipif(sys.platform != "win32", reason="propose_interpreters calls from_exe with Windows paths") From 337f7a64a1cfa394a8973d0bd7e54a45e4ca5cb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 17:02:16 -0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A7=AA=20test(discovery):=20keep=20Py?= =?UTF-8?q?thon=20DLLs=20on=20PATH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows needs the standalone Python directory on PATH when starting a temporary executable symlink. Keep that directory after the symlink's parent so the test reaches name matching. --- tests/windows/test_windows.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/windows/test_windows.py b/tests/windows/test_windows.py index 027ce39..65c0789 100644 --- a/tests/windows/test_windows.py +++ b/tests/windows/test_windows.py @@ -1,22 +1,19 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING +from pathlib import Path import pytest from python_discovery import PythonSpec, get_interpreter -if TYPE_CHECKING: - from pathlib import Path - @pytest.mark.skipif(sys.platform != "win32", reason="Windows executable alias") def test_discovery_via_path_literal_symlink(tmp_path: Path) -> None: link = tmp_path / "python_custom.exe" - link.symlink_to(sys.executable) + link.symlink_to(Path(sys.base_prefix) / "python.exe") - interpreter = get_interpreter("python_custom", [], env={"PATH": str(tmp_path)}) + interpreter = get_interpreter("python_custom", [], env={"PATH": f"{tmp_path};{sys.base_prefix}"}) assert interpreter is not None assert interpreter.executable == str(link)