Skip to content

Commit cd89d34

Browse files
committed
feat: add noxfile checks
Signed-off-by: Henry Schreiner <[email protected]>
1 parent f8521ac commit cd89d34

File tree

5 files changed

+445
-7
lines changed

5 files changed

+445
-7
lines changed

docs/pages/guides/tasks.md

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,9 @@ This supports setting up a quick server as well, run like this:
260260
$ nox -s docs -- --serve
261261
```
262262

263+
Notice that we set `default=False` so that docs are not built every time nox is
264+
run without arguments. {% rr NOX103 %}
265+
263266
#### Build (pure Python)
264267

265268
For pure Python packages, this could be useful:
@@ -304,7 +307,8 @@ def build(session: nox.Session) -> None:
304307

305308
The [uv](https://github.com/astral-sh/uv) project is a Rust reimplementation of
306309
pip, pip-tools, and venv that is very, very fast. You can tell nox to use `uv`
307-
if it is on your system by adding the following to your `noxfile.py`:
310+
if it is on your system by adding the following to your `noxfile.py`
311+
{% rr NOX102 %}:
308312

309313
```python
310314
nox.needs_version = ">=2024.3.2"
@@ -322,7 +326,39 @@ Check your jobs with `uv`; most things do not need to change. The main
322326
difference is `uv` doesn't install `pip` unless you ask it to. If you want to
323327
interact with uv, nox might be getting uv from it's environment instead of the
324328
system environment, so you can install `uv` if `shutil.which("uv")` returns
325-
`None`.
329+
`None`. You should also set a minimum version of nox. {% rr NOX101 %}
330+
331+
### Running without nox or requiring dependencies
332+
333+
Nox also allows you to use the script block, both for running with runners (like
334+
`uv run` and `pipx run`), and for specifying dependencies to install or a
335+
minimum nox version; nox will read this block and run itself from a venv with
336+
those requirements installed if they are not met.
337+
338+
```python
339+
#!/usr/bin/env -S uv run --script
340+
341+
# /// script
342+
# dependencies = ["nox>=2025.10.16"]
343+
# ///
344+
345+
import nox
346+
347+
nox.needs_version = ">=2025.10.16"
348+
349+
if __name__ == "__main__":
350+
nox.main()
351+
```
352+
353+
The script block then specifies that nox is required {% rr NOX201 %}. If you
354+
want other dependencies here, those will also be installed before the file is
355+
run. Older versions of nox still need the `nox.needs_version` line to keep nice
356+
error messages.
357+
358+
The first line is a shebang line {% rr NOX202 %}, which allows this file to be
359+
run directly if it is made executable. You can put any runner here; uv is shown.
360+
You also need a main block {% rr NOX203 %} to allow nox to be run when this file
361+
is executed directly.
326362

327363
### Examples
328364

noxfile.py

100644100755
Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
#!/usr/bin/env -S uv run --script
2+
3+
# /// script
4+
# dependencies = ["nox>=2025.2.9"]
5+
# ///
6+
17
"""
28
Nox runner for cookie & sp-repo-review.
39
@@ -26,7 +32,6 @@
2632
import nox
2733

2834
nox.needs_version = ">=2025.2.9"
29-
nox.options.sessions = ["rr_lint", "rr_tests", "rr_pylint", "readme"]
3035
nox.options.default_venv_backend = "uv|virtualenv"
3136

3237

@@ -58,7 +63,7 @@ def get_expected_version(backend: str, vcs: bool) -> str:
5863
return "0.2.3" if vcs and backend not in {"maturin", "mesonpy", "uv"} else "0.1.0"
5964

6065

61-
def make_copier(session: nox.Session, backend: str, vcs: bool) -> None:
66+
def make_copier(session: nox.Session, backend: str, vcs: bool) -> Path:
6267
package_dir = Path(f"copy-{backend}")
6368
if package_dir.exists():
6469
rmtree_ro(package_dir)
@@ -85,7 +90,7 @@ def make_copier(session: nox.Session, backend: str, vcs: bool) -> None:
8590
return package_dir
8691

8792

88-
def make_cookie(session: nox.Session, backend: str, vcs: bool) -> None:
93+
def make_cookie(session: nox.Session, backend: str, vcs: bool) -> Path:
8994
package_dir = Path(f"cookie-{backend}")
9095
if package_dir.exists():
9196
rmtree_ro(package_dir)
@@ -106,7 +111,7 @@ def make_cookie(session: nox.Session, backend: str, vcs: bool) -> None:
106111
return package_dir
107112

108113

109-
def make_cruft(session: nox.Session, backend: str, vcs: bool) -> None:
114+
def make_cruft(session: nox.Session, backend: str, vcs: bool) -> Path:
110115
package_dir = Path(f"cruft-{backend}")
111116
if package_dir.exists():
112117
rmtree_ro(package_dir)
@@ -156,7 +161,7 @@ def init_git(session: nox.Session, package_dir: Path) -> None:
156161
IGNORE_FILES = {"__pycache__", ".git", ".copier-answers.yml", ".cruft.json"}
157162

158163

159-
def valid_path(path: Path):
164+
def valid_path(path: Path) -> bool:
160165
return path.is_file() and not IGNORE_FILES & set(path.parts)
161166

162167

@@ -548,3 +553,7 @@ def rr_build(session: nox.Session) -> None:
548553

549554
session.install("build")
550555
session.run("python", "-m", "build")
556+
557+
558+
if __name__ == "__main__":
559+
nox.main()

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ mypy = "sp_repo_review.checks.mypy:repo_review_checks"
6666
github = "sp_repo_review.checks.github:repo_review_checks"
6767
readthedocs = "sp_repo_review.checks.readthedocs:repo_review_checks"
6868
setupcfg = "sp_repo_review.checks.setupcfg:repo_review_checks"
69+
noxfile = "sp_repo_review.checks.noxfile:repo_review_checks"
6970

7071
[project.entry-points."repo_review.fixtures"]
7172
workflows = "sp_repo_review.checks.github:workflows"
@@ -74,6 +75,7 @@ precommit = "sp_repo_review.checks.precommit:precommit"
7475
readthedocs = "sp_repo_review.checks.readthedocs:readthedocs"
7576
ruff = "sp_repo_review.checks.ruff:ruff"
7677
setupcfg = "sp_repo_review.checks.setupcfg:setupcfg"
78+
noxfile = "sp_repo_review.checks.noxfile:noxfile"
7779

7880
[project.entry-points."repo_review.families"]
7981
scikit-hep = "sp_repo_review.families:get_families"
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
# NOX: Noxfile checks
2+
## NOXxxx: noxfile checks
3+
4+
from __future__ import annotations
5+
6+
import ast
7+
import dataclasses
8+
import re
9+
from typing import Any
10+
11+
from .._compat import tomllib
12+
from .._compat.importlib.resources.abc import Traversable
13+
from . import mk_url
14+
15+
REGEX = re.compile(
16+
r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$"
17+
)
18+
19+
20+
@dataclasses.dataclass(frozen=True, kw_only=True, slots=True, eq=False)
21+
class NoxfileInfo:
22+
module: ast.Module
23+
shebang: str
24+
script: dict[str, Any]
25+
26+
__hash__ = None # type: ignore[assignment]
27+
28+
@classmethod
29+
def from_str(cls, content: str) -> NoxfileInfo:
30+
module = ast.parse(content, filename="noxfile.py")
31+
shebang_match = re.match(r"^#!.*\n", content)
32+
shebang = shebang_match.group(0).strip() if shebang_match else ""
33+
script = _load_script_block(content)
34+
return cls(module=module, shebang=shebang, script=script)
35+
36+
def __eq__(self, other: object) -> bool:
37+
if not isinstance(other, NoxfileInfo):
38+
return NotImplemented
39+
40+
ast_equal = ast.dump(self.module, include_attributes=True) == ast.dump(
41+
other.module, include_attributes=True
42+
)
43+
return (
44+
self.shebang == other.shebang and self.script == other.script and ast_equal
45+
)
46+
47+
48+
def _load_script_block(content: str, /) -> dict[str, Any]:
49+
name = "script"
50+
matches = list(filter(lambda m: m.group("type") == name, REGEX.finditer(content)))
51+
52+
if not matches:
53+
return {}
54+
55+
if len(matches) > 1:
56+
msg = f"Multiple {name} blocks found"
57+
raise ValueError(msg)
58+
59+
content = "".join(
60+
line[2:] if line.startswith("# ") else line[1:]
61+
for line in matches[0].group("content").splitlines(keepends=True)
62+
)
63+
return tomllib.loads(content)
64+
65+
66+
def noxfile(root: Traversable) -> NoxfileInfo | None:
67+
"""
68+
Returns the shabang line (or empty string if missing), the noxfile script block, and the AST of the noxfile.py.
69+
Returns None if noxfile.py is not present.
70+
"""
71+
72+
noxfile_path = root.joinpath("noxfile.py")
73+
if not noxfile_path.is_file():
74+
return None
75+
76+
with noxfile_path.open("r", encoding="utf-8") as f:
77+
return NoxfileInfo.from_str(f.read())
78+
79+
80+
class Noxfile:
81+
family = "noxfile"
82+
requires = {"PY007"}
83+
url = mk_url("tasks")
84+
85+
86+
class NOX101(Noxfile):
87+
"Sets minimum nox version"
88+
89+
@staticmethod
90+
def check(noxfile: NoxfileInfo | None) -> bool | None:
91+
"""Set a minimum nox version:
92+
93+
```python
94+
nox.needs_version = "2025.10.14"
95+
```
96+
"""
97+
98+
if noxfile is None:
99+
return None
100+
101+
for statement in noxfile.module.body:
102+
if isinstance(statement, ast.Assign):
103+
for target in statement.targets:
104+
match target:
105+
case ast.Attribute(
106+
value=ast.Name(id="nox"), attr="needs_version"
107+
):
108+
return True
109+
110+
return False
111+
112+
113+
class NOX102(Noxfile):
114+
"Sets venv backend"
115+
116+
@staticmethod
117+
def check(noxfile: NoxfileInfo | None | None) -> bool | None:
118+
"""
119+
The default venv backend should be set, ideally to `uv|virtualenv`:
120+
121+
```python
122+
nox.options.default_venv_backend = "uv|virtualenv"
123+
```
124+
"""
125+
if noxfile is None:
126+
return None
127+
128+
for statement in noxfile.module.body:
129+
if isinstance(statement, ast.Assign):
130+
for target in statement.targets:
131+
match target:
132+
case ast.Attribute(
133+
value=ast.Attribute(
134+
value=ast.Name(id="nox"), attr="options"
135+
),
136+
attr="default_venv_backend",
137+
):
138+
return True
139+
140+
return False
141+
142+
143+
class NOX103(Noxfile):
144+
"Set default per session instead of session list"
145+
146+
@staticmethod
147+
def check(noxfile: NoxfileInfo | None) -> bool | None:
148+
"""
149+
You should use `default=` in each session instead of setting a global list.
150+
"""
151+
if noxfile is None:
152+
return None
153+
154+
for statement in noxfile.module.body:
155+
if isinstance(statement, ast.Assign):
156+
for target in statement.targets:
157+
match target:
158+
case ast.Attribute(
159+
value=ast.Attribute(
160+
value=ast.Name(id="nox"), attr="options"
161+
),
162+
attr="sessions",
163+
):
164+
return False
165+
166+
return True
167+
168+
169+
class NOX201(Noxfile):
170+
"Set a script block with dependencies in your noxfile"
171+
172+
@staticmethod
173+
def check(noxfile: NoxfileInfo | None) -> bool | None:
174+
"""
175+
You should have a script block with nox in it, for example:
176+
177+
```toml
178+
# /// script
179+
dependencies = ["nox"]
180+
# ///
181+
```
182+
"""
183+
if noxfile is None:
184+
return None
185+
match noxfile.script:
186+
case {"dependencies": list()}:
187+
return True
188+
return False
189+
190+
191+
class NOX202(Noxfile):
192+
"Has a shebang line"
193+
194+
@staticmethod
195+
def check(noxfile: NoxfileInfo | None) -> bool | None:
196+
"""
197+
You should have a shabang line at the top of your noxfile.py, for example:
198+
199+
```python
200+
#!/usr/bin/env -S uv run --script
201+
```
202+
"""
203+
if noxfile is None:
204+
return None
205+
return bool(noxfile.shebang)
206+
207+
208+
class NOX203(Noxfile):
209+
"Provide a main block to run nox"
210+
211+
@staticmethod
212+
def check(noxfile: NoxfileInfo | None) -> bool | None:
213+
"""
214+
You should have a main block at the bottom of your noxfile.py, for example:
215+
216+
```python
217+
if __name__ == "__main__":
218+
nox.main()
219+
```
220+
"""
221+
if noxfile is None:
222+
return None
223+
224+
for statement in noxfile.module.body:
225+
if isinstance(statement, ast.If):
226+
match statement.test:
227+
case ast.Compare(
228+
left=ast.Name(id="__name__"),
229+
ops=[ast.Eq()],
230+
comparators=[ast.Constant(value="__main__")],
231+
):
232+
return True
233+
234+
return False
235+
236+
237+
def repo_review_checks(
238+
list_all: bool = True, noxfile: tuple[str, dict[str, Any], ast.Module] | None = None
239+
) -> dict[str, Noxfile]:
240+
if not list_all and noxfile is None:
241+
return {}
242+
return {p.__name__: p() for p in Noxfile.__subclasses__()}

0 commit comments

Comments
 (0)