diff --git a/docs/changelog/158.bugfix.rst b/docs/changelog/158.bugfix.rst new file mode 100644 index 0000000..36514e2 --- /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. 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