diff --git a/docs/changelog/+uv-minor-link-order.bugfix.rst b/docs/changelog/+uv-minor-link-order.bugfix.rst new file mode 100644 index 0000000..012764a --- /dev/null +++ b/docs/changelog/+uv-minor-link-order.bugfix.rst @@ -0,0 +1,2 @@ +Rank uv's minor version links behind the concrete install on Windows too, where uv creates them as directory junctions +that ``Path.is_symlink()`` does not report. diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index df54789..65a12de 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -389,8 +389,9 @@ def _uv_sort_key(install: _UvInstall) -> tuple[int, ...]: version = key.version return ( impl_order.index(key.implementation) if key.implementation in impl_order else len(impl_order), - # uv's minor version links point at a concrete install that moves on upgrade, so report the target instead - int(install.path.is_symlink()), + # uv's minor version links point at a concrete install that moves on upgrade, so report the target instead; + # spot them by the name they resolve to, as on Windows they are junctions, which is_symlink() does not report + int(install.path.resolve().name != install.path.name), -version.major, -version.minor, -version.micro, diff --git a/tests/test_iter_interpreters.py b/tests/test_iter_interpreters.py index 8a83218..2ce5fb4 100644 --- a/tests/test_iter_interpreters.py +++ b/tests/test_iter_interpreters.py @@ -431,6 +431,42 @@ def test_iter_interpreters_uv_minor_version_link_loses_to_its_target( assert uv_probed("3.15") == ["cpython-3.15.0b3-macos-aarch64-none/bin/python"] +@pytest.mark.parametrize( + ("concrete", "link"), + [ + pytest.param("cpython-3.15.0b3-windows-x86_64-none", "cpython-3.15-windows-x86_64-none", id="prerelease"), + # the link ties with its target on version, so directory name order decides, and "-" sorts before "." + pytest.param("cpython-3.14.0-windows-x86_64-none", "cpython-3.14-windows-x86_64-none", id="first-release"), + ], +) +def test_iter_interpreters_uv_minor_version_junction_loses_to_its_target( + uv_install: Callable[..., None], + uv_probed: Callable[[str | None], list[str]], + uv_junction: Callable[[str, str], None], + concrete: str, + link: str, +) -> None: + uv_install(f"{concrete}/python.exe") + uv_junction(concrete, link) + + assert uv_probed("3") == [f"{concrete}/python.exe"] + + +@pytest.fixture +def uv_junction(uv_dir: Path, mocker: MockerFixture) -> Callable[[str, str], None]: + def _junction(target: str, link: str) -> None: + if IS_WIN: # pragma: win32 cover + import _winapi + + _winapi.CreateJunction(str(uv_dir / target), str(uv_dir / link)) + else: # pragma: win32 no cover + # stand in for the junction with a symlink that does not report as one + (uv_dir / link).symlink_to(uv_dir / target) + mocker.patch.object(Path, "is_symlink", return_value=False) + + return _junction + + def test_iter_interpreters_uv_probes_one_executable_per_install( uv_dir: Path, uv_install: Callable[..., None], monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture ) -> None: