Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/changelog/+uv-patch-version.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Find uv-managed patch releases when a version specifier names only their major and minor version.
20 changes: 18 additions & 2 deletions src/python_discovery/_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)))


Expand Down
56 changes: 52 additions & 4 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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)