Skip to content

Commit 81cf550

Browse files
committed
feat: use python-discovery
This enables version ranges to be used. Assisted-by: OpenCode:Kimi-K2.5 Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>
1 parent 71eb524 commit 81cf550

File tree

4 files changed

+174
-6
lines changed

4 files changed

+174
-6
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ repos:
4848
- packaging
4949
- pbs_installer
5050
- pytest<9 # pytest 9 requires 3.10+
51+
- python-discovery
5152
- importlib_metadata
5253
- importlib_resources
5354
- tomli

nox/virtualenv.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,36 @@
3434
import nox.command
3535
from nox.logger import logger
3636

37+
38+
def _python_discovery_get_interpreter() -> Any:
39+
"""Lazy import of get_interpreter from python-discovery."""
40+
import python_discovery # noqa: PLC0415
41+
42+
return python_discovery.get_interpreter
43+
44+
45+
def _python_discovery_DiskCache() -> Any:
46+
"""Lazy import of DiskCache from python-discovery."""
47+
import python_discovery # noqa: PLC0415
48+
49+
return python_discovery.DiskCache
50+
51+
52+
def _get_python_discovery_cache() -> Any:
53+
"""Get or create the DiskCache for python-discovery.
54+
55+
Returns:
56+
DiskCache instance or None if caching should not be used
57+
"""
58+
cache_dir = Path.home() / ".cache" / "nox" / "python-discovery"
59+
try:
60+
disk_cache_cls = _python_discovery_DiskCache()
61+
return disk_cache_cls(root=cache_dir)
62+
except OSError:
63+
# If cache directory can't be created, return None (no caching)
64+
return None
65+
66+
3767
if TYPE_CHECKING:
3868
from collections.abc import Callable, Mapping, Sequence
3969

@@ -111,11 +141,46 @@ def find_uv() -> tuple[bool, str, version.Version]:
111141
)
112142

113143

144+
def _is_version_range_spec(spec: str) -> bool:
145+
"""Check if the spec is a version range spec (e.g., '>=3.11,<3.13')."""
146+
# Check for PEP 440 version comparison operators
147+
return any(op in spec for op in [">=", ">", "<=", "<", "~=", "==", "!=", ","])
148+
149+
114150
def _find_python(interpreter: str, xy_ver: str) -> str | None:
115-
"""Find a python executable matching the requested interpreter"""
151+
"""Find a python executable matching the requested interpreter.
152+
153+
Uses traditional methods first for backwards compatibility, with python-discovery
154+
as a fallback for advanced version specs or when traditional methods fail.
155+
"""
156+
# For version range specs (e.g., ">=3.11,<3.13"), use python-discovery directly
157+
if _is_version_range_spec(interpreter):
158+
try:
159+
cache = _get_python_discovery_cache()
160+
get_interpreter = _python_discovery_get_interpreter()
161+
result = get_interpreter(interpreter, cache=cache)
162+
if result is not None:
163+
return str(result.executable)
164+
except (OSError, ValueError, RuntimeError):
165+
# Fall back to traditional discovery on error
166+
pass
167+
return None
168+
169+
# Try traditional discovery first for backwards compatibility
116170
if shutil.which(interpreter):
117171
return interpreter
118172

173+
# Fall back to python-discovery for other interpreters that weren't found
174+
try:
175+
cache = _get_python_discovery_cache()
176+
get_interpreter = _python_discovery_get_interpreter()
177+
result = get_interpreter(interpreter, cache=cache)
178+
if result is not None:
179+
return str(result.executable)
180+
except (OSError, ValueError, RuntimeError):
181+
# Fall back to platform-specific discovery on error
182+
pass
183+
119184
# Windows only search for the executable
120185
if _PLATFORM.startswith("win"):
121186
# Allow versions of the form ``X.Y-32`` for Windows.
@@ -777,6 +842,14 @@ def _resolved_interpreter(self) -> str:
777842
t = match.group("t")
778843
cleaned_interpreter = f"python{xy_version}{t}"
779844

845+
# For version range specs (e.g., ">=3.11,<3.13"), check directly
846+
# This enables new functionality for specifying version ranges
847+
if _is_version_range_spec(self.interpreter) and (
848+
resolved := _find_python(self.interpreter, xy_version)
849+
):
850+
self._resolved = resolved
851+
return self._resolved
852+
780853
match self.download_python, self.venv_backend:
781854
# never -> check for interpreters
782855
case "never", _:

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ dependencies = [
4343
"dependency-groups>=1.1",
4444
"humanize>=4",
4545
"packaging>=22",
46+
"python-discovery>=0.1",
4647
"tomli>=1.1; python_version<'3.11'",
4748
"virtualenv>=20.15",
4849
]

tests/test_virtualenv.py

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,19 @@ def special_which(name: str, path: Any = None) -> str | None: # noqa: ARG001
114114

115115
monkeypatch.setattr(shutil, "which", special_which)
116116

117+
# Also mock python-discovery's get_interpreter for backwards compatibility
118+
# with tests that rely on the mocked shutil.which behavior
119+
def mock_get_interpreter(*args: object, **kwargs: object) -> None:
120+
# For tests, we want to respect who the traditional discovery would have found
121+
# Since special_which returns None for unknown interpreters, we also return None
122+
return None
123+
124+
monkeypatch.setattr(
125+
nox.virtualenv,
126+
"_python_discovery_get_interpreter",
127+
lambda: mock_get_interpreter,
128+
)
129+
117130
def special_run(cmd: Any, *args: Any, **kwargs: Any) -> TextProcessResult: # noqa: ARG001
118131
return TextProcessResult(sysexec_result)
119132

@@ -1596,12 +1609,26 @@ def test_download_python_never_missing_interpreter(
15961609
pbs_install_mock: mock.Mock,
15971610
venv_backend: str,
15981611
make_one: Callable[..., tuple[VirtualEnv, Path]],
1612+
monkeypatch: pytest.MonkeyPatch,
15991613
) -> None:
1614+
"""Test that InterpreterNotFound is raised when interpreter is missing."""
1615+
1616+
def mock_get_interpreter(*args: object, **kwargs: object) -> None:
1617+
return None
1618+
1619+
# Mock at the nox module level to prevent python-discovery from finding
1620+
monkeypatch.setattr(
1621+
nox.virtualenv,
1622+
"_python_discovery_get_interpreter",
1623+
lambda: mock_get_interpreter,
1624+
)
1625+
16001626
venv, _ = make_one(
16011627
interpreter="python3.11",
16021628
venv_backend=venv_backend,
16031629
download_python="never",
16041630
)
1631+
16051632
with pytest.raises(nox.virtualenv.InterpreterNotFound):
16061633
_ = venv._resolved_interpreter
16071634

@@ -1656,7 +1683,19 @@ def test_download_python_auto_missing_interpreter(
16561683
pbs_install_mock: mock.Mock,
16571684
venv_backend: str,
16581685
make_one: Callable[..., tuple[VirtualEnv, Path]],
1686+
monkeypatch: pytest.MonkeyPatch,
16591687
) -> None:
1688+
1689+
# Mock python-discovery to return None (simulating missing interpreter)
1690+
def mock_get_interpreter(*args: object, **kwargs: object) -> None:
1691+
return None
1692+
1693+
monkeypatch.setattr(
1694+
nox.virtualenv,
1695+
"_python_discovery_get_interpreter",
1696+
lambda: mock_get_interpreter,
1697+
)
1698+
16601699
venv, _ = make_one(
16611700
interpreter="python3.11",
16621701
venv_backend=venv_backend,
@@ -1688,14 +1727,26 @@ def test_download_python_auto_missing_interpreter(
16881727
"nox.virtualenv.uv_install_python",
16891728
return_value=True,
16901729
)
1691-
@mock.patch.object(shutil, "which", return_value="/usr/bin/python3.11")
1730+
@mock.patch.object(shutil, "which", return_value=None)
16921731
def test_download_python_always_preexisting_interpreter(
16931732
which: mock.Mock,
16941733
uv_install_mock: mock.Mock,
16951734
pbs_install_mock: mock.Mock,
16961735
venv_backend: str,
16971736
make_one: Callable[..., tuple[VirtualEnv, Path]],
1737+
monkeypatch: pytest.MonkeyPatch,
16981738
) -> None:
1739+
1740+
# Mock python-discovery to return None (simulating missing interpreter)
1741+
def mock_get_interpreter(*args: object, **kwargs: object) -> None:
1742+
return None
1743+
1744+
monkeypatch.setattr(
1745+
nox.virtualenv,
1746+
"_python_discovery_get_interpreter",
1747+
lambda: mock_get_interpreter,
1748+
)
1749+
16991750
venv, _ = make_one(
17001751
interpreter="python3.11",
17011752
venv_backend=venv_backend,
@@ -1728,17 +1779,27 @@ def test_download_python_failed_install(
17281779
download_python: str,
17291780
venv_backend: str,
17301781
make_one: Callable[..., tuple[VirtualEnv, Path]],
1782+
monkeypatch: pytest.MonkeyPatch,
17311783
) -> None:
1784+
1785+
# Mock python-discovery to return None (simulating missing interpreter)
1786+
def mock_get_interpreter(*args: object, **kwargs: object) -> None:
1787+
return None
1788+
1789+
monkeypatch.setattr(
1790+
nox.virtualenv,
1791+
"_python_discovery_get_interpreter",
1792+
lambda: mock_get_interpreter,
1793+
)
1794+
monkeypatch.setattr(shutil, "which", lambda _x: None)
1795+
17321796
venv, _ = make_one(
17331797
interpreter="python3.11",
17341798
venv_backend=venv_backend,
17351799
download_python=download_python,
17361800
)
17371801

1738-
with (
1739-
mock.patch.object(shutil, "which", return_value=None) as _,
1740-
pytest.raises(nox.virtualenv.InterpreterNotFound),
1741-
):
1802+
with pytest.raises(nox.virtualenv.InterpreterNotFound):
17421803
_ = venv._resolved_interpreter
17431804

17441805
if venv_backend == "uv":
@@ -1850,8 +1911,20 @@ def test_download_python_uv_unsupported_version(
18501911
uv_install_mock: mock.Mock,
18511912
download_python: str,
18521913
make_one: Callable[..., tuple[VirtualEnv, Path]],
1914+
monkeypatch: pytest.MonkeyPatch,
18531915
) -> None:
18541916
"""Test we dont install for unsupported uv versions"""
1917+
1918+
# Mock python-discovery to return None (simulating missing interpreter)
1919+
def mock_get_interpreter(*args: object, **kwargs: object) -> None:
1920+
return None
1921+
1922+
monkeypatch.setattr(
1923+
nox.virtualenv,
1924+
"_python_discovery_get_interpreter",
1925+
lambda: mock_get_interpreter,
1926+
)
1927+
18551928
venv, _ = make_one(
18561929
interpreter="python3.11",
18571930
venv_backend="uv",
@@ -1866,3 +1939,23 @@ def test_download_python_uv_unsupported_version(
18661939
which.assert_not_called()
18671940
else: # auto
18681941
which.assert_any_call("python3.11")
1942+
1943+
1944+
@pytest.mark.parametrize(
1945+
("spec", "is_version_range"),
1946+
[
1947+
(">=3.11,<3.13", True),
1948+
(">=3.9", True),
1949+
("<3.14", True),
1950+
("~=3.11", True),
1951+
("==3.12.1", True),
1952+
("!=3.11", True),
1953+
("3.12", False),
1954+
("python3.12", False),
1955+
("pypy3.10", False),
1956+
],
1957+
)
1958+
def test_is_version_range_spec(spec: str, is_version_range: bool) -> None:
1959+
"""Test the _is_version_range_spec function correctly identifies version specs."""
1960+
result = nox.virtualenv._is_version_range_spec(spec)
1961+
assert result == is_version_range

0 commit comments

Comments
 (0)