Skip to content

Commit a49c730

Browse files
authored
feat: support for uv-installed pythons (#842)
* dummy support for uv python installs * attempt install before confirming the interpreter is available * adding some tests * change version output to json so it's consistent in linux vs windows * skip test with uv-python-support in windows * fix using the wrong variable caused problems with pypy * change min supported uv version * remove if statement * change global variable and handle uv missing in uv_version * typo
1 parent ba29b2c commit a49c730

File tree

2 files changed

+92
-0
lines changed

2 files changed

+92
-0
lines changed

nox/virtualenv.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import abc
1818
import contextlib
1919
import functools
20+
import json
2021
import os
2122
import platform
2223
import re
@@ -28,6 +29,8 @@
2829
from socket import gethostbyname
2930
from typing import Any, ClassVar
3031

32+
from packaging import version
33+
3134
import nox
3235
import nox.command
3336
from nox.logger import logger
@@ -65,7 +68,39 @@ def find_uv() -> tuple[bool, str]:
6568
return uv_on_path is not None, "uv"
6669

6770

71+
def uv_version() -> version.Version:
72+
"""Returns uv's version defaulting to 0.0 if uv is not available"""
73+
try:
74+
ret = subprocess.run(
75+
[UV, "version", "--output-format", "json"],
76+
check=False,
77+
text=True,
78+
capture_output=True,
79+
)
80+
except FileNotFoundError:
81+
logger.info("uv binary not found.")
82+
return version.Version("0.0")
83+
84+
if ret.returncode == 0 and ret.stdout:
85+
return version.Version(json.loads(ret.stdout).get("version"))
86+
else:
87+
logger.info("Failed to establish uv's version.")
88+
return version.Version("0.0")
89+
90+
91+
def uv_install_python(python_version: str) -> bool:
92+
"""Attempts to install a given python version with uv"""
93+
ret = subprocess.run(
94+
[UV, "python", "install", python_version],
95+
check=False,
96+
)
97+
return ret.returncode == 0
98+
99+
68100
HAS_UV, UV = find_uv()
101+
# supported since uv 0.3 but 0.4.16 is the first version that doesn't cause
102+
# issues for nox with pypy/cpython confusion
103+
UV_PYTHON_SUPPORT = uv_version() >= version.Version("0.4.16")
69104

70105

71106
class InterpreterNotFound(OSError):
@@ -526,6 +561,12 @@ def _resolved_interpreter(self) -> str:
526561
self._resolved = cleaned_interpreter
527562
return self._resolved
528563

564+
if HAS_UV and UV_PYTHON_SUPPORT:
565+
uv_python_success = uv_install_python(cleaned_interpreter)
566+
if uv_python_success:
567+
self._resolved = cleaned_interpreter
568+
return self._resolved
569+
529570
# The rest of this is only applicable to Windows, so if we don't have
530571
# an interpreter by now, raise.
531572
if _SYSTEM != "Windows":

tests/test_virtualenv.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,11 @@ def test_uv_creation(make_one):
269269
assert venv._check_reused_environment_type()
270270

271271

272+
@has_uv
273+
def test_uv_managed_python(make_one):
274+
make_one(interpreter="cpython3.12", venv_backend="uv")
275+
276+
272277
def test_constructor_defaults(make_one):
273278
venv, _ = make_one()
274279
assert venv.location
@@ -620,6 +625,50 @@ def find_uv_bin():
620625
assert nox.virtualenv.find_uv() == (found, path)
621626

622627

628+
@pytest.mark.parametrize(
629+
["return_code", "stdout", "expected_result"],
630+
[
631+
(0, '{"version": "0.2.3", "commit_info": null}', "0.2.3"),
632+
(1, None, "0.0"),
633+
(1, '{"version": "9.9.9", "commit_info": null}', "0.0"),
634+
],
635+
)
636+
def test_uv_version(monkeypatch, return_code, stdout, expected_result):
637+
def mock_run(*args, **kwargs):
638+
return subprocess.CompletedProcess(
639+
args=["uv", "version", "--output-format", "json"],
640+
stdout=stdout,
641+
returncode=return_code,
642+
)
643+
644+
monkeypatch.setattr(subprocess, "run", mock_run)
645+
assert nox.virtualenv.uv_version() == version.Version(expected_result)
646+
647+
648+
def test_uv_version_no_uv(monkeypatch):
649+
def mock_exception(*args, **kwargs):
650+
raise FileNotFoundError
651+
652+
monkeypatch.setattr(subprocess, "run", mock_exception)
653+
assert nox.virtualenv.uv_version() == version.Version("0.0")
654+
655+
656+
@pytest.mark.parametrize(
657+
["requested_python", "expected_result"],
658+
[
659+
("3.11", True),
660+
("pypy3.8", True),
661+
("cpython3.9", True),
662+
("python3.12", True),
663+
("nonpython9.22", False),
664+
("java11", False),
665+
],
666+
)
667+
@has_uv
668+
def test_uv_install(requested_python, expected_result):
669+
assert nox.virtualenv.uv_install_python(requested_python) == expected_result
670+
671+
623672
def test_create_reuse_venv_environment(make_one, monkeypatch):
624673
# Making the reuse requirement more strict
625674
monkeypatch.setenv("NOX_ENABLE_STALENESS_CHECK", "1")
@@ -840,6 +889,7 @@ def special_run(cmd, *args, **kwargs):
840889

841890

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

0 commit comments

Comments
 (0)