diff --git a/docs/changelog/+uv-patch-version.bugfix.rst b/docs/changelog/+uv-patch-version.bugfix.rst new file mode 100644 index 0000000..2fb54b7 --- /dev/null +++ b/docs/changelog/+uv-patch-version.bugfix.rst @@ -0,0 +1 @@ +Find uv-managed patch releases when a version specifier names only their major and minor version. diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index 5f82233..a85c51b 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -383,8 +383,24 @@ def _uv_version_matches(key: _UvKey, spec: PythonSpec) -> bool: # GraalPy keys carry the GraalVM version in older uv releases, so only CPython and PyPy state a Python version if key.implementation not in {"cpython", "pypy"}: return True - if spec.version_specifier is not None and not spec.version_specifier.contains(str(key.version)): - return False + if spec.version_specifier is not None: + for specifier in spec.version_specifier: + if specifier.version is None: + return False + # Match at the requested precision, as PythonInfo.satisfies does after probing. + numeric_version = specifier.version_str + for prefix in ("rc", "b", "a"): + if prefix in numeric_version: + numeric_version = numeric_version.split(prefix)[0] + break + precision = numeric_version.count(".") + 1 + release = ".".join(str(part) for part in key.version.release[:precision]) + if key.version.pre_type is not None and ( + precision == 3 or specifier.version.pre_type is not None # ruff:ignore[magic-value-comparison] + ): + release = f"{release}{key.version.pre_type}{key.version.pre_num}" + if not specifier.contains(release): + return False return all(req is None or our == req for our, req in zip(key.version.release, (spec.major, spec.minor, spec.micro))) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 0b69e59..964e58b 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -1,10 +1,10 @@ from __future__ import annotations -import copy import logging import os import stat import sys +from copy import deepcopy from pathlib import Path from subprocess import check_call, check_output from typing import TYPE_CHECKING, Final @@ -13,8 +13,8 @@ import pytest -from python_discovery import DiskCache, PythonInfo, get_interpreter -from python_discovery._discovery import IS_WIN, LazyPathDump, get_paths +from python_discovery import DiskCache, PythonInfo, PythonSpec, SimpleSpecifierSet, get_interpreter +from python_discovery._discovery import IS_WIN, LazyPathDump, get_paths, propose_interpreters if TYPE_CHECKING: from collections.abc import Callable @@ -174,7 +174,7 @@ def test_discovery_fallback_reuses_preferred_paths( executable = tmp_path / "preferred" / ("Scripts/python.exe" if IS_WIN else "bin/python") executable.parent.mkdir(parents=True) executable.touch() - preferred_info = copy.deepcopy(PythonInfo.current_system(session_cache)) + preferred_info = deepcopy(PythonInfo.current_system(session_cache)) preferred_info.executable = str(executable) mocker.patch.object(PythonInfo, "from_exe", return_value=preferred_info) paths = [str(executable)] @@ -876,3 +876,51 @@ def test_discovery_uv_store_is_searched_after_path( monkeypatch.setenv("PATH", str(path_dir)) assert uv_probed() == [str(path_executable), store_executable] + + +@pytest.mark.parametrize( + ("spec", "install_version", "releaselevel", "expected"), + [ + pytest.param("==3.99", "3.99.2", "final", True, id="minor-equality"), + pytest.param(">=3.99,<3.100", "3.99.2", "final", True, id="minor-range"), + pytest.param("==3.98", "3.99.2", "final", False, id="different-minor"), + pytest.param(">=3.99a1", "3.99a2", "alpha", True, id="alpha"), + pytest.param(">=3.99", "3.99a2", "alpha", True, id="alpha-minor-bound"), + pytest.param(">=3.99b1", "3.99b2", "beta", True, id="beta"), + pytest.param(">=3.99rc1", "3.99rc2", "candidate", True, id="candidate"), + ], +) +def test_discovery_uv_patch_release_matches_minor_spec( + tmp_path: Path, mocker: MockerFixture, spec: str, install_version: str, releaselevel: str, expected: bool +) -> None: + store = tmp_path / "uv" + executable = store / f"cpython-{install_version}-linux-x86_64-gnu" / "bin" / "python" + executable.parent.mkdir(parents=True) + executable.touch() + info = deepcopy(PythonInfo.current_system()) + info.executable = str(executable) + info.system_executable = str(executable) + info.version_info = info.version_info._replace( + major=3, minor=99, micro=2 if releaselevel == "final" else 0, releaselevel=releaselevel, serial=2 + ) + mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=info) + + found = get_interpreter(spec, [], env={"PATH": "", "UV_PYTHON_INSTALL_DIR": str(store)}) + + assert (found is info) is expected + + +def test_discovery_uv_invalid_hand_built_spec_skips_install(tmp_path: Path, mocker: MockerFixture) -> None: + store = tmp_path / "uv" + executable = store / "cpython-3.99.2-linux-x86_64-gnu" / "bin" / "python" + executable.parent.mkdir(parents=True) + executable.touch() + spec = PythonSpec.from_string_spec("==3.99") + spec.version_specifier = SimpleSpecifierSet.from_string("==invalid") + info = deepcopy(PythonInfo.current_system()) + info.executable = str(executable) + mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=info) + + candidates = list(propose_interpreters(spec, [], env={"PATH": "", "UV_PYTHON_INSTALL_DIR": str(store)})) + + assert all(candidate is not info for candidate, _ in candidates)