Skip to content

Commit 714592d

Browse files
authored
fix: download upper-bound-only Python ranges (#1148)
* fix: install the highest allowed version for upper-bound-only ranges An upper-bound-only range such as "<3.14" had no installable floor, so --download-python could not help. Pick the highest X.Y below the tightest upper bound, and step down past "!=" exclusions. Assisted-by: ClaudeCode:claude-fable-5 * fix: never pick a minor whose patch releases can exceed an upper bound An install target "X.Y" resolves to the latest X.Y patch release, so bounds like "<3.14.4" or "<=3.14" could install an interpreter outside the range once a newer patch exists (and discovery, which matches with strict PEP 440 semantics, would then never find it). Always step down to the minor below the bound, which every patch release satisfies. Assisted-by: ClaudeCode:claude-fable-5 * refactor: simplify upper-bound target resolution Return (major, minor) tuples from _highest_under_upper_bound instead of strings the caller re-parses, and derive the free-threaded "t" suffix once up front. Also fixes two edge cases: mixed specs like <3.13,<3.14t no longer drop the "t", and clauses packaging cannot parse (<3.14,<3.12.) return None instead of raising InvalidSpecifier. Assisted-by: ClaudeCode:claude-fable-5
1 parent 22d4ada commit 714592d

3 files changed

Lines changed: 116 additions & 16 deletions

File tree

docs/config.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ Nox will use the first installed interpreter that satisfies it:
159159
Range specifiers are only supported on the ``venv``, ``virtualenv``, and ``uv``
160160
backends, not on conda backends.
161161

162-
If the specified python interpreter is not found, Nox can automatically download it when ``--download-python`` is set to ``auto`` (the default) or ``always``. ``never`` avoids the download. This requires the ``[pbs]`` extra when not using uv as a backend. When a range is given, the floor of its lowest bound is downloaded (``>=3.14`` downloads ``3.14``); a range with no lower bound (such as ``<3.14``) cannot be downloaded.
162+
If the specified python interpreter is not found, Nox can automatically download it when ``--download-python`` is set to ``auto`` (the default) or ``always``. ``never`` avoids the download. This requires the ``[pbs]`` extra when not using uv as a backend. When a range is given, the floor of its lowest bound is downloaded (``>=3.14`` downloads ``3.14``). A range with only upper bounds downloads the highest minor version that the range fully allows (``<3.14`` downloads ``3.13``, ``<=3.14.4`` downloads ``3.13``, ``<3.14,!=3.13.*`` downloads ``3.12``). A range with no usable bound (such as ``!=3.12``) cannot be downloaded.
163163

164164
When collecting your sessions, Nox will create a separate session for each interpreter. You can see these sessions when running ``nox --list``. For example this Noxfile:
165165

nox/virtualenv.py

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
from socket import gethostbyname
4141
from typing import TYPE_CHECKING, Any, Literal
4242

43-
from packaging import version
43+
from packaging import specifiers, version
4444

4545
import nox
4646
import nox.command
@@ -190,27 +190,84 @@ def _find_python(interpreter: str) -> str | None:
190190
return info.executable if info is not None else None
191191

192192

193+
def _highest_under_upper_bound(
194+
operator: str, version_str: str
195+
) -> tuple[int, int] | None:
196+
"""Return the highest ``(major, minor)`` fully below a ``<``/``<=`` clause.
197+
198+
Installers resolve ``X.Y`` to its latest patch release, so the pick must
199+
allow *every* ``X.Y`` patch; that is always the minor below the bound's:
200+
``<3.12`` -> ``3.11``, ``<=3.12`` -> ``3.11``, ``<3.12.4`` -> ``3.11``
201+
(``3.12`` would install a patch that may exceed ``3.12.4``). Returns
202+
``None`` for other operators or when no minor version can be picked
203+
(``<4``, ``<3.0``).
204+
"""
205+
if operator not in {"<", "<="}:
206+
return None
207+
try:
208+
release = version.Version(version_str).release
209+
except version.InvalidVersion:
210+
return None
211+
if len(release) < 2:
212+
return None
213+
major, minor = release[0], release[1] - 1
214+
if minor < 0:
215+
return None
216+
return major, minor
217+
218+
193219
def _concrete_install_target(interpreter: str) -> str | None:
194220
"""Return a concrete version to hand to an installer, or ``None``.
195221
196222
Non-range specs (names, concrete versions, paths) are returned unchanged.
197223
For a PEP 440 range (``>=3.14``) the floor of the first lower-bound clause
198-
is returned (``3.14``) so a concrete interpreter can be downloaded. A
199-
non-CPython implementation prefix is preserved (``pypy>=3.10`` -> ``pypy3.10``)
200-
so the right flavor is installed; the floor already carries any free-threaded
201-
``t`` suffix. Upper-bound-only (``<3.14``) or ``!=`` ranges have no
202-
installable floor and return ``None``.
224+
is returned (``3.14``) so a concrete interpreter can be downloaded. Without
225+
a lower bound, the highest version under the tightest upper bound that the
226+
other clauses allow is used instead (``<3.12`` -> ``3.11``,
227+
``<3.14,!=3.13.*`` -> ``3.12``). A non-CPython implementation prefix is
228+
preserved (``pypy>=3.10`` -> ``pypy3.10``) so the right flavor is installed;
229+
the version already carries any free-threaded ``t`` suffix. Ranges with no
230+
usable bound (``!=3.12``, ``<4``) return ``None``.
203231
"""
204232
from python_discovery import PythonSpec # noqa: PLC0415
205233

206234
spec = PythonSpec.from_string_spec(interpreter)
207235
if spec.version_specifier is None:
208236
return interpreter
237+
impl = spec.implementation
238+
prefix = impl if impl and impl != "cpython" else ""
209239
for specifier in spec.version_specifier.specifiers:
210240
if specifier.operator in {">=", "==", "~=", ">"}:
211-
impl = spec.implementation
212-
prefix = impl if impl and impl != "cpython" else ""
213241
return f"{prefix}{specifier.version_str}"
242+
243+
# python-discovery's specifiers don't understand the free-threaded "t"
244+
# suffix, so strip it up front and do the version arithmetic with packaging.
245+
suffix = (
246+
"t"
247+
if any(s.version_str.endswith("t") for s in spec.version_specifier.specifiers)
248+
else ""
249+
)
250+
clauses = [
251+
(s.operator, s.version_str.removesuffix("t"))
252+
for s in spec.version_specifier.specifiers
253+
]
254+
candidates = [
255+
candidate
256+
for op, ver in clauses
257+
if (candidate := _highest_under_upper_bound(op, ver)) is not None
258+
]
259+
if not candidates:
260+
return None
261+
try:
262+
allowed = specifiers.SpecifierSet(",".join(f"{op}{ver}" for op, ver in clauses))
263+
except specifiers.InvalidSpecifier:
264+
return None
265+
# The tightest upper bound wins, but another clause (an "!=") can exclude
266+
# the pick, so step down through the minors until one clears the whole set.
267+
major, top_minor = min(candidates)
268+
for minor in range(top_minor, -1, -1):
269+
if allowed.contains(f"{major}.{minor}"):
270+
return f"{prefix}{major}.{minor}{suffix}"
214271
return None
215272

216273

@@ -865,9 +922,10 @@ def _resolved_interpreter(self) -> str:
865922
def _install_python(self, interpreter: str) -> str | None:
866923
"""Install the requested interpreter for this backend, if possible.
867924
868-
For a version range the floor is installed (e.g. ``>=3.14`` -> ``3.14``);
869-
ranges with no installable floor return ``None``. Returns the resolved
870-
interpreter on success, or ``None`` on failure.
925+
For a version range a concrete version is installed (e.g. ``>=3.14`` ->
926+
``3.14``, ``<3.12`` -> ``3.11``); ranges with no usable bound return
927+
``None``. Returns the resolved interpreter on success, or ``None`` on
928+
failure.
871929
"""
872930
target = _concrete_install_target(interpreter)
873931
if target is None:

tests/test_virtualenv.py

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,7 +1307,25 @@ def boom(*args: object, **kwargs: object) -> NoReturn:
13071307
("pypy>=3.10", "pypy3.10"),
13081308
("cpython>=3.12", "3.12"),
13091309
(">=3.13t", "3.13t"),
1310-
("<3.14", None),
1310+
("<3.14", "3.13"),
1311+
("<=3.14", "3.13"),
1312+
("<3.14.4", "3.13"),
1313+
("<=3.14.4", "3.13"),
1314+
("<3.14.0", "3.13"),
1315+
("<3.14t", "3.13t"),
1316+
("pypy<3.11", "pypy3.10"),
1317+
("<3.14,<3.12", "3.11"),
1318+
("<3.12,!=3.11.*", "3.10"),
1319+
("<3.14,!=3.13.*", "3.12"),
1320+
("<3.14,!=3.13.*,!=3.12.*", "3.11"),
1321+
("<3.14t,!=3.13.*", "3.12t"),
1322+
("<3.13,<3.14t", "3.12t"),
1323+
("<3.1,!=3.0.*", None),
1324+
# python-discovery's permissive grammar accepts these; packaging can't.
1325+
("<3.12.", None),
1326+
("<3.14,<3.12.", None),
1327+
("<4", None),
1328+
("<3.0", None),
13111329
("!=3.12", None),
13121330
],
13131331
)
@@ -1816,19 +1834,43 @@ def test_download_python_range_installs_floor(
18161834

18171835

18181836
@pytest.mark.parametrize("download_python", ["always", "auto"])
1819-
@mock.patch("nox.virtualenv.pbs_install_python")
1820-
@mock.patch("nox.virtualenv.uv_install_python")
1837+
@mock.patch(
1838+
"nox.virtualenv.pbs_install_python",
1839+
return_value="/.local/share/nox/cpython@3.13.0/bin/python3.13",
1840+
)
1841+
@mock.patch("nox.virtualenv.uv_install_python", return_value=True)
18211842
def test_download_python_range_without_floor(
18221843
uv_install_mock: mock.Mock,
18231844
pbs_install_mock: mock.Mock,
18241845
download_python: str,
18251846
make_one: Callable[..., tuple[VirtualEnv, Path]],
18261847
patch_discover: Callable[[str | None], list[str]],
18271848
) -> None:
1828-
# An upper-bound-only range has no installable floor: never install, error.
1849+
# An upper-bound-only range installs the highest version it allows.
18291850
patch_discover(None)
18301851
venv, _ = make_one(interpreter="<3.14", download_python=download_python)
18311852

1853+
assert (
1854+
venv._resolved_interpreter == "/.local/share/nox/cpython@3.13.0/bin/python3.13"
1855+
)
1856+
pbs_install_mock.assert_called_once_with("python3.13")
1857+
uv_install_mock.assert_not_called()
1858+
1859+
1860+
@pytest.mark.parametrize("download_python", ["always", "auto"])
1861+
@mock.patch("nox.virtualenv.pbs_install_python")
1862+
@mock.patch("nox.virtualenv.uv_install_python")
1863+
def test_download_python_range_without_bounds(
1864+
uv_install_mock: mock.Mock,
1865+
pbs_install_mock: mock.Mock,
1866+
download_python: str,
1867+
make_one: Callable[..., tuple[VirtualEnv, Path]],
1868+
patch_discover: Callable[[str | None], list[str]],
1869+
) -> None:
1870+
# An exclusion-only range has no version to install: never install, error.
1871+
patch_discover(None)
1872+
venv, _ = make_one(interpreter="!=3.14", download_python=download_python)
1873+
18321874
with pytest.raises(nox.virtualenv.InterpreterNotFound):
18331875
_ = venv._resolved_interpreter
18341876

0 commit comments

Comments
 (0)