Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support for uv-installed pythons #842

Merged
merged 12 commits into from
Oct 2, 2024
41 changes: 41 additions & 0 deletions nox/virtualenv.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import abc
import contextlib
import functools
import json
import os
import platform
import re
Expand All @@ -28,6 +29,8 @@
from socket import gethostbyname
from typing import Any, ClassVar

from packaging import version

import nox
import nox.command
from nox.logger import logger
Expand Down Expand Up @@ -65,7 +68,39 @@ def find_uv() -> tuple[bool, str]:
return uv_on_path is not None, "uv"


def uv_version() -> version.Version:
"""Returns uv's version defaulting to 0.0 if uv is not available"""
try:
ret = subprocess.run(
[UV, "version", "--output-format", "json"],
check=False,
text=True,
capture_output=True,
)
except FileNotFoundError:
logger.info("uv binary not found.")
return version.Version("0.0")

if ret.returncode == 0 and ret.stdout:
return version.Version(json.loads(ret.stdout).get("version"))
else:
logger.info("Failed to establish uv's version.")
return version.Version("0.0")


def uv_install_python(python_version: str) -> bool:
"""Attempts to install a given python version with uv"""
ret = subprocess.run(
[UV, "python", "install", python_version],
check=False,
)
return ret.returncode == 0


HAS_UV, UV = find_uv()
# supported since uv 0.3 but 0.4.16 is the first version that doesn't cause
# issues for nox with pypy/cpython confusion
UV_PYTHON_SUPPORT = uv_version() >= version.Version("0.4.16")


class InterpreterNotFound(OSError):
Expand Down Expand Up @@ -526,6 +561,12 @@ def _resolved_interpreter(self) -> str:
self._resolved = cleaned_interpreter
return self._resolved

if HAS_UV and UV_PYTHON_SUPPORT:
uv_python_success = uv_install_python(cleaned_interpreter)
if uv_python_success:
self._resolved = cleaned_interpreter
return self._resolved

# The rest of this is only applicable to Windows, so if we don't have
# an interpreter by now, raise.
if _SYSTEM != "Windows":
Expand Down
51 changes: 51 additions & 0 deletions tests/test_virtualenv.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,11 @@ def test_uv_creation(make_one):
assert venv._check_reused_environment_type()


@has_uv
def test_uv_managed_python(make_one):
make_one(interpreter="cpython3.12", venv_backend="uv")


def test_constructor_defaults(make_one):
venv, _ = make_one()
assert venv.location
Expand Down Expand Up @@ -620,6 +625,50 @@ def find_uv_bin():
assert nox.virtualenv.find_uv() == (found, path)


@pytest.mark.parametrize(
["return_code", "stdout", "expected_result"],
[
(0, '{"version": "0.2.3", "commit_info": null}', "0.2.3"),
(1, None, "0.0"),
(1, '{"version": "9.9.9", "commit_info": null}', "0.0"),
],
)
def test_uv_version(monkeypatch, return_code, stdout, expected_result):
def mock_run(*args, **kwargs):
return subprocess.CompletedProcess(
args=["uv", "version", "--output-format", "json"],
stdout=stdout,
returncode=return_code,
)

monkeypatch.setattr(subprocess, "run", mock_run)
assert nox.virtualenv.uv_version() == version.Version(expected_result)


def test_uv_version_no_uv(monkeypatch):
def mock_exception(*args, **kwargs):
raise FileNotFoundError

monkeypatch.setattr(subprocess, "run", mock_exception)
assert nox.virtualenv.uv_version() == version.Version("0.0")


@pytest.mark.parametrize(
["requested_python", "expected_result"],
[
("3.11", True),
("pypy3.8", True),
("cpython3.9", True),
("python3.12", True),
("nonpython9.22", False),
("java11", False),
],
)
@has_uv
def test_uv_install(requested_python, expected_result):
assert nox.virtualenv.uv_install_python(requested_python) == expected_result


def test_create_reuse_venv_environment(make_one, monkeypatch):
# Making the reuse requirement more strict
monkeypatch.setenv("NOX_ENABLE_STALENESS_CHECK", "1")
Expand Down Expand Up @@ -840,6 +889,7 @@ def special_run(cmd, *args, **kwargs):


@mock.patch("nox.virtualenv._SYSTEM", new="Windows")
@mock.patch("nox.virtualenv.UV_PYTHON_SUPPORT", new=False)
def test__resolved_interpreter_windows_path_and_version(make_one, patch_sysfind):
# Establish that if we get a standard pythonX.Y path, we look it
# up via the path on Windows.
Expand All @@ -865,6 +915,7 @@ def test__resolved_interpreter_windows_path_and_version(make_one, patch_sysfind)
@pytest.mark.parametrize("sysfind_result", [r"c:\python37-x64\python.exe", None])
@pytest.mark.parametrize("sysexec_result", ["3.7.3\\n", RAISE_ERROR])
@mock.patch("nox.virtualenv._SYSTEM", new="Windows")
@mock.patch("nox.virtualenv.UV_PYTHON_SUPPORT", new=False)
def test__resolved_interpreter_windows_path_and_version_fails(
input_, sysfind_result, sysexec_result, make_one, patch_sysfind
):
Expand Down
Loading