Files
kvitt/tools/dependency-report/latest.py
T
jacobh2 cab3e935d6 SPEC v0.1 and project rules for Kvitt
Write SPEC.md v0.1 from the owner interview (requirements, data model,
stack, API outline, security, business rules, deployment, milestones,
UX sprint brief, contracts). Fill the CLAUDE.md blanks, move the Gradle
pin files into android/, point the tools at android/ and server/, and
record the git bootstrap exception in the handoff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:39:02 +02:00

148 lines
5.9 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Version drift report for android/gradle/libs.versions.toml (Plan 09, D-056).
Reads every [versions] entry, finds an artifact that uses it in [libraries] or [plugins], and asks Google Maven and
Maven Central for that artifact's maven-metadata.xml. Prints one table: current, latest stable, newest (only where a
pre-release is newer than the latest stable), and how far the current version is behind.
The Gradle wrapper version is compared with services.gradle.org. Nothing here runs from the build; run it by hand:
python3 tools/dependency-report/latest.py
Rule (CLAUDE.md › Build and verify): run it at the start of every plan. A component more than one stable minor
behind gets a line in that plan's report; a toolchain component (Gradle, AGP, Kotlin, JDK LTS) more than one line
behind triggers a Plan 09-shaped upgrade before the plan starts.
"""
import json
import re
import sys
import urllib.request
import xml.etree.ElementTree as ET
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] / "android" # the Gradle project lives in android/
REPOS = ["https://dl.google.com/android/maven2", "https://repo1.maven.org/maven2"]
# Catalog entries that are SDK levels, not artifacts.
NOT_ARTIFACTS = {"minSdk", "compileSdk", "targetSdk"}
# The toolchain is looked up by its Gradle plugin, not by the first library that shares its version.
PREFERRED = {
"agp": ("com.android.tools.build", "gradle"),
"kotlin": ("org.jetbrains.kotlin", "kotlin-gradle-plugin"),
"ksp": ("com.google.devtools.ksp", "symbol-processing-gradle-plugin"),
}
STABLE = re.compile(r"^\d+(\.\d+)*$")
def key(version):
"""Numeric parts first; a pre-release (anything after the first '-') sorts before its release."""
base, _, suffix = version.partition("-")
numbers = [int(p) if p.isdigit() else -1 for p in base.split(".")]
return numbers, (0, suffix) if suffix else (1, "")
def fetch(url):
try:
with urllib.request.urlopen(url, timeout=20) as response:
return response.read()
except OSError:
return None
def versions_of(group, name):
"""Versions from every repository together: some artifacts (early KSP) also sit, stale, on Google Maven."""
found = set()
for repo in REPOS:
body = fetch(f"{repo}/{group.replace('.', '/')}/{name}/maven-metadata.xml")
if body:
found.update(v.text for v in ET.fromstring(body).iter("version") if v.text)
return sorted(found)
def plugin_marker(plugin_id):
return plugin_id, f"{plugin_id}.gradle.plugin"
def read_catalog(text):
"""The subset of TOML the catalog uses (works on Python < 3.11, which has no tomllib): returns the [versions]
entries in file order and, per version name, the first library or plugin that refers to it."""
versions, artifacts, section = {}, {}, None
for raw in text.splitlines():
line = raw.split("#", 1)[0].strip()
if not line:
continue
if line.startswith("["):
section = line.strip("[]")
continue
if section == "versions":
match = re.match(r'([\w-]+)\s*=\s*"([^"]+)"', line)
if match:
versions[match.group(1)] = match.group(2)
continue
ref = re.search(r'version\.ref\s*=\s*"([^"]+)"', line)
if not ref or ref.group(1) in artifacts:
continue
module = re.search(r'module\s*=\s*"([^"]+)"', line)
plugin = re.search(r'\bid\s*=\s*"([^"]+)"', line)
if section == "libraries" and module:
artifacts[ref.group(1)] = tuple(module.group(1).split(":"))
elif section == "plugins" and plugin:
artifacts[ref.group(1)] = plugin_marker(plugin.group(1))
return versions, artifacts
def behind(current, stable):
if not stable or not STABLE.match(current):
return ""
c, s = [int(x) for x in current.split(".")], [int(x) for x in stable.split(".")]
c += [0] * (3 - len(c))
s += [0] * (3 - len(s))
if c[0] < s[0]:
return f"{s[0] - c[0]} major"
if c[1] < s[1]:
return f"{s[1] - c[1]} minor"
if c[2] < s[2]:
return "patch"
return ""
def row(name, coordinate, current, available):
stable = sorted((v for v in available if STABLE.match(v)), key=key)
everything = sorted(available, key=key)
latest = stable[-1] if stable else "?"
newest = everything[-1] if everything else "?"
return [name, coordinate, current, latest, newest if newest not in (latest, current) else "", behind(current, latest if stable else "")]
def gradle_row():
props = (ROOT / "gradle/wrapper/gradle-wrapper.properties").read_text()
match = re.search(r"gradle-([\d.]+)-(bin|all)\.zip", props)
current = match.group(1) if match else "?"
body = fetch("https://services.gradle.org/versions/all")
available = []
if body:
available = [v["version"] for v in json.loads(body) if not v.get("snapshot") and not v.get("nightly")]
return row("gradle (wrapper)", "services.gradle.org", current, available)
def main():
versions, artifacts = read_catalog((ROOT / "gradle/libs.versions.toml").read_text())
rows = [gradle_row()]
for name, current in versions.items():
if name in NOT_ARTIFACTS:
continue
coordinate = PREFERRED.get(name) or artifacts.get(name)
if not coordinate:
rows.append([name, "(no artifact)", str(current), "", "", ""])
continue
rows.append(row(name, ":".join(coordinate), str(current), versions_of(*coordinate)))
header = ["version", "artifact", "current", "latest stable", "newer pre-release", "behind"]
widths = [max(len(str(r[i])) for r in rows + [header]) for i in range(len(header))]
print(" | ".join(h.ljust(w) for h, w in zip(header, widths)))
print("-+-".join("-" * w for w in widths))
for r in rows:
print(" | ".join(str(c).ljust(w) for c, w in zip(r, widths)))
return 0
if __name__ == "__main__":
sys.exit(main())