#!/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())