File: project.py

package info (click to toggle)
python-nox 2025.11.12-1
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 4,880 kB
  • sloc: python: 10,199; makefile: 204; sh: 6
file content (160 lines) | stat: -rw-r--r-- 5,012 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
from __future__ import annotations

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

import packaging.requirements
import packaging.specifiers
from dependency_groups import resolve

if TYPE_CHECKING:
    import os
    from typing import Any

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


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


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 = "pyproject.toml", *, missing_ok: bool = False
) -> 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. The default is ``"pyproject.toml"``.

    If ``missing_ok``, this will return an empty dict if a script block was not
    found, otherwise it will raise a error.

    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":
        return _load_toml_file(filepath)
    if filepath.suffix in {".py", ""}:
        return _load_script_block(filepath, missing_ok=missing_ok)
    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, *, missing_ok: bool) -> 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:
        if missing_ok:
            return {}
        msg = f"No {name} block found in {filepath}"
        raise ValueError(msg)
    if len(matches) > 1:
        msg = f"Multiple {name} blocks found in {filepath}"
        raise ValueError(msg)

    content = "".join(
        line[2:] if line.startswith("# ") else line[1:]
        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
        msg = 'No Python version classifiers found in "project.classifiers"'
        raise ValueError(msg)

    requires_python_str = pyproject.get("project", {}).get("requires-python", "")
    if not requires_python_str:
        msg = 'No "project.requires-python" value set'
        raise ValueError(msg)

    for spec in packaging.specifiers.SpecifierSet(requires_python_str):
        if spec.operator in {">", ">=", "~="}:
            min_minor_version = int(spec.version.split(".")[1])
            break
    else:
        msg = 'No minimum version found in "project.requires-python"'
        raise ValueError(msg)

    max_minor_version = int(max_version.split(".")[1])

    return [f"3.{v}" for v in range(min_minor_version, max_minor_version + 1)]


def dependency_groups(pyproject: dict[str, Any], *groups: str) -> tuple[str, ...]:
    """
    Get a list of dependencies from a ``[dependency-groups]`` section(s).

    Example:

    .. code-block:: python

        @nox.session
        def test(session):
            pyproject = nox.project.load_toml("pyproject.toml")
            session.install(*nox.project.dependency_groups(pyproject, "dev"))
    """
    dep_groups = pyproject["dependency-groups"]
    return resolve(dep_groups, *groups)