utils/dep_freshness/parsers/gradle.py
Krzysztof kuhy Rudnicki dccfe14ae9
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Drop TachiyomiSY mentions from comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:36:38 +01:00

159 lines
5.9 KiB
Python

"""Gradle: `gradle/libs.versions.toml` -> Maven coordinates, and the wrapper.
The version catalog is the one place a Gradle build should declare versions,
which makes it a manifest the gate can read without evaluating Kotlin DSL.
A build may have several (`settings.gradle.kts` can `from(files(...))` any
`*.versions.toml`; a large app can have several), so the suffix is what is matched.
Three shapes appear in `[libraries]` and `[plugins]`:
foo = "group:artifact:1.2.3" # inline string
foo = { module = "group:artifact", version.ref = "x" }
foo = { group = "g", name = "a", version = "1.2.3" }
plug = { id = "com.example.plugin", version.ref = "x" }
A library with no version is managed by a BOM (`junit-platform-launcher`
in a typical app) and is skipped: the BOM's own entry is the pin that counts.
A rich version table (`{ strictly = "1.2" }`) is read as the constraint it
spells and lands as unpinned when it is a range, like every other ecosystem.
The line reported is the `[versions]` entry when a `version.ref` is used,
because that is where the fix goes; several libraries sharing one ref each
report the same line, which is one finding per stale artifact, not noise.
`gradle-wrapper.properties` names the Gradle distribution; that is the
toolchain pin, judged against services.gradle.org like Flutter against its
release feed.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
import tomllib
from dep_freshness._tables import GITCOMMIT, JITPACK_GROUP_PREFIX, MAVEN, TOOLCHAIN
from dep_freshness.models import Dep
from dep_freshness.parsers._lines import index
from dep_freshness.versions import exact_pin
CATALOG_SUFFIX = ".versions.toml" # libs.versions.toml, but also sy.versions.toml
WRAPPER_NAME = "gradle-wrapper.properties"
_DISTRIBUTION = re.compile(
r"gradle-(?P<version>\d+(?:\.\d+)*(?:-[0-9A-Za-z.-]+?)?)-(?:bin|all)\.zip"
)
# JitPack accepts any commit as a version; 7 to 40 hex digits and nothing
# else is one. `1.0.0` never matches (dots), and a tag like `v2.3.0` has a
# letter outside a-f.
_COMMIT = re.compile(r"^[0-9a-f]{7,40}$")
def _commit_pin(name: str, version: str) -> bool:
"""True for `com.github.*` at a commit hash: a gitcommit dep, not maven."""
return name.startswith(JITPACK_GROUP_PREFIX) and bool(_COMMIT.match(version))
def _rich(value: Any) -> str:
"""The version string a catalog value spells, for any of its shapes."""
if isinstance(value, dict):
for key in ("strictly", "require", "prefer"):
if value.get(key):
return str(value[key])
return ""
return str(value or "")
def _coordinates(entry: Any, plugin: bool) -> tuple[str, str] | None:
"""`(name, inline_version)` for a library or plugin entry, or None."""
if isinstance(entry, str):
parts = entry.split(":")
if plugin:
return (parts[0], parts[1] if len(parts) > 1 else "")
if len(parts) < 2:
return None
return (f"{parts[0]}:{parts[1]}", parts[2] if len(parts) > 2 else "")
if not isinstance(entry, dict):
return None
if plugin:
name = str(entry.get("id") or "")
elif entry.get("module"):
name = str(entry["module"])
else:
name = f"{entry.get('group', '')}:{entry.get('name', '')}"
if not name.strip(":"):
return None
version = entry.get("version")
if isinstance(version, dict) and version.get("ref"):
return (name, f"ref:{version['ref']}")
return (name, _rich(version))
def parse_catalog(path: Path) -> list[Dep]:
"""Every versioned library and plugin in a Gradle version catalog."""
try:
data = tomllib.loads(path.read_text(encoding="utf-8"))
except (OSError, tomllib.TOMLDecodeError):
return []
versions = {k: _rich(v) for k, v in (data.get("versions") or {}).items()}
lines = index(path)
deps: list[Dep] = []
for section, plugin in (("libraries", False), ("plugins", True)):
for alias, entry in (data.get(section) or {}).items():
coords = _coordinates(entry, plugin)
if coords is None:
continue
name, version = coords
line = lines.get(str(alias), 0)
if version.startswith("ref:"):
ref = version[4:]
version = versions.get(ref, "")
line = lines.get(ref, line)
if not version:
continue # BOM-managed, or a dangling ref the build itself rejects
if plugin:
name = f"{name}:{name}.gradle.plugin"
ecosystem, pinned = MAVEN, exact_pin(version)
if _commit_pin(name, version):
ecosystem, pinned = GITCOMMIT, version.strip()
deps.append(
Dep(
ecosystem=ecosystem,
name=name,
constraint=version,
path=path,
line=line,
pinned=pinned,
)
)
return deps
def parse_wrapper(path: Path) -> list[Dep]:
"""The Gradle distribution the wrapper downloads, as a toolchain pin."""
try:
text = path.read_text(encoding="utf-8")
except OSError:
return []
for number, line in enumerate(text.splitlines(), start=1):
if not line.startswith("distributionUrl="):
continue
match = _DISTRIBUTION.search(line)
version = match.group("version") if match else ""
return [
Dep(
ecosystem=TOOLCHAIN,
name="gradle",
constraint=version,
path=path,
line=number,
pinned=exact_pin(version),
)
]
return []
def is_catalog(path: Path) -> bool:
"""True for `libs.versions.toml` and any sibling catalog a build declares."""
return path.name.endswith(CATALOG_SUFFIX)