Skip to content

Commit d6e1906

Browse files
henryiiicjolowicz
andauthored
feat: support PEP 723 with a toml load function (#811)
* feat: support PEP 723 with a toml load function Signed-off-by: Henry Schreiner <[email protected]> * refactor: nox.toml.load Signed-off-by: Henry Schreiner <[email protected]> * tests: fix coverage for module dir's Signed-off-by: Henry Schreiner <[email protected]> * refactor: nox.project.load_toml Signed-off-by: Henry Schreiner <[email protected]> * Update requirements-test.txt Co-authored-by: Claudio Jolowicz <[email protected]> * Update requirements-test.txt --------- Signed-off-by: Henry Schreiner <[email protected]> Co-authored-by: Claudio Jolowicz <[email protected]>
1 parent d3dd1f8 commit d6e1906

8 files changed

+231
-4
lines changed

.pre-commit-config.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ repos:
4040
- jinja2
4141
- packaging
4242
- importlib_metadata
43+
- tomli
4344
- uv
4445

4546
- repo: https://github.com/codespell-project/codespell

docs/tutorial.rst

+40
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,46 @@ dependency (e.g. ``libfoo``) is available during installation:
169169
These commands will run even if you are only installing, and will not run if
170170
the environment is being reused without reinstallation.
171171

172+
173+
Loading dependencies from pyproject.toml or scripts
174+
---------------------------------------------------
175+
176+
One common need is loading a dependency list from a ``pyproject.toml`` file
177+
(say to prepare an environment without installing the package) or supporting
178+
`PEP 723 <https://peps.python.org/pep-0723>`_ scripts. Nox provides a helper to
179+
load these with ``nox.project.load_toml``. It can be passed a filepath to a toml
180+
file or to a script file following PEP 723. For example, if you have the
181+
following ``peps.py``:
182+
183+
184+
.. code-block:: python
185+
186+
# /// script
187+
# requires-python = ">=3.11"
188+
# dependencies = [
189+
# "requests<3",
190+
# "rich",
191+
# ]
192+
# ///
193+
194+
import requests
195+
from rich.pretty import pprint
196+
197+
resp = requests.get("https://peps.python.org/api/peps.json")
198+
data = resp.json()
199+
pprint([(k, v["title"]) for k, v in data.items()][:10])
200+
201+
You can make a session for it like this:
202+
203+
.. code-block:: python
204+
205+
@nox.session
206+
def peps(session):
207+
requirements = nox.project.load_toml("peps.py")["dependencies"]
208+
session.install(*requirements)
209+
session.run("peps.py")
210+
211+
172212
Running commands
173213
----------------
174214

nox/__init__.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from __future__ import annotations
1616

17+
from nox import project
1718
from nox._options import noxfile_options as options
1819
from nox._parametrize import Param as param
1920
from nox._parametrize import parametrize_decorator as parametrize
@@ -22,4 +23,12 @@
2223

2324
needs_version: str | None = None
2425

25-
__all__ = ["needs_version", "parametrize", "param", "session", "options", "Session"]
26+
__all__ = [
27+
"needs_version",
28+
"parametrize",
29+
"param",
30+
"session",
31+
"options",
32+
"Session",
33+
"project",
34+
]

nox/project.py

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import re
5+
import sys
6+
from pathlib import Path
7+
from typing import TYPE_CHECKING
8+
9+
if TYPE_CHECKING:
10+
from typing import Any
11+
12+
if sys.version_info < (3, 11):
13+
import tomli as tomllib
14+
else:
15+
import tomllib
16+
17+
18+
__all__ = ["load_toml"]
19+
20+
21+
def __dir__() -> list[str]:
22+
return __all__
23+
24+
25+
# Note: the implementation (including this regex) taken from PEP 723
26+
# https://peps.python.org/pep-0723
27+
28+
REGEX = re.compile(
29+
r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$"
30+
)
31+
32+
33+
def load_toml(filename: os.PathLike[str] | str) -> dict[str, Any]:
34+
"""
35+
Load a toml file or a script with a PEP 723 script block.
36+
37+
The file must have a ``.toml`` extension to be considered a toml file or a
38+
``.py`` extension / no extension to be considered a script. Other file
39+
extensions are not valid in this function.
40+
"""
41+
filepath = Path(filename)
42+
if filepath.suffix == ".toml":
43+
return _load_toml_file(filepath)
44+
if filepath.suffix in {".py", ""}:
45+
return _load_script_block(filepath)
46+
msg = f"Extension must be .py or .toml, got {filepath.suffix}"
47+
raise ValueError(msg)
48+
49+
50+
def _load_toml_file(filepath: Path) -> dict[str, Any]:
51+
with filepath.open("rb") as f:
52+
return tomllib.load(f)
53+
54+
55+
def _load_script_block(filepath: Path) -> dict[str, Any]:
56+
name = "script"
57+
script = filepath.read_text(encoding="utf-8")
58+
matches = list(filter(lambda m: m.group("type") == name, REGEX.finditer(script)))
59+
60+
if not matches:
61+
raise ValueError(f"No {name} block found in {filepath}")
62+
if len(matches) > 1:
63+
raise ValueError(f"Multiple {name} blocks found in {filepath}")
64+
65+
content = "".join(
66+
line[2:] if line.startswith("# ") else line[1:]
67+
for line in matches[0].group("content").splitlines(keepends=True)
68+
)
69+
return tomllib.loads(content)

noxfile.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def cover(session: nox.Session) -> None:
119119
if ON_WINDOWS_CI:
120120
return
121121

122-
session.install("coverage[toml]>=5.3")
122+
session.install("coverage[toml]>=7.3")
123123
session.run("coverage", "combine")
124124
session.run("coverage", "report", "--fail-under=100", "--show-missing")
125125
session.run("coverage", "erase")

pyproject.toml

+2-1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ dependencies = [
4444
"colorlog<7.0.0,>=2.6.1",
4545
'importlib-metadata; python_version < "3.8"',
4646
"packaging>=20.9",
47+
'tomli>=1; python_version < "3.11"',
4748
'typing-extensions>=3.7.4; python_version < "3.8"',
4849
"virtualenv>=20.14.1",
4950
]
@@ -107,7 +108,7 @@ relative_files = true
107108
source_pkgs = [ "nox" ]
108109

109110
[tool.coverage.report]
110-
exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", "@overload" ]
111+
exclude_also = [ "def __dir__()", "if TYPE_CHECKING:", "@overload" ]
111112

112113
[tool.mypy]
113114
files = [ "nox/**/*.py", "noxfile.py" ]

requirements-test.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
coverage[toml]>=5.3
1+
coverage[toml]>=7.2
22
flask
33
myst-parser
44
pytest>=6.0

tests/test_toml.py

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import textwrap
2+
from pathlib import Path
3+
4+
import pytest
5+
6+
import nox
7+
8+
9+
def test_load_pyproject(tmp_path: Path) -> None:
10+
filepath = tmp_path / "example.toml"
11+
filepath.write_text(
12+
"""
13+
[project]
14+
name = "hi"
15+
version = "1.0"
16+
dependencies = ["numpy", "requests"]
17+
"""
18+
)
19+
20+
toml = nox.project.load_toml(filepath)
21+
assert toml["project"]["dependencies"] == ["numpy", "requests"]
22+
23+
24+
@pytest.mark.parametrize("example", ["example.py", "example"])
25+
def test_load_script_block(tmp_path: Path, example: str) -> None:
26+
filepath = tmp_path / example
27+
filepath.write_text(
28+
textwrap.dedent(
29+
"""\
30+
#!/usr/bin/env pipx run
31+
# /// script
32+
# requires-python = ">=3.11"
33+
# dependencies = [
34+
# "requests<3",
35+
# "rich",
36+
# ]
37+
# ///
38+
39+
import requests
40+
from rich.pretty import pprint
41+
42+
resp = requests.get("https://peps.python.org/api/peps.json")
43+
data = resp.json()
44+
pprint([(k, v["title"]) for k, v in data.items()][:10])
45+
"""
46+
)
47+
)
48+
49+
toml = nox.project.load_toml(filepath)
50+
assert toml["dependencies"] == ["requests<3", "rich"]
51+
52+
53+
def test_load_no_script_block(tmp_path: Path) -> None:
54+
filepath = tmp_path / "example.py"
55+
filepath.write_text(
56+
textwrap.dedent(
57+
"""\
58+
#!/usr/bin/python
59+
60+
import requests
61+
from rich.pretty import pprint
62+
63+
resp = requests.get("https://peps.python.org/api/peps.json")
64+
data = resp.json()
65+
pprint([(k, v["title"]) for k, v in data.items()][:10])
66+
"""
67+
)
68+
)
69+
70+
with pytest.raises(ValueError, match="No script block found"):
71+
nox.project.load_toml(filepath)
72+
73+
74+
def test_load_multiple_script_block(tmp_path: Path) -> None:
75+
filepath = tmp_path / "example.py"
76+
filepath.write_text(
77+
textwrap.dedent(
78+
"""\
79+
# /// script
80+
# dependencies = [
81+
# "requests<3",
82+
# "rich",
83+
# ]
84+
# ///
85+
86+
# /// script
87+
# requires-python = ">=3.11"
88+
# ///
89+
90+
import requests
91+
from rich.pretty import pprint
92+
93+
resp = requests.get("https://peps.python.org/api/peps.json")
94+
data = resp.json()
95+
pprint([(k, v["title"]) for k, v in data.items()][:10])
96+
"""
97+
)
98+
)
99+
100+
with pytest.raises(ValueError, match="Multiple script blocks found"):
101+
nox.project.load_toml(filepath)
102+
103+
104+
def test_load_non_recongnised_extension():
105+
msg = "Extension must be .py or .toml, got .txt"
106+
with pytest.raises(ValueError, match=msg):
107+
nox.project.load_toml("some.txt")

0 commit comments

Comments
 (0)