From 1081924895ea5c2cf100f48555c7ce1f1005dc59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 16:58:49 -0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20fix(discovery):=20retain=20p?= =?UTF-8?q?referred=20venv=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/191.bugfix.rst | 1 + src/python_discovery/_discovery.py | 2 +- tests/test_discovery.py | 42 +++++++++++++++++++++++++++--- 3 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 docs/changelog/191.bugfix.rst diff --git a/docs/changelog/191.bugfix.rst b/docs/changelog/191.bugfix.rst new file mode 100644 index 0000000..dbc9146 --- /dev/null +++ b/docs/changelog/191.bugfix.rst @@ -0,0 +1 @@ +Preserve a virtual environment selected through ``try_first_with`` instead of returning its base interpreter. diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index 9109f60..851ec4c 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -212,7 +212,7 @@ def _propose_explicit( tested_exes: set[str], ) -> Generator[tuple[PythonInfo | None, bool], None, None]: for py_exe in try_first_with: - if exe_raw := _check_exe(str(Path(py_exe).resolve()), tested_exes): + if Path(py_exe).exists() and (exe_raw := _check_exe(py_exe, tested_exes)): yield PythonInfo.from_exe(exe_raw, cache, env=env), True if spec.path is not None: diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 2440a03..65e8d18 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -4,9 +4,9 @@ import logging import os import stat -import subprocess import sys from pathlib import Path +from subprocess import check_call, check_output from typing import TYPE_CHECKING, Final from unittest.mock import patch from uuid import uuid4 @@ -206,8 +206,8 @@ def test_discovery_absolute_path_with_try_first( good_env = tmp_path / "good" bad_env = tmp_path / "bad" - subprocess.check_call([sys.executable, "-m", "venv", str(good_env)]) - subprocess.check_call([sys.executable, "-m", "venv", str(bad_env)]) + check_call([sys.executable, "-m", "venv", str(good_env)]) + check_call([sys.executable, "-m", "venv", str(bad_env)]) scripts_dir = "Scripts" if IS_WIN else "bin" exe_name = "python.exe" if IS_WIN else "python" @@ -229,13 +229,47 @@ def test_discovery_venvs_on_one_base_keep_their_executable(tmp_path: Path, sessi exe_name = "python.exe" if IS_WIN else "python" exes = [tmp_path / name / scripts_dir / exe_name for name in ("a", "b")] for exe in exes: - subprocess.check_call([sys.executable, "-m", "venv", "--without-pip", str(exe.parents[1])]) + check_call([sys.executable, "-m", "venv", "--without-pip", str(exe.parents[1])]) found = [get_interpreter(str(exe), cache=session_cache) for exe in exes] assert [Path(info.executable) for info in found if info is not None] == exes +def test_discovery_try_first_with_keeps_venv(tmp_path: Path, session_cache: DiskCache) -> None: + venv = tmp_path / "preferred" + check_call([sys.executable, "-m", "venv", "--without-pip", str(venv)]) + exe = venv / ("Scripts/python.exe" if IS_WIN else "bin/python") + + info = get_interpreter( + f"{sys.version_info.major}.{sys.version_info.minor}", + try_first_with=[str(exe)], + cache=session_cache, + env={**os.environ, "PATH": "", "UV_PYTHON_INSTALL_DIR": str(tmp_path / "missing")}, + ) + + assert info is not None + assert ( + info.executable, + check_output([info.executable, "-c", "import sys; print(sys.prefix)"], text=True).strip(), + ) == (str(exe), str(venv)) + + +def test_discovery_try_first_with_skips_broken_symlink(tmp_path: Path, session_cache: DiskCache) -> None: + broken = tmp_path / "missing-python" + broken.symlink_to(tmp_path / "missing") + + info = get_interpreter( + f"{sys.version_info.major}.{sys.version_info.minor}", + try_first_with=[str(broken)], + cache=session_cache, + env={**os.environ, "PATH": "", "UV_PYTHON_INSTALL_DIR": str(tmp_path / "missing")}, + ) + + assert info is not None + assert info.executable == PythonInfo.current_system(session_cache).executable + + def test_discovery_via_path_with_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: a_file = tmp_path / "a_file" a_file.touch() From 6a0ca5d94469282ca1934df94d763343f7f5a383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 17:04:07 -0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A7=AA=20test(discovery):=20accept=20?= =?UTF-8?q?free-threaded=20Python?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_discovery.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 65e8d18..fb0b8eb 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -242,7 +242,7 @@ def test_discovery_try_first_with_keeps_venv(tmp_path: Path, session_cache: Disk exe = venv / ("Scripts/python.exe" if IS_WIN else "bin/python") info = get_interpreter( - f"{sys.version_info.major}.{sys.version_info.minor}", + ">=3", try_first_with=[str(exe)], cache=session_cache, env={**os.environ, "PATH": "", "UV_PYTHON_INSTALL_DIR": str(tmp_path / "missing")}, @@ -260,7 +260,7 @@ def test_discovery_try_first_with_skips_broken_symlink(tmp_path: Path, session_c broken.symlink_to(tmp_path / "missing") info = get_interpreter( - f"{sys.version_info.major}.{sys.version_info.minor}", + ">=3", try_first_with=[str(broken)], cache=session_cache, env={**os.environ, "PATH": "", "UV_PYTHON_INSTALL_DIR": str(tmp_path / "missing")},