Skip to content

Commit

Permalink
feat: support PEP 723 with a toml load function (#811)
Browse files Browse the repository at this point in the history
* feat: support PEP 723 with a toml load function

Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>

* refactor: nox.toml.load

Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>

* tests: fix coverage for module dir's

Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>

* refactor: nox.project.load_toml

Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>

* Update requirements-test.txt

Co-authored-by: Claudio Jolowicz <cjolowicz@gmail.com>

* Update requirements-test.txt

---------

Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>
Co-authored-by: Claudio Jolowicz <cjolowicz@gmail.com>
  • Loading branch information
henryiii and cjolowicz committed Apr 12, 2024
1 parent d3dd1f8 commit d6e1906
Show file tree
Hide file tree
Showing 8 changed files with 231 additions and 4 deletions.
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Expand Up @@ -40,6 +40,7 @@ repos:
- jinja2
- packaging
- importlib_metadata
- tomli
- uv

- repo: https://github.com/codespell-project/codespell
Expand Down
40 changes: 40 additions & 0 deletions docs/tutorial.rst
Expand Up @@ -169,6 +169,46 @@ dependency (e.g. ``libfoo``) is available during installation:
These commands will run even if you are only installing, and will not run if
the environment is being reused without reinstallation.


Loading dependencies from pyproject.toml or scripts
---------------------------------------------------

One common need is loading a dependency list from a ``pyproject.toml`` file
(say to prepare an environment without installing the package) or supporting
`PEP 723 <https://peps.python.org/pep-0723>`_ scripts. Nox provides a helper to
load these with ``nox.project.load_toml``. It can be passed a filepath to a toml
file or to a script file following PEP 723. For example, if you have the
following ``peps.py``:


.. code-block:: python
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "requests<3",
# "rich",
# ]
# ///
import requests
from rich.pretty import pprint
resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
You can make a session for it like this:

.. code-block:: python
@nox.session
def peps(session):
requirements = nox.project.load_toml("peps.py")["dependencies"]
session.install(*requirements)
session.run("peps.py")
Running commands
----------------

Expand Down
11 changes: 10 additions & 1 deletion nox/__init__.py
Expand Up @@ -14,6 +14,7 @@

from __future__ import annotations

from nox import project
from nox._options import noxfile_options as options
from nox._parametrize import Param as param
from nox._parametrize import parametrize_decorator as parametrize
Expand All @@ -22,4 +23,12 @@

needs_version: str | None = None

__all__ = ["needs_version", "parametrize", "param", "session", "options", "Session"]
__all__ = [
"needs_version",
"parametrize",
"param",
"session",
"options",
"Session",
"project",
]
69 changes: 69 additions & 0 deletions nox/project.py
@@ -0,0 +1,69 @@
from __future__ import annotations

import os
import re
import sys
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from typing import Any

if sys.version_info < (3, 11):
import tomli as tomllib
else:
import tomllib


__all__ = ["load_toml"]


def __dir__() -> list[str]:
return __all__


# Note: the implementation (including this regex) taken from PEP 723
# https://peps.python.org/pep-0723

REGEX = re.compile(
r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$"
)


def load_toml(filename: os.PathLike[str] | str) -> dict[str, Any]:
"""
Load a toml file or a script with a PEP 723 script block.
The file must have a ``.toml`` extension to be considered a toml file or a
``.py`` extension / no extension to be considered a script. Other file
extensions are not valid in this function.
"""
filepath = Path(filename)
if filepath.suffix == ".toml":
return _load_toml_file(filepath)
if filepath.suffix in {".py", ""}:
return _load_script_block(filepath)
msg = f"Extension must be .py or .toml, got {filepath.suffix}"
raise ValueError(msg)


def _load_toml_file(filepath: Path) -> dict[str, Any]:
with filepath.open("rb") as f:
return tomllib.load(f)


def _load_script_block(filepath: Path) -> dict[str, Any]:
name = "script"
script = filepath.read_text(encoding="utf-8")
matches = list(filter(lambda m: m.group("type") == name, REGEX.finditer(script)))

if not matches:
raise ValueError(f"No {name} block found in {filepath}")
if len(matches) > 1:
raise ValueError(f"Multiple {name} blocks found in {filepath}")

content = "".join(
line[2:] if line.startswith("# ") else line[1:]
for line in matches[0].group("content").splitlines(keepends=True)
)
return tomllib.loads(content)
2 changes: 1 addition & 1 deletion noxfile.py
Expand Up @@ -119,7 +119,7 @@ def cover(session: nox.Session) -> None:
if ON_WINDOWS_CI:
return

session.install("coverage[toml]>=5.3")
session.install("coverage[toml]>=7.3")
session.run("coverage", "combine")
session.run("coverage", "report", "--fail-under=100", "--show-missing")
session.run("coverage", "erase")
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Expand Up @@ -44,6 +44,7 @@ dependencies = [
"colorlog<7.0.0,>=2.6.1",
'importlib-metadata; python_version < "3.8"',
"packaging>=20.9",
'tomli>=1; python_version < "3.11"',
'typing-extensions>=3.7.4; python_version < "3.8"',
"virtualenv>=20.14.1",
]
Expand Down Expand Up @@ -107,7 +108,7 @@ relative_files = true
source_pkgs = [ "nox" ]

[tool.coverage.report]
exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", "@overload" ]
exclude_also = [ "def __dir__()", "if TYPE_CHECKING:", "@overload" ]

[tool.mypy]
files = [ "nox/**/*.py", "noxfile.py" ]
Expand Down
2 changes: 1 addition & 1 deletion requirements-test.txt
@@ -1,4 +1,4 @@
coverage[toml]>=5.3
coverage[toml]>=7.2
flask
myst-parser
pytest>=6.0
Expand Down
107 changes: 107 additions & 0 deletions tests/test_toml.py
@@ -0,0 +1,107 @@
import textwrap
from pathlib import Path

import pytest

import nox


def test_load_pyproject(tmp_path: Path) -> None:
filepath = tmp_path / "example.toml"
filepath.write_text(
"""
[project]
name = "hi"
version = "1.0"
dependencies = ["numpy", "requests"]
"""
)

toml = nox.project.load_toml(filepath)
assert toml["project"]["dependencies"] == ["numpy", "requests"]


@pytest.mark.parametrize("example", ["example.py", "example"])
def test_load_script_block(tmp_path: Path, example: str) -> None:
filepath = tmp_path / example
filepath.write_text(
textwrap.dedent(
"""\
#!/usr/bin/env pipx run
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "requests<3",
# "rich",
# ]
# ///
import requests
from rich.pretty import pprint
resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
"""
)
)

toml = nox.project.load_toml(filepath)
assert toml["dependencies"] == ["requests<3", "rich"]


def test_load_no_script_block(tmp_path: Path) -> None:
filepath = tmp_path / "example.py"
filepath.write_text(
textwrap.dedent(
"""\
#!/usr/bin/python
import requests
from rich.pretty import pprint
resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
"""
)
)

with pytest.raises(ValueError, match="No script block found"):
nox.project.load_toml(filepath)


def test_load_multiple_script_block(tmp_path: Path) -> None:
filepath = tmp_path / "example.py"
filepath.write_text(
textwrap.dedent(
"""\
# /// script
# dependencies = [
# "requests<3",
# "rich",
# ]
# ///
# /// script
# requires-python = ">=3.11"
# ///
import requests
from rich.pretty import pprint
resp = requests.get("https://peps.python.org/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
"""
)
)

with pytest.raises(ValueError, match="Multiple script blocks found"):
nox.project.load_toml(filepath)


def test_load_non_recongnised_extension():
msg = "Extension must be .py or .toml, got .txt"
with pytest.raises(ValueError, match=msg):
nox.project.load_toml("some.txt")

0 comments on commit d6e1906

Please sign in to comment.