Skip to content

Commit

Permalink
feat: add helper to get the Python listing (#877)
Browse files Browse the repository at this point in the history
* feat: add helper to get the Python listing

Signed-off-by: Henry Schreiner <[email protected]>

* Update nox/project.py

* refactor: python_list -> python_versions

Signed-off-by: Henry Schreiner <[email protected]>

---------

Signed-off-by: Henry Schreiner <[email protected]>
  • Loading branch information
henryiii authored Oct 29, 2024
1 parent 6edc697 commit 040a93c
Show file tree
Hide file tree
Showing 4 changed files with 136 additions and 1 deletion.
7 changes: 7 additions & 0 deletions docs/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------------------------------------
Expand Down
3 changes: 3 additions & 0 deletions docs/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------------

Expand Down
62 changes: 61 additions & 1 deletion nox/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from pathlib import Path
from typing import TYPE_CHECKING

import packaging.specifiers

if TYPE_CHECKING:
from typing import Any

Expand All @@ -15,7 +17,7 @@
import tomllib


__all__ = ["load_toml"]
__all__ = ["load_toml", "python_versions"]


def __dir__() -> list[str]:
Expand All @@ -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":
Expand Down Expand Up @@ -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)]
65 changes: 65 additions & 0 deletions tests/test_project.py
Original file line number Diff line number Diff line change
@@ -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")

0 comments on commit 040a93c

Please sign in to comment.