Source code for repomatic.deps.requirements

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""The requirement arrays of a `pyproject.toml`, and the package names in them.

Shared by the three modules that read what a project declares: the version
policy ({mod}`repomatic.deps.policy`), the shippability gate
({mod}`repomatic.deps.shippable`) and the git-to-release swap
({mod}`repomatic.deps.sources`). Each array is located by its TOML path, the one
spelling every report uses.
"""

from __future__ import annotations

import re
from dataclasses import dataclass

from packaging.requirements import InvalidRequirement, Requirement
from packaging.utils import canonicalize_name

from ..pyproject import cached_toml
from ..versions import safe_version

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Iterator, Mapping
    from pathlib import Path
    from typing import Any

LOWER_BOUND_OPERATORS = frozenset((">", ">="))
"""PEP 440 operators that put a floor under a requirement.

The one spelling of "lower bound" every check tests specifiers against. A site
that also treats an exact pin as a bound unions {data}`PIN_OPERATOR` in
explicitly, so the difference stays a decision rather than an accident.
"""

PIN_OPERATOR = frozenset(("==",))
"""The PEP 440 operator pinning a requirement to one version.

A pin demands its version the way a floor demands its minimum, so the checks
asking "can PyPI serve what this declaration requires" test both; the checks
looking for the managed git-branch floor idiom deliberately do not.
"""

PUBLISHED_TABLES = ("project", "project.optional-dependencies")
"""Tables whose requirement arrays land in the published `Requires-Dist`.

`[dependency-groups]` ([PEP 735](https://peps.python.org/pep-0735/)) is
deliberately absent: it never reaches distribution metadata. Neither does
`[build-system].requires`, which only the build front end reads.
"""

BUILD_TABLE = "build-system"
"""The table whose `requires` array lists the build dependencies."""

TOML_TABLE_HEADER = re.compile(r"\s*\[{1,2}\s*(?P<path>[^]]+?)\s*\]{1,2}\s*$")
"""A `[table]` or `[[array of tables]]` header, capturing its dotted path."""


[docs] @dataclass(frozen=True) class RequirementArray: """One requirement array of a `pyproject.toml`.""" table: str """The dotted path of the table holding the array (`project`, `project.optional-dependencies`, `dependency-groups`, `build-system`).""" key: str """The array's key in that table: `dependencies`, an extra, a group, or `requires`.""" items: list[Any] """The live array, so a caller can rewrite an item in place.""" @property def location(self) -> str: """The TOML path of the array, as every report spells it.""" return f"{self.table}.{self.key}" @property def published(self) -> bool: """Whether the array reaches the published `Requires-Dist`. What separates a finding an installer trips over from one only a contributor does: see {data}`PUBLISHED_TABLES`. """ return self.table in PUBLISHED_TABLES
[docs] def requirements(self) -> Iterator[tuple[int, str, Requirement]]: """Yield each parsable requirement, with its index and its raw string. Non-string items (like `{include-group = …}` entries) and malformed specifiers are skipped. """ for index, item in enumerate(self.items): requirement = parse_requirement(item) if requirement is not None: yield index, item, requirement
[docs] def read_project(pyproject_path: Path) -> Mapping[str, Any]: """Parse *pyproject_path* read-only, through the shared TOML cache. :param pyproject_path: The `pyproject.toml` file, whatever its name. :return: The parsed document, or an empty mapping when the file is missing. Shared with the cache: treat it as read-only. """ try: data: Mapping[str, Any] = cached_toml(pyproject_path) except OSError: return {} return data
[docs] def requirement_arrays(doc: Mapping[str, Any]) -> Iterator[RequirementArray]: """Yield every requirement array of a parsed `pyproject.toml`. Covers `[project] dependencies`, each `[project.optional-dependencies]` extra, each `[dependency-groups]` group, and `[build-system] requires`. Values that are not lists are skipped. :param doc: The parsed `pyproject.toml`. """ project = doc.get("project", {}) if isinstance(dependencies := project.get("dependencies"), list): yield RequirementArray("project", "dependencies", dependencies) for extra, items in (project.get("optional-dependencies") or {}).items(): if isinstance(items, list): yield RequirementArray("project.optional-dependencies", extra, items) for group, items in (doc.get("dependency-groups") or {}).items(): if isinstance(items, list): yield RequirementArray("dependency-groups", group, items) if isinstance(requires := doc.get(BUILD_TABLE, {}).get("requires"), list): yield RequirementArray(BUILD_TABLE, "requires", requires)
[docs] def parse_requirement(item: object) -> Requirement | None: """Parse a requirement array item, returning `None` for anything else. :param item: One entry of a requirement array. :return: The parsed requirement, or `None` for a non-string entry or an unparsable specifier. """ if not isinstance(item, str): return None try: return Requirement(item) except InvalidRequirement: return None
[docs] def dev_floor(doc: Mapping[str, Any], name: str) -> str | None: """The highest `.dev` lower bound declared for *name*, if any. Scans every requirement array for lower-bound clauses (`>=` or `>`) whose version is a dev release. The highest one is the project's declared "awaited release" threshold. :param doc: The parsed `pyproject.toml`. :param name: Package name (any capitalization or separator style). :return: The floor version string, or `None` when the package has no dev floor (a git override is then outside the managed idiom). """ canonical = canonicalize_name(name) floors = [] for array in requirement_arrays(doc): for _index, _raw, requirement in array.requirements(): if canonicalize_name(requirement.name) != canonical: continue for spec in requirement.specifier: if spec.operator not in LOWER_BOUND_OPERATORS: continue version = safe_version(spec.version) if version is not None and version.is_devrelease: floors.append(version) return str(max(floors)) if floors else None
[docs] def iter_toml_lines(text: str) -> Iterator[tuple[int, str, str]]: """Yield each line of a TOML text, with its number and the table it sits in. Enough TOML parsing to place a line: the parsed document holds values and comments, but no line numbers, and a reader needs one to find a declaration. A header line already belongs to the table it opens. :param text: The TOML text. :return: `(line number, table path, line)` triples. Numbers start at 1, and the table path is empty above the first header. """ table = "" for number, line in enumerate(text.splitlines(), start=1): header = TOML_TABLE_HEADER.match(line) if header: table = header["path"] yield number, table, line
[docs] def pep503_pattern(name: str) -> str: """A regex matching *name* in any spelling PEP 503 treats as equal. A canonical name separates its parts with `-`, where a declaration may spell any of `-`, `_` or `.`. Match it case-insensitively, since the declaration also picks its own case. :param name: A package name, in any spelling. :return: The pattern, with no anchors or flags. """ parts = canonicalize_name(name).split("-") return "[-_.]+".join(re.escape(part) for part in parts)