Skip to content

Commit beab6c3

Browse files
authored
feat: support PEP 723 noxfiles (#881)
* feat: support PEP 723 directly fix: version based on pipx fix: subprocess on Windows fix: uv or virtualenv, test fix: windows fix: ignore errors in rmtree (3.8+ should be fine on Windows now) fix: resolve nox path on Windows chore: update for recent linting/typing additions Signed-off-by: Henry Schreiner <[email protected]> * feat: support setting the script backend Signed-off-by: Henry Schreiner <[email protected]> --------- Signed-off-by: Henry Schreiner <[email protected]>
1 parent 73534d2 commit beab6c3

8 files changed

+319
-10
lines changed

docs/tutorial.rst

+11-2
Original file line numberDiff line numberDiff line change
@@ -597,8 +597,8 @@ the tags, so all three sessions:
597597
* flake8
598598
599599
600-
Running without the nox command
601-
-------------------------------
600+
Running without the nox command or adding dependencies
601+
------------------------------------------------------
602602

603603
With a few small additions to your noxfile, you can support running using only
604604
a generalized Python runner, such as ``pipx run noxfile.py``, ``uv run
@@ -618,6 +618,15 @@ And the following block of code:
618618
if __name__ == "__main__":
619619
nox.main()
620620
621+
If this comment block is present, nox will also read it, and run a custom
622+
environment (``_nox_script_mode``) if the dependencies are not met in the
623+
current environment. This allows you to specify dependencies for your noxfile
624+
or a minimum version of nox here (``requires-python`` version setting not
625+
supported yet, but planned). You can control this with
626+
``--script-mode``/``NOX_SCRIPT_MODE``; ``none`` will deactivate it, and
627+
``fresh`` will rebuild it; the default is ``reuse``. You can also set
628+
``--script-venv-backend``/``tool.nox.script-venv-backend``/``NOX_SCRIPT_VENV_BACKEND``
629+
to control the backend used; the default is ``"uv|virtualenv"``.
621630

622631
Next steps
623632
----------

nox/_cli.py

+125-1
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,26 @@
1616

1717
from __future__ import annotations
1818

19+
import importlib.metadata
20+
import os
21+
import shutil
22+
import subprocess
1923
import sys
20-
from typing import Any
24+
from pathlib import Path
25+
from typing import TYPE_CHECKING, Any, NoReturn
2126

27+
import packaging.requirements
28+
import packaging.utils
29+
30+
import nox.command
31+
import nox.virtualenv
2232
from nox import _options, tasks, workflow
2333
from nox._version import get_nox_version
2434
from nox.logger import setup_logging
35+
from nox.project import load_toml
36+
37+
if TYPE_CHECKING:
38+
from collections.abc import Generator
2539

2640
__all__ = ["execute_workflow", "main"]
2741

@@ -51,6 +65,88 @@ def execute_workflow(args: Any) -> int:
5165
)
5266

5367

68+
def get_dependencies(
69+
req: packaging.requirements.Requirement,
70+
) -> Generator[packaging.requirements.Requirement, None, None]:
71+
"""
72+
Gets all dependencies. Raises ModuleNotFoundError if a package is not installed.
73+
"""
74+
info = importlib.metadata.metadata(req.name)
75+
yield req
76+
77+
dist_list = info.get_all("requires-dist") or []
78+
extra_list = [packaging.requirements.Requirement(mk) for mk in dist_list]
79+
for extra in req.extras:
80+
for ireq in extra_list:
81+
if ireq.marker and not ireq.marker.evaluate({"extra": extra}):
82+
continue
83+
yield from get_dependencies(ireq)
84+
85+
86+
def check_dependencies(dependencies: list[str]) -> bool:
87+
"""
88+
Checks to see if a list of dependencies is currently installed.
89+
"""
90+
itr_deps = (packaging.requirements.Requirement(d) for d in dependencies)
91+
deps = [d for d in itr_deps if not d.marker or d.marker.evaluate()]
92+
93+
# Select the one nox dependency (required)
94+
nox_dep = [d for d in deps if packaging.utils.canonicalize_name(d.name) == "nox"]
95+
if not nox_dep:
96+
msg = "Must have a nox dependency in TOML script dependencies"
97+
raise ValueError(msg)
98+
99+
try:
100+
expanded_deps = {d for req in deps for d in get_dependencies(req)}
101+
except ModuleNotFoundError:
102+
return False
103+
104+
for dep in expanded_deps:
105+
if dep.specifier:
106+
version = importlib.metadata.version(dep.name)
107+
if not dep.specifier.contains(version):
108+
return False
109+
110+
return True
111+
112+
113+
def run_script_mode(
114+
envdir: Path, *, reuse: bool, dependencies: list[str], venv_backend: str
115+
) -> NoReturn:
116+
envdir.mkdir(exist_ok=True)
117+
noxenv = envdir.joinpath("_nox_script_mode")
118+
venv = nox.virtualenv.get_virtualenv(
119+
*venv_backend.split("|"),
120+
reuse_existing=reuse,
121+
envdir=str(noxenv),
122+
)
123+
venv.create()
124+
env = {k: v for k, v in venv._get_env({}).items() if v is not None}
125+
env["NOX_SCRIPT_MODE"] = "none"
126+
cmd = (
127+
[nox.virtualenv.UV, "pip", "install"]
128+
if venv.venv_backend == "uv"
129+
else ["pip", "install"]
130+
)
131+
subprocess.run([*cmd, *dependencies], env=env, check=True)
132+
nox_cmd = shutil.which("nox", path=env["PATH"])
133+
assert nox_cmd is not None, "Nox must be discoverable when installed"
134+
# The os.exec functions don't work properly on Windows
135+
if sys.platform.startswith("win"):
136+
raise SystemExit(
137+
subprocess.run(
138+
[nox_cmd, *sys.argv[1:]],
139+
env=env,
140+
stdout=None,
141+
stderr=None,
142+
encoding="utf-8",
143+
text=True,
144+
check=False,
145+
).returncode
146+
)
147+
os.execle(nox_cmd, nox_cmd, *sys.argv[1:], env) # pragma: nocover # noqa: S606
148+
149+
54150
def main() -> None:
55151
args = _options.options.parse_args()
56152

@@ -65,6 +161,34 @@ def main() -> None:
65161
setup_logging(
66162
color=args.color, verbose=args.verbose, add_timestamp=args.add_timestamp
67163
)
164+
nox_script_mode = os.environ.get("NOX_SCRIPT_MODE", "") or args.script_mode
165+
if nox_script_mode not in {"none", "reuse", "fresh"}:
166+
msg = f"Invalid NOX_SCRIPT_MODE: {nox_script_mode!r}, must be one of 'none', 'reuse', or 'fresh'"
167+
raise SystemExit(msg)
168+
if nox_script_mode != "none":
169+
toml_config = load_toml(os.path.expandvars(args.noxfile), missing_ok=True)
170+
dependencies = toml_config.get("dependencies")
171+
if dependencies is not None:
172+
valid_env = check_dependencies(dependencies)
173+
# Coverage misses this, but it's covered via subprocess call
174+
if not valid_env: # pragma: nocover
175+
venv_backend = (
176+
os.environ.get("NOX_SCRIPT_VENV_BACKEND")
177+
or args.script_venv_backend
178+
or (
179+
toml_config.get("tool", {})
180+
.get("nox", {})
181+
.get("script-venv-backend", "uv|virtualenv")
182+
)
183+
)
184+
185+
envdir = Path(args.envdir or ".nox")
186+
run_script_mode(
187+
envdir,
188+
reuse=nox_script_mode == "reuse",
189+
dependencies=dependencies,
190+
venv_backend=venv_backend,
191+
)
68192

69193
exit_code = execute_workflow(args)
70194

nox/_options.py

+12
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,18 @@ def _tag_completer(
338338
action="store_true",
339339
help="Show the Nox version and exit.",
340340
),
341+
_option_set.Option(
342+
"script_mode",
343+
"--script-mode",
344+
group=options.groups["general"],
345+
choices=["none", "fresh", "reuse"],
346+
default="reuse",
347+
),
348+
_option_set.Option(
349+
"script_venv_backend",
350+
"--script-venv-backend",
351+
group=options.groups["general"],
352+
),
341353
_option_set.Option(
342354
"list_sessions",
343355
"-l",

nox/project.py

+10-3
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,19 @@ def __dir__() -> list[str]:
3434
)
3535

3636

37-
def load_toml(filename: os.PathLike[str] | str) -> dict[str, Any]:
37+
def load_toml(
38+
filename: os.PathLike[str] | str, *, missing_ok: bool = False
39+
) -> dict[str, Any]:
3840
"""
3941
Load a toml file or a script with a PEP 723 script block.
4042
4143
The file must have a ``.toml`` extension to be considered a toml file or a
4244
``.py`` extension / no extension to be considered a script. Other file
4345
extensions are not valid in this function.
4446
47+
If ``missing_ok``, this will return an empty dict if a script block was not
48+
found, otherwise it will raise a error.
49+
4550
Example:
4651
4752
.. code-block:: python
@@ -55,7 +60,7 @@ def myscript(session):
5560
if filepath.suffix == ".toml":
5661
return _load_toml_file(filepath)
5762
if filepath.suffix in {".py", ""}:
58-
return _load_script_block(filepath)
63+
return _load_script_block(filepath, missing_ok=missing_ok)
5964
msg = f"Extension must be .py or .toml, got {filepath.suffix}"
6065
raise ValueError(msg)
6166

@@ -65,12 +70,14 @@ def _load_toml_file(filepath: Path) -> dict[str, Any]:
6570
return tomllib.load(f)
6671

6772

68-
def _load_script_block(filepath: Path) -> dict[str, Any]:
73+
def _load_script_block(filepath: Path, *, missing_ok: bool) -> dict[str, Any]:
6974
name = "script"
7075
script = filepath.read_text(encoding="utf-8")
7176
matches = list(filter(lambda m: m.group("type") == name, REGEX.finditer(script)))
7277

7378
if not matches:
79+
if missing_ok:
80+
return {}
7481
msg = f"No {name} block found in {filepath}"
7582
raise ValueError(msg)
7683
if len(matches) > 1:

nox/virtualenv.py

+3-4
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ def _clean_location(self) -> bool:
353353
if self.reuse_existing and is_conda:
354354
return False
355355
if not is_conda:
356-
shutil.rmtree(self.location)
356+
shutil.rmtree(self.location, ignore_errors=True)
357357
else:
358358
cmd = [
359359
self.conda_cmd,
@@ -365,8 +365,7 @@ def _clean_location(self) -> bool:
365365
]
366366
nox.command.run(cmd, silent=True, log=False)
367367
# Make sure that location is clean
368-
with contextlib.suppress(FileNotFoundError):
369-
shutil.rmtree(self.location)
368+
shutil.rmtree(self.location, ignore_errors=True)
370369

371370
return True
372371

@@ -498,7 +497,7 @@ def _clean_location(self) -> bool:
498497
and self._check_reused_environment_interpreter()
499498
):
500499
return False
501-
shutil.rmtree(self.location)
500+
shutil.rmtree(self.location, ignore_errors=True)
502501
return True
503502

504503
def _read_pyvenv_cfg(self) -> dict[str, str] | None:
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# /// script
2+
# dependencies = ["nox", "cowsay"]
3+
# ///
4+
5+
import cowsay
6+
7+
import nox
8+
9+
10+
@nox.session
11+
def example(session: nox.Session) -> None:
12+
print(cowsay.cow("hello_world"))

tests/test__cli.py

+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import importlib.metadata
2+
import importlib.util
3+
import sys
4+
from pathlib import Path
5+
6+
import packaging.requirements
7+
import packaging.version
8+
import pytest
9+
10+
import nox._cli
11+
12+
13+
def test_get_dependencies() -> None:
14+
if importlib.util.find_spec("tox") is None:
15+
with pytest.raises(ModuleNotFoundError):
16+
list(
17+
nox._cli.get_dependencies(
18+
packaging.requirements.Requirement("nox[tox_to_nox]")
19+
)
20+
)
21+
else:
22+
deps = nox._cli.get_dependencies(
23+
packaging.requirements.Requirement("nox[tox_to_nox]")
24+
)
25+
dep_list = {
26+
"argcomplete",
27+
"attrs",
28+
"colorlog",
29+
"dependency-groups",
30+
"jinja2",
31+
"nox",
32+
"packaging",
33+
"tox",
34+
"virtualenv",
35+
}
36+
if sys.version_info < (3, 9):
37+
dep_list.add("importlib-resources")
38+
if sys.version_info < (3, 11):
39+
dep_list.add("tomli")
40+
assert {d.name for d in deps} == dep_list
41+
42+
43+
def test_version_check() -> None:
44+
current_version = packaging.version.Version(importlib.metadata.version("nox"))
45+
46+
assert nox._cli.check_dependencies([f"nox>={current_version}"])
47+
assert not nox._cli.check_dependencies([f"nox>{current_version}"])
48+
49+
plus_one = packaging.version.Version(
50+
f"{current_version.major}.{current_version.minor}.{current_version.micro + 1}"
51+
)
52+
assert not nox._cli.check_dependencies([f"nox>={plus_one}"])
53+
54+
55+
def test_nox_check() -> None:
56+
with pytest.raises(ValueError, match="Must have a nox"):
57+
nox._cli.check_dependencies(["packaging"])
58+
59+
with pytest.raises(ValueError, match="Must have a nox"):
60+
nox._cli.check_dependencies([])
61+
62+
63+
def test_unmatched_specifier() -> None:
64+
assert not nox._cli.check_dependencies(["packaging<1", "nox"])
65+
66+
67+
def test_invalid_mode(monkeypatch: pytest.MonkeyPatch) -> None:
68+
monkeypatch.setenv("NOX_SCRIPT_MODE", "invalid")
69+
monkeypatch.setattr(sys, "argv", ["nox"])
70+
71+
with pytest.raises(SystemExit, match="Invalid NOX_SCRIPT_MODE"):
72+
nox._cli.main()
73+
74+
75+
def test_invalid_backend_envvar(
76+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
77+
) -> None:
78+
monkeypatch.setenv("NOX_SCRIPT_VENV_BACKEND", "invalid")
79+
monkeypatch.setattr(sys, "argv", ["nox"])
80+
monkeypatch.chdir(tmp_path)
81+
tmp_path.joinpath("noxfile.py").write_text(
82+
"# /// script\n# dependencies=['nox', 'invalid']\n# ///",
83+
encoding="utf-8",
84+
)
85+
86+
with pytest.raises(ValueError, match="Expected venv_backend one of"):
87+
nox._cli.main()
88+
89+
90+
def test_invalid_backend_inline(
91+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
92+
) -> None:
93+
monkeypatch.setattr(sys, "argv", ["nox"])
94+
monkeypatch.chdir(tmp_path)
95+
tmp_path.joinpath("noxfile.py").write_text(
96+
"# /// script\n# dependencies=['nox', 'invalid']\n# tool.nox.script-venv-backend = 'invalid'\n# ///",
97+
encoding="utf-8",
98+
)
99+
100+
with pytest.raises(ValueError, match="Expected venv_backend one of"):
101+
nox._cli.main()

0 commit comments

Comments
 (0)