Skip to content

Commit df43085

Browse files
saucoidehenryiii
andauthored
feat: add --download-python python option (#989)
* Add --download-python python option Adding a --download-python / NOX_DOWNLOAD_PYTHON option to control the behavior of uv installing python, and introducing nox-installed pythons via pbs-installer for venv/virtualenv backends The options are "auto" (default), "always" and "never". - "auto" will first check for a pre-existing installation, and fall back to installing with pbs-installer (or uv in uv venv_backends) - "always" will ignore system pythons and always try to install - "never" will only use pre-existing python installs * add kwargs to condaenv * change removeprefix since its not supported in 3.8 * linting * pass 'never' to expected failure on windows * allow none * handle windows install layout * fix windows test * fix buggy version matching * make mypy happy * silence http loggers * add new dependencies to the cli test * rearrange to ensure windows is already fully checked before installing * remove todos * do not let the test download python * which is called multiple times on windows * add documentation * whitespace * require pbs_installer during conda tests Otherwise it fails to collect tests due to the import * undo * rename package * reorder * add httpx to conda test reqs * add test to cover successful install finds new python * test the success cases * patch uv too * test bad version & failed install for uv * try to clarify tests * fix tests * test the auto case in the failed_install test * remove redudant test * Update docs/config.rst --------- Co-authored-by: Henry Schreiner <HenrySchreinerIII@gmail.com>
1 parent c804a04 commit df43085

16 files changed

Lines changed: 528 additions & 49 deletions

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ repos:
4545
- jinja2
4646
- orjson # Faster mypy
4747
- packaging
48+
- pbs_installer
4849
- pytest
4950
- importlib_metadata
5051
- importlib_resources

docs/config.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ When you provide a version number, Nox automatically prepends python to determin
125125
def tests(session):
126126
pass
127127
128+
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.
129+
128130
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:
129131

130132
.. code-block:: python
@@ -511,6 +513,7 @@ The following options can be specified in the Noxfile:
511513
* ``nox.options.stop_on_first_error`` is equivalent to specifying :ref:`--stop-on-first-error <opt-stop-on-first-error>`. You can force this off by specifying ``--no-stop-on-first-error`` during invocation.
512514
* ``nox.options.error_on_missing_interpreters`` is equivalent to specifying :ref:`--error-on-missing-interpreters <opt-error-on-missing-interpreters>`. You can force this off by specifying ``--no-error-on-missing-interpreters`` during invocation.
513515
* ``nox.options.error_on_external_run`` is equivalent to specifying :ref:`--error-on-external-run <opt-error-on-external-run>`. You can force this off by specifying ``--no-error-on-external-run`` during invocation.
516+
* ``nox.options.download_python`` is equivalent to specifying ``--download-python``.
514517
* ``nox.options.report`` is equivalent to specifying :ref:`--report <opt-report>`.
515518
* ``nox.options.verbose`` is equivalent to specifying :ref:`-v or --verbose <opt-verbose>`. You can force this off by specifying ``--no-verbose`` during invocation.
516519

docs/usage.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,21 @@ using the ``python`` specified for the current ``PATH``:
286286

287287
NOXFORCEPYTHON=python NOXSESSION=lint nox
288288

289+
Downloading Python interpreters
290+
-------------------------------
291+
292+
Nox can download Python interpreters, either via uv or directly from
293+
python-build-standalone, by using ``--download-python``:
294+
295+
.. code-block:: console
296+
297+
nox --download-python auto # Download if interpreter not found (default)
298+
nox --download-python never # Never download interpreters
299+
nox --download-python always # Always download interpreters
300+
301+
You can also set this option with the ``NOX_DOWNLOAD_PYTHON`` environment
302+
variable.
303+
289304
.. _opt-stop-on-first-error:
290305

291306
Stopping if any session fails

nox/_cli.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
import sys
2424
import urllib.parse
2525
from pathlib import Path
26-
from typing import TYPE_CHECKING, Any, NoReturn
26+
from typing import TYPE_CHECKING, Any, Literal, NoReturn, cast
2727

2828
import packaging.requirements
2929
import packaging.utils
@@ -32,7 +32,7 @@
3232
import nox.virtualenv
3333
from nox import _options, tasks, workflow
3434
from nox._version import get_nox_version
35-
from nox.logger import setup_logging
35+
from nox.logger import logger, setup_logging
3636
from nox.project import load_toml
3737

3838
if TYPE_CHECKING:
@@ -137,12 +137,18 @@ def check_url_dependency(dep_url: str, dist: importlib.metadata.Distribution) ->
137137

138138

139139
def run_script_mode(
140-
envdir: Path, *, reuse: bool, dependencies: list[str], venv_backend: str
140+
envdir: Path,
141+
*,
142+
reuse: bool,
143+
dependencies: list[str],
144+
venv_backend: str,
145+
download_python: Literal["auto", "never", "always"],
141146
) -> NoReturn:
142147
envdir.mkdir(exist_ok=True)
143148
noxenv = envdir.joinpath("_nox_script_mode")
144149
venv = nox.virtualenv.get_virtualenv(
145150
*venv_backend.split("|"),
151+
download_python=download_python,
146152
reuse_existing=reuse,
147153
envdir=str(noxenv),
148154
)
@@ -208,12 +214,32 @@ def main() -> None:
208214
)
209215
)
210216

217+
download_python = (
218+
os.environ.get("NOX_SCRIPT_DOWNLOAD_PYTHON")
219+
or (
220+
toml_config.get("tool", {})
221+
.get("nox", {})
222+
.get("script-download-python", "auto")
223+
)
224+
or args.download_python
225+
)
226+
227+
if download_python not in ("auto", "never", "always"):
228+
logger.warning(
229+
f"Invalid parameter for {download_python=}. Defaulting to 'auto'"
230+
)
231+
download_python = "auto"
232+
download_python = cast(
233+
"Literal['auto', 'never', 'always']", download_python
234+
)
235+
211236
envdir = Path(args.envdir or ".nox")
212237
run_script_mode(
213238
envdir,
214239
reuse=nox_script_mode == "reuse",
215240
dependencies=dependencies,
216241
venv_backend=venv_backend,
242+
download_python=download_python,
217243
)
218244

219245
exit_code = execute_workflow(args)

nox/_decorators.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import functools
1919
import inspect
2020
import types
21-
from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
21+
from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar, cast
2222

2323
if TYPE_CHECKING:
2424
from collections.abc import Iterable, Mapping, Sequence
@@ -78,6 +78,7 @@ def __init__(
7878
*,
7979
default: bool = True,
8080
requires: Sequence[str] | None = None,
81+
download_python: Literal["auto", "never", "always"] | None = None,
8182
) -> None:
8283
self.func = func
8384
self.python = python
@@ -89,6 +90,7 @@ def __init__(
8990
self.tags = list(tags or [])
9091
self.default = default
9192
self.requires = list(requires or [])
93+
self.download_python = download_python
9294

9395
def __repr__(self) -> str:
9496
return f"{self.__class__.__name__}(name={self.name!r})"
@@ -110,6 +112,7 @@ def copy(self, name: str | None = None) -> Func:
110112
self.tags,
111113
default=self.default,
112114
requires=self._requires,
115+
download_python=self.download_python,
113116
)
114117

115118
@property
@@ -165,6 +168,7 @@ def __init__(self, func: Func, param_spec: Param) -> None:
165168
func.tags + param_spec.tags,
166169
default=func.default,
167170
requires=func.requires,
171+
download_python=func.download_python,
168172
)
169173
self.call_spec = call_spec
170174
self.session_signature = session_signature

nox/_option_set.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ def __dir__() -> list[str]:
6161
@attrs.define(slots=True, kw_only=True)
6262
class NoxOptions:
6363
default_venv_backend: None | str = attrs.field(validator=av_opt_str)
64+
download_python: None | Literal["auto", "never", "always"] = attrs.field(
65+
default=None, validator=av.optional(av.in_(["auto", "never", "always"]))
66+
)
6467
envdir: None | str | os.PathLike[str] = attrs.field(validator=av_opt_path)
6568
error_on_external_run: bool = attrs.field(validator=av_bool)
6669
error_on_missing_interpreters: bool = attrs.field(validator=av_bool)

nox/_options.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,20 @@ def _tag_completer(
527527
help="Directory where Nox will store virtualenvs, this is ``.nox`` by default.",
528528
completer=argcomplete.completers.DirectoriesCompleter(), # type: ignore[no-untyped-call]
529529
),
530+
_option_set.Option(
531+
"download_python",
532+
"--download-python",
533+
"--download-python",
534+
noxfile=True,
535+
group=options.groups["python"],
536+
default=lambda: os.getenv("NOX_DOWNLOAD_PYTHON"),
537+
help=(
538+
"When should nox download python standalone builds to run the sessions,"
539+
" defaults to 'auto' which will download when the version requested can't"
540+
" be found in the running environment."
541+
),
542+
choices=["auto", "never", "always"],
543+
),
530544
_option_set.Option(
531545
"extra_pythons",
532546
"--extra-pythons",

nox/logger.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,5 @@ def setup_logging(
151151

152152
# Silence noisy loggers
153153
logging.getLogger("sh").setLevel(logging.WARNING)
154+
logging.getLogger("httpx").setLevel(logging.WARNING)
155+
logging.getLogger("httpcore").setLevel(logging.WARNING)

nox/registry.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import copy
1818
import functools
19-
from typing import TYPE_CHECKING, Any, Callable, overload
19+
from typing import TYPE_CHECKING, Any, Callable, Literal, overload
2020

2121
from ._decorators import Func
2222

@@ -55,6 +55,7 @@ def session_decorator(
5555
*,
5656
default: bool = ...,
5757
requires: Sequence[str] | None = ...,
58+
download_python: Literal["auto", "never", "always"] | None = None,
5859
) -> Callable[[RawFunc | Func], Func]: ...
5960

6061

@@ -71,6 +72,7 @@ def session_decorator(
7172
*,
7273
default: bool = True,
7374
requires: Sequence[str] | None = None,
75+
download_python: Literal["auto", "never", "always"] | None = None,
7476
) -> Func | Callable[[RawFunc | Func], Func]:
7577
"""Designate the decorated function as a session."""
7678
# If `func` is provided, then this is the decorator call with the function
@@ -92,6 +94,7 @@ def session_decorator(
9294
tags=tags,
9395
default=default,
9496
requires=requires,
97+
download_python=download_python,
9598
)
9699

97100
if py is not None and python is not None:
@@ -116,6 +119,7 @@ def session_decorator(
116119
tags=tags,
117120
default=default,
118121
requires=requires,
122+
download_python=download_python,
119123
)
120124
_REGISTRY[name or func.__name__] = fn
121125
return fn

nox/sessions.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1017,10 +1017,15 @@ def _create_venv(self) -> None:
10171017
or "virtualenv"
10181018
).split("|")
10191019

1020+
download_python = (
1021+
self.global_config.download_python or self.func.download_python or "auto"
1022+
)
1023+
10201024
self.venv = get_virtualenv(
10211025
*backends,
1022-
reuse_existing=reuse_existing,
1026+
download_python=download_python,
10231027
envdir=self.envdir,
1028+
reuse_existing=reuse_existing,
10241029
interpreter=self.func.python,
10251030
venv_params=self.func.venv_params,
10261031
)

0 commit comments

Comments
 (0)