From 8f3b68cca283393100c3c0a3b1fd0b5952fb381d Mon Sep 17 00:00:00 2001 From: darrenhuai <60621295+darrenhuai@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:46:13 -0700 Subject: [PATCH 1/3] fix(uv): rank minor version junctions behind the concrete install on Windows uv links each minor version at its newest patch install, cpython-3.15 pointing at cpython-3.15.0b3 for example, and #112 ranks that link behind the install it points at because uv repoints it on the next patch release. On Windows uv creates these links as directory junctions, and Path.is_symlink() returns False for a junction on every Python version, so the link was never demoted there. For a prerelease the link parses as the final release and wins outright, and for an X.Y.0 install the two tie and the link wins on directory name order. The resolved-path dedup then drops the concrete install, so get_interpreter() and iter_interpreters() report the junction path, which moves the next time uv installs a patch release. PEP 514 discovery runs first and finds the concrete path for installs uv registered there, so this hits Windows users whose uv Python has no registry entry. Spot the link by comparing the name it resolves to with its own name instead. Path.resolve() follows symlinks and junctions alike on every supported version, so this needs no Windows-only API: os.path.isjunction only exists from 3.12 and the stat reparse tag constants are only defined on Windows. --- src/python_discovery/_discovery.py | 5 +++-- tests/test_iter_interpreters.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) 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..e56606e 100644 --- a/tests/test_iter_interpreters.py +++ b/tests/test_iter_interpreters.py @@ -431,6 +431,36 @@ 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_dir: Path, + uv_install: Callable[..., None], + uv_probed: Callable[[str | None], list[str]], + mocker: MockerFixture, + concrete: str, + link: str, +) -> None: + """On Windows uv links minor versions with a directory junction, which ``Path.is_symlink`` does not report.""" + uv_install(f"{concrete}/python.exe") + if IS_WIN: # pragma: win32 cover + import _winapi + + _winapi.CreateJunction(str(uv_dir / concrete), 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 / concrete) + mocker.patch.object(Path, "is_symlink", return_value=False) + + assert uv_probed("3") == [f"{concrete}/python.exe"] + + def test_iter_interpreters_uv_probes_one_executable_per_install( uv_dir: Path, uv_install: Callable[..., None], monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture ) -> None: From 60629d188b4b99ca4dbbf5726dd7c2d99bc7b891 Mon Sep 17 00:00:00 2001 From: darrenhuai <60621295+darrenhuai@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:58:18 -0700 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=93=9D=20docs(changelog):=20add=20the?= =?UTF-8?q?=20news=20fragment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/+uv-minor-link-order.bugfix.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 docs/changelog/+uv-minor-link-order.bugfix.rst 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. From 9f310eb704f99d78ccba245075f5d0e1c8354fb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 27 Oct 2026 23:14:56 -0700 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A7=AA=20test(uv):=20create=20the=20j?= =?UTF-8?q?unction=20in=20a=20fixture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the platform branch out of the test body so the test only states what it checks. Drop the test docstring; the source comment already explains why junctions need their own detection. --- tests/test_iter_interpreters.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/tests/test_iter_interpreters.py b/tests/test_iter_interpreters.py index e56606e..2ce5fb4 100644 --- a/tests/test_iter_interpreters.py +++ b/tests/test_iter_interpreters.py @@ -440,27 +440,33 @@ def test_iter_interpreters_uv_minor_version_link_loses_to_its_target( ], ) def test_iter_interpreters_uv_minor_version_junction_loses_to_its_target( - uv_dir: Path, uv_install: Callable[..., None], uv_probed: Callable[[str | None], list[str]], - mocker: MockerFixture, + uv_junction: Callable[[str, str], None], concrete: str, link: str, ) -> None: - """On Windows uv links minor versions with a directory junction, which ``Path.is_symlink`` does not report.""" uv_install(f"{concrete}/python.exe") - if IS_WIN: # pragma: win32 cover - import _winapi - - _winapi.CreateJunction(str(uv_dir / concrete), 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 / concrete) - mocker.patch.object(Path, "is_symlink", return_value=False) + 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: