utils/dep_freshness/parsers/python.py
Krzysztof kuhy Rudnicki 2a2f18ca56 Add the shared dependency-freshness gate
"Are we on the newest version?" was not mechanically answerable across these
repos: constraint styles had drifted per-ecosystem and a caret range is
"newest-compatible" by definition, which gives a checker nothing to compare.
This gate makes it answerable and then enforces the answer.

Checks two things at once -- that every dependency carries an exact pin, and
that the pin equals its registry's latest STABLE release -- across pub.dev,
PyPI, npm, crates.io, the Go proxy, kuhyx/utils git tags, and the Flutter /
Node / Python toolchains.

Three deliberate design points:

* Pre-releases are rejected per-ecosystem, because every ecosystem leaks them
  differently and each leak fails GREEN. npm lets a maintainer point
  `dist-tags.latest` at an rc; crates.io's `max_version` includes them; PyPI's
  legacy `info.version` includes yanked releases. Unit-tested with fixtures,
  since an end-to-end run cannot surface a wrong "up to date".

* Offline, the gate serves an expired cache entry and PASSES with a DEGRADED
  banner (exit 3 only under --strict, which is CI). `--no-verify` is banned
  here, so a hook that hard-failed offline would leave no way to commit at all.

* The allowlist has two entry classes. `blocked_by: transitive:<pkg>@<ver>` is
  a predicate that clears itself when upstream moves and must NOT carry a
  date; everything else is discretionary and requires `expires`, capped at 90
  days. Exact pins plus latest-everywhere is provably unsatisfiable when a
  dependency internally constrains a shared transitive, and expiring the
  predicate class on a calendar would turn the gate red for a reason no commit
  could fix. An entry with nothing left to excuse is itself an error.

One hook id, one shim, one shared implementation -- always delegating, so it
cannot fork the way the 250-line cap forked into four ids, two of which were
independent reimplementations.

256 tests, 100% branch coverage on every module of the gate. Also adds the
workflow that runs them, which incidentally gives md_naming's 71 tests their
first CI run.

Not yet installed in any repo: a repo that gets the gate before its deps are
bumped is instantly and permanently red, so the gate and the upgrade land
together, per repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 13:00:35 +02:00

100 lines
3.3 KiB
Python

"""`pyproject.toml` and `requirements*.txt` -> PyPI dependencies.
PEP 508 markers, extras and environment conditions all appear in the wild here,
so the name is taken as the leading identifier and everything after the first
comparator is the constraint. Anything that is not a single `==` pin is
reported unpinned rather than guessed at.
"""
from __future__ import annotations
from pathlib import Path
import re
import tomllib
from dep_freshness._tables import PYPI, TOOLCHAIN
from dep_freshness.models import Dep
from dep_freshness.parsers._lines import index
from dep_freshness.versions import exact_pin
_REQ = re.compile(
r"^\s*(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)"
r"(?:\[(?P<extras>[^\]]*)\])?"
r"\s*(?P<constraint>.*)$"
)
_SKIP_PREFIXES = ("-", "#", "git+", "http://", "https://", ".", "/")
def _split(requirement: str) -> tuple[str, str] | None:
"""`('ruff', '==0.16.5')` from a PEP 508 requirement string."""
text = requirement.split("#", 1)[0].split(";", 1)[0].strip()
if not text or text.startswith(_SKIP_PREFIXES):
return None
match = _REQ.match(text)
if not match:
return None
return (match.group("name"), match.group("constraint").strip())
def _dep(name: str, constraint: str, path: Path, line: int) -> Dep:
pinned = exact_pin(constraint) if constraint.startswith("==") else None
return Dep(
ecosystem=PYPI, name=name, constraint=constraint or "(unconstrained)",
path=path, line=line, pinned=pinned,
)
def parse_requirements(path: Path) -> list[Dep]:
try:
text = path.read_text(encoding="utf-8")
except OSError:
return []
deps: list[Dep] = []
for number, raw in enumerate(text.splitlines(), start=1):
split = _split(raw)
if split:
deps.append(_dep(split[0], split[1], path, number))
return deps
def parse_pyproject(path: Path) -> list[Dep]:
try:
data = tomllib.loads(path.read_text(encoding="utf-8"))
except (OSError, tomllib.TOMLDecodeError):
return []
lines = index(path)
project = data.get("project") or {}
deps: list[Dep] = []
requires = project.get("requires-python")
if isinstance(requires, str):
deps.append(Dep(
ecosystem=TOOLCHAIN, name="python", constraint=requires, path=path,
line=lines.get("requires-python", 0), caret_ok=True,
))
groups: list[list] = [list(project.get("dependencies") or [])]
for extra in (project.get("optional-dependencies") or {}).values():
groups.append(list(extra))
for group in (data.get("dependency-groups") or {}).values():
groups.append([g for g in group if isinstance(g, str)])
for group in groups:
for requirement in group:
split = _split(str(requirement))
if split:
deps.append(_dep(split[0], split[1], path, lines.get(split[0], 0)))
return deps
def parse_python_version(path: Path) -> list[Dep]:
"""`.python-version` — compared against the installed interpreter."""
try:
value = path.read_text(encoding="utf-8").strip().splitlines()[0].strip()
except (OSError, IndexError):
return []
return [Dep(
ecosystem=TOOLCHAIN, name="python", constraint=value, path=path,
line=1, pinned=exact_pin(value),
)]