diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ec349577..72e48e79 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,6 +40,7 @@ repos: - jinja2 - packaging - importlib_metadata + - tomli - uv - repo: https://github.com/codespell-project/codespell diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 4b3131f1..e9030c50 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -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 `_ 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 ---------------- diff --git a/nox/__init__.py b/nox/__init__.py index 1afe51a7..2e47ad57 100644 --- a/nox/__init__.py +++ b/nox/__init__.py @@ -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 @@ -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", +] diff --git a/nox/project.py b/nox/project.py new file mode 100644 index 00000000..20a76aa7 --- /dev/null +++ b/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[a-zA-Z0-9-]+)$\s(?P(^#(| .*)$\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) diff --git a/noxfile.py b/noxfile.py index a151e5b1..0b3b054f 100644 --- a/noxfile.py +++ b/noxfile.py @@ -96,7 +96,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") diff --git a/pyproject.toml b/pyproject.toml index 38b6ec87..913ae1dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] @@ -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" ] diff --git a/requirements-test.txt b/requirements-test.txt index eb05f07f..b4240b7e 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,4 +1,4 @@ -coverage[toml]>=5.3 +coverage[toml]>=7.2 flask myst-parser pytest>=6.0 diff --git a/tests/test_toml.py b/tests/test_toml.py new file mode 100644 index 00000000..833f1d12 --- /dev/null +++ b/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")