From 040a93c3b085c68f85b95ccb20d4d21f6997cabf Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 29 Oct 2024 16:40:37 -0400 Subject: [PATCH] feat: add helper to get the Python listing (#877) * feat: add helper to get the Python listing Signed-off-by: Henry Schreiner * Update nox/project.py * refactor: python_list -> python_versions Signed-off-by: Henry Schreiner --------- Signed-off-by: Henry Schreiner --- docs/config.rst | 7 +++++ docs/tutorial.rst | 3 ++ nox/project.py | 62 ++++++++++++++++++++++++++++++++++++++++- tests/test_project.py | 65 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 tests/test_project.py diff --git a/docs/config.rst b/docs/config.rst index 04c0997c..593c1117 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -466,6 +466,13 @@ class. :members: :undoc-members: +The pyproject.toml helpers +-------------------------- + +Nox provides helpers for ``pyproject.toml`` projects in the ``nox.project`` namespace. + +.. automodule:: nox.project + :members: Modifying Nox's behavior in the Noxfile --------------------------------------- diff --git a/docs/tutorial.rst b/docs/tutorial.rst index afdfb363..bb4e20e3 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -218,6 +218,9 @@ is provided: session.install_and_run_script("peps.py") +Other helpers for ``pyproject.toml`` based projects are also available in +``nox.project``. + Running commands ---------------- diff --git a/nox/project.py b/nox/project.py index 20a76aa7..24a4712e 100644 --- a/nox/project.py +++ b/nox/project.py @@ -6,6 +6,8 @@ from pathlib import Path from typing import TYPE_CHECKING +import packaging.specifiers + if TYPE_CHECKING: from typing import Any @@ -15,7 +17,7 @@ import tomllib -__all__ = ["load_toml"] +__all__ = ["load_toml", "python_versions"] def __dir__() -> list[str]: @@ -37,6 +39,15 @@ def load_toml(filename: os.PathLike[str] | str) -> dict[str, Any]: 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. + + Example: + + .. code-block:: python + + @nox.session + def myscript(session): + myscript_options = nox.project.load_toml("myscript.py") + session.install(*myscript_options["dependencies"]) """ filepath = Path(filename) if filepath.suffix == ".toml": @@ -67,3 +78,52 @@ def _load_script_block(filepath: Path) -> dict[str, Any]: for line in matches[0].group("content").splitlines(keepends=True) ) return tomllib.loads(content) + + +def python_versions( + pyproject: dict[str, Any], *, max_version: str | None = None +) -> list[str]: + """ + Read a list of supported Python versions. Without ``max_version``, this + will read the trove classifiers (recommended). With a ``max_version``, it + will read the requires-python setting for a lower bound, and will use the + value of ``max_version`` as the upper bound. (Reminder: you should never + set an upper bound in ``requires-python``). + + Example: + + .. code-block:: python + + import nox + + PYPROJECT = nox.project.load_toml("pyproject.toml") + # From classifiers + PYTHON_VERSIONS = nox.project.python_versions(PYPROJECT) + # Or from requires-python + PYTHON_VERSIONS = nox.project.python_versions(PYPROJECT, max_version="3.13") + """ + if max_version is None: + # Classifiers are a list of every Python version + from_classifiers = [ + c.split()[-1] + for c in pyproject.get("project", {}).get("classifiers", []) + if c.startswith("Programming Language :: Python :: 3.") + ] + if from_classifiers: + return from_classifiers + raise ValueError('No Python version classifiers found in "project.classifiers"') + + requires_python_str = pyproject.get("project", {}).get("requires-python", "") + if not requires_python_str: + raise ValueError('No "project.requires-python" value set') + + for spec in packaging.specifiers.SpecifierSet(requires_python_str): + if spec.operator in {">", ">=", "~="}: + min_minor_version = int(spec.version.split(".")[1]) + break + else: + raise ValueError('No minimum version found in "project.requires-python"') + + max_minor_version = int(max_version.split(".")[1]) + + return [f"3.{v}" for v in range(min_minor_version, max_minor_version + 1)] diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 00000000..a522c77c --- /dev/null +++ b/tests/test_project.py @@ -0,0 +1,65 @@ +import pytest + +from nox.project import python_versions + + +def test_classifiers(): + pyproject = { + "project": { + "classifiers": [ + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Software Development :: Testing", + ], + "requires-python": ">=3.10", + } + } + + assert python_versions(pyproject) == ["3.7", "3.9", "3.12"] + + +def test_no_classifiers(): + pyproject = {"project": {"requires-python": ">=3.9"}} + with pytest.raises(ValueError, match="No Python version classifiers"): + python_versions(pyproject) + + +def test_no_requires_python(): + pyproject = {"project": {"classifiers": ["Programming Language :: Python :: 3.12"]}} + with pytest.raises(ValueError, match='No "project.requires-python" value set'): + python_versions(pyproject, max_version="3.13") + + +def test_python_range(): + pyproject = { + "project": { + "classifiers": [ + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Software Development :: Testing", + ], + "requires-python": ">=3.10", + } + } + + assert python_versions(pyproject, max_version="3.12") == ["3.10", "3.11", "3.12"] + assert python_versions(pyproject, max_version="3.11") == ["3.10", "3.11"] + + +def test_python_range_gt(): + pyproject = {"project": {"requires-python": ">3.2.1,<3.3"}} + + assert python_versions(pyproject, max_version="3.4") == ["3.2", "3.3", "3.4"] + + +def test_python_range_no_min(): + pyproject = {"project": {"requires-python": "==3.3.1"}} + + with pytest.raises(ValueError, match="No minimum version found"): + python_versions(pyproject, max_version="3.5")