Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support PEP 723 with a toml load function #811

Merged
merged 6 commits into from
Apr 12, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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.toml.load``. 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.toml.load("peps.py")["dependencies"]
session.install(*requirements)
session.run("peps.py")


Running commands
----------------

Expand Down
11 changes: 10 additions & 1 deletion nox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from __future__ import annotations

from nox import toml
henryiii marked this conversation as resolved.
Show resolved Hide resolved
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",
"toml",
]
69 changes: 69 additions & 0 deletions nox/toml.py
Original file line number Diff line number Diff line change
@@ -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"]


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(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)
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
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
107 changes: 107 additions & 0 deletions tests/test_toml.py
Original file line number Diff line number Diff line change
@@ -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.toml.load(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.toml.load(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.toml.load(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.toml.load(filepath)


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