From 7644b70c470747f460bdd2a4925c28bdff6910a4 Mon Sep 17 00:00:00 2001 From: darrenhuai <60621295+darrenhuai@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:50:43 -0700 Subject: [PATCH 1/3] fix(discovery): match relative interpreter paths that contain ".." A relative spec that steps up a directory, such as ../venv/bin/python or ..\other\Scripts\python.exe, never matched. get_interpreter returned None and iter_interpreters yielded nothing, even though the file exists and was interrogated. virtualenv hands -p straight to get_interpreter, so virtualenv -p ../venv/bin/python could not find the interpreter either. _check_exe proposed the candidate as Path(path).absolute(), which drops "." parts but keeps "..", while PythonInfo._satisfies_path compares the executable with os.path.abspath(spec.path), which collapses them. The two strings never agreed, and neither the is_abs shortcut nor the basename fallback applies to a relative path. The proposer used os.path.abspath before the standalone rewrite in #28, and the existing relative-path test only used a path below the cwd. _check_exe now returns os.path.abspath(path) again, so the proposed executable is spelled the way the comparison expects. try_first_with entries are already resolved and absolute specs are accepted on is_abs, so for them the only change is that an absolute spec with ".." reports a normalized executable. --- src/python_discovery/_discovery.py | 3 ++- tests/test_discovery.py | 19 +++++++++++++++++++ tests/test_iter_interpreters.py | 14 ++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index 5bde25e..df54789 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -158,7 +158,8 @@ def _check_exe(path: str, tested_exes: set[str]) -> str | None: if exe_id in tested_exes: return None tested_exes.add(exe_id) - return str(Path(path).absolute()) + # unlike Path.absolute(), abspath collapses "..", which PythonInfo._satisfies_path relies on + return os.path.abspath(path) def _is_new_exe(exe_raw: str, tested_exes: set[str]) -> bool: diff --git a/tests/test_discovery.py b/tests/test_discovery.py index af13847..6f25a0a 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -93,6 +93,25 @@ def test_relative_path(session_cache: DiskCache, monkeypatch: pytest.MonkeyPatch assert result is not None +@pytest.mark.parametrize( + "sep", + [ + pytest.param("/", id="slash"), + pytest.param("\\", id="backslash", marks=pytest.mark.skipif(not IS_WIN, reason="Windows separator")), + ], +) +def test_relative_path_with_parent_reference( + session_cache: DiskCache, monkeypatch: pytest.MonkeyPatch, sep: str +) -> None: + sys_executable = Path(PythonInfo.current_system(session_cache).system_executable) + monkeypatch.chdir(str(sys_executable.parent)) + relative = sep.join((os.pardir, sys_executable.parent.name, sys_executable.name)) + result = get_interpreter(relative, [], session_cache) + assert result is not None + assert os.pardir not in Path(result.executable).parts + assert Path(result.executable).samefile(sys_executable) + + def test_discovery_fallback_fail(session_cache: DiskCache, caplog: pytest.LogCaptureFixture) -> None: caplog.set_level(logging.DEBUG) result = get_interpreter(["magic-one", "magic-two"], cache=session_cache) diff --git a/tests/test_iter_interpreters.py b/tests/test_iter_interpreters.py index c348b71..1cae65f 100644 --- a/tests/test_iter_interpreters.py +++ b/tests/test_iter_interpreters.py @@ -64,6 +64,20 @@ def test_iter_interpreters_with_abs_path_yields_one(session_cache: DiskCache) -> assert results[0].executable == sys.executable +@pytest.mark.usefixtures("uv_dir") +def test_iter_interpreters_relative_path_with_parent_reference( + session_cache: DiskCache, monkeypatch: pytest.MonkeyPatch +) -> None: + sys_executable = Path(PythonInfo.current_system(session_cache).system_executable) + monkeypatch.chdir(str(sys_executable.parent)) + monkeypatch.setenv("PATH", "") + relative = str(Path(os.pardir) / sys_executable.parent.name / sys_executable.name) + results = list(iter_interpreters(relative, cache=session_cache)) + assert len(results) == 1 + assert os.pardir not in Path(results[0].executable).parts + assert Path(results[0].executable).samefile(sys_executable) + + def test_iter_interpreters_sequence_dedups_across_keys(session_cache: DiskCache) -> None: results = list(iter_interpreters([sys.executable, sys.executable], cache=session_cache)) assert len(results) == 1 From 169660506605533300cfb563f4f6050801bb0b82 Mon Sep 17 00:00:00 2001 From: darrenhuai <60621295+darrenhuai@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:34:46 -0700 Subject: [PATCH 2/3] docs: add changelog fragment for 158 --- docs/changelog/158.bugfix.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 docs/changelog/158.bugfix.rst diff --git a/docs/changelog/158.bugfix.rst b/docs/changelog/158.bugfix.rst new file mode 100644 index 0000000..8463510 --- /dev/null +++ b/docs/changelog/158.bugfix.rst @@ -0,0 +1,2 @@ +Accept relative interpreter paths that step up a directory, such as ``../venv/bin/python``, which were found and +queried but never matched the spec - by :user:`darrenhuai`. From 59355749fc547be757dc72f62e4f55afb2412ed7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 27 Oct 2026 12:33:21 -0700 Subject: [PATCH 3/3] docs(changelog): drop hand-written author credit --- docs/changelog/158.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog/158.bugfix.rst b/docs/changelog/158.bugfix.rst index 8463510..36514e2 100644 --- a/docs/changelog/158.bugfix.rst +++ b/docs/changelog/158.bugfix.rst @@ -1,2 +1,2 @@ Accept relative interpreter paths that step up a directory, such as ``../venv/bin/python``, which were found and -queried but never matched the spec - by :user:`darrenhuai`. +queried but never matched the spec.