mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 12:27:47 +01:00
dep_freshness: Gradle ecosystem -- version catalogs, wrapper, Maven registries
The gate had no way to read a Gradle build, which left the two Kotlin
forks (tachiyomisy, syncyomi-src) outside the fleet-wide rule.
- parsers/gradle.py reads gradle/libs.versions.toml ([libraries] and
[plugins] in every shape: inline string, module + version.ref,
group/name/version, rich {strictly|require|prefer} tables) into
`maven:<group>:<artifact>` deps, pointing the fix at the [versions]
line when a ref is used; BOM-managed entries without a version are
skipped. gradle-wrapper.properties becomes `toolchain:gradle`.
- registries/maven.py asks Google Maven, Central and the plugin portal
in that order, reading the full <versions> list -- AndroidX writes
alphas into <latest>. Plugins resolve through their marker artifact.
- registries/toolchain.gradle_latest reads services.gradle.org.
- http.get_text shares the transport with get_json (XML bodies).
- Templates: the pre-commit `files:` and the CI cache key know the
catalog. 100% branch on every new file; fixtures only.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Yc4TiPjhbQLfH35QBPfrbP
This commit is contained in:
parent
3b5c247f05
commit
a4ea305164
@ -17,13 +17,14 @@ NPM: Final = "npm"
|
||||
CARGO: Final = "cargo"
|
||||
GOMOD: Final = "gomod"
|
||||
GITTAG: Final = "gittag"
|
||||
MAVEN: Final = "maven"
|
||||
TOOLCHAIN: Final = "toolchain"
|
||||
# The constraint a workflow parser writes for a version MATRIX. Standing
|
||||
# decision (kuhy, 2026-08-28): one toolchain version per repo, always newest,
|
||||
# so a matrix is a finding to delete rather than a range to satisfy.
|
||||
MATRIX: Final = "matrix"
|
||||
|
||||
ECOSYSTEMS: Final = (PUB, PYPI, NPM, CARGO, GOMOD, GITTAG, TOOLCHAIN)
|
||||
ECOSYSTEMS: Final = (PUB, PYPI, NPM, CARGO, GOMOD, GITTAG, MAVEN, TOOLCHAIN)
|
||||
|
||||
# --- Registry endpoints -----------------------------------------------------
|
||||
|
||||
@ -37,6 +38,14 @@ FLUTTER_RELEASES: Final = (
|
||||
"releases_linux.json"
|
||||
)
|
||||
NODE_RELEASES: Final = "https://nodejs.org/dist/index.json"
|
||||
GRADLE_RELEASES: Final = "https://services.gradle.org/versions/current"
|
||||
# Asked in order; a 404 moves on. Google first because AndroidX and the
|
||||
# Android Gradle plugin exist nowhere else, and Central answers 404 fast.
|
||||
MAVEN_REPOS: Final = (
|
||||
"https://dl.google.com/dl/android/maven2/",
|
||||
"https://repo1.maven.org/maven2/",
|
||||
"https://plugins.gradle.org/m2/",
|
||||
)
|
||||
UTILS_TAG_REMOTE: Final = "https://github.com/kuhyx/utils"
|
||||
|
||||
# pnpm 11 quarantines packages younger than this before it will install them,
|
||||
@ -84,6 +93,8 @@ MANIFEST_GLOBS: Final = (
|
||||
".fvmrc",
|
||||
".nvmrc",
|
||||
".python-version",
|
||||
"libs.versions.toml",
|
||||
"gradle-wrapper.properties",
|
||||
)
|
||||
REQUIREMENTS_PATTERN: Final = r"^requirements.*\.txt$"
|
||||
|
||||
|
||||
@ -8,14 +8,21 @@ could ever fix, so reporting it only teaches the user to ignore the gate.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from dep_freshness._tables import EXCLUDED_DIRS, MANIFEST_GLOBS, REQUIREMENTS_PATTERN
|
||||
from dep_freshness.models import Dep
|
||||
from dep_freshness.parsers import (
|
||||
fvm, golang, javascript, pubspec, python, rust, workflow,
|
||||
fvm,
|
||||
golang,
|
||||
gradle,
|
||||
javascript,
|
||||
pubspec,
|
||||
python,
|
||||
rust,
|
||||
workflow,
|
||||
)
|
||||
|
||||
_REQUIREMENTS = re.compile(REQUIREMENTS_PATTERN)
|
||||
@ -29,6 +36,8 @@ _BY_NAME = {
|
||||
".fvmrc": fvm.parse,
|
||||
".nvmrc": javascript.parse_nvmrc,
|
||||
".python-version": python.parse_python_version,
|
||||
gradle.CATALOG_NAME: gradle.parse_catalog,
|
||||
gradle.WRAPPER_NAME: gradle.parse_wrapper,
|
||||
}
|
||||
|
||||
|
||||
|
||||
139
dep_freshness/parsers/gradle.py
Normal file
139
dep_freshness/parsers/gradle.py
Normal file
@ -0,0 +1,139 @@
|
||||
"""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.
|
||||
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 TachiyomiSY) 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 MAVEN, TOOLCHAIN
|
||||
from dep_freshness.models import Dep
|
||||
from dep_freshness.parsers._lines import index
|
||||
from dep_freshness.versions import exact_pin
|
||||
|
||||
CATALOG_NAME = "libs.versions.toml"
|
||||
WRAPPER_NAME = "gradle-wrapper.properties"
|
||||
_DISTRIBUTION = re.compile(
|
||||
r"gradle-(?P<version>\d+(?:\.\d+)*(?:-[0-9A-Za-z.-]+?)?)-(?:bin|all)\.zip"
|
||||
)
|
||||
|
||||
|
||||
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"
|
||||
deps.append(
|
||||
Dep(
|
||||
ecosystem=MAVEN,
|
||||
name=name,
|
||||
constraint=version,
|
||||
path=path,
|
||||
line=line,
|
||||
pinned=exact_pin(version),
|
||||
)
|
||||
)
|
||||
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 []
|
||||
@ -55,12 +55,10 @@ def host_reachable(url: str) -> bool:
|
||||
return _reachable[host]
|
||||
|
||||
|
||||
def get_json(url: str, accept: str | None = None) -> Any:
|
||||
"""Fetch and decode JSON, or raise `Offline` / return None on 404.
|
||||
def get_text(url: str, accept: str | None = None) -> str | None:
|
||||
"""Fetch a body as text, or raise `Offline` / return None on 404.
|
||||
|
||||
`Offline` means "ask the cache"; None means "the registry answered and
|
||||
this package genuinely is not there", which is a real finding rather than
|
||||
a degraded run and must not be papered over with a stale cache entry.
|
||||
Same contract as `get_json`; Maven metadata is XML, not JSON.
|
||||
"""
|
||||
if not host_reachable(url):
|
||||
raise Offline(url)
|
||||
@ -70,10 +68,26 @@ def get_json(url: str, accept: str | None = None) -> Any:
|
||||
request = Request(url, headers=headers)
|
||||
try:
|
||||
with urlopen(request, timeout=HTTP_TIMEOUT) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
return response.read().decode("utf-8")
|
||||
except HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
return None
|
||||
raise Offline(f"{url}: HTTP {exc.code}") from exc
|
||||
except (URLError, TimeoutError, OSError, ValueError) as exc:
|
||||
raise Offline(f"{url}: {exc}") from exc
|
||||
|
||||
|
||||
def get_json(url: str, accept: str | None = None) -> Any:
|
||||
"""Fetch and decode JSON, or raise `Offline` / return None on 404.
|
||||
|
||||
`Offline` means "ask the cache"; None means "the registry answered and
|
||||
this package genuinely is not there", which is a real finding rather than
|
||||
a degraded run and must not be papered over with a stale cache entry.
|
||||
"""
|
||||
body = get_text(url, accept)
|
||||
if body is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(body)
|
||||
except ValueError as exc:
|
||||
raise Offline(f"{url}: {exc}") from exc
|
||||
|
||||
55
dep_freshness/registries/maven.py
Normal file
55
dep_freshness/registries/maven.py
Normal file
@ -0,0 +1,55 @@
|
||||
"""Maven repositories: newest stable of `group:artifact`, from maven-metadata.
|
||||
|
||||
Three repositories, asked in order until one knows the artifact: Google
|
||||
Maven (every `androidx.*` and `com.android.*` artifact lives ONLY there),
|
||||
Maven Central, and the Gradle plugin portal (plugin marker artifacts
|
||||
`<id>:<id>.gradle.plugin`). A 404 means "not here", not "not anywhere", so
|
||||
it moves on; anything else surfaces as `Offline` from the HTTP layer.
|
||||
|
||||
`<latest>` and `<release>` in the metadata are not trusted: AndroidX writes
|
||||
`1.2.0-alpha03` into `<latest>` routinely. The full `<versions>` list goes
|
||||
through `newest_stable` like every other ecosystem.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from dep_freshness._tables import MAVEN_REPOS
|
||||
from dep_freshness.registries.http import get_text
|
||||
from dep_freshness.versions import newest_stable
|
||||
|
||||
|
||||
def metadata_url(repo: str, name: str) -> str | None:
|
||||
"""`.../group/path/artifact/maven-metadata.xml`, or None for a bad name."""
|
||||
group, sep, artifact = name.partition(":")
|
||||
if not sep or not group or not artifact:
|
||||
return None
|
||||
return f"{repo}{group.replace('.', '/')}/{artifact}/maven-metadata.xml"
|
||||
|
||||
|
||||
def versions_in(xml: str) -> list[str]:
|
||||
"""Every `<version>` in a maven-metadata document; [] when unparseable."""
|
||||
try:
|
||||
root = ElementTree.fromstring(xml)
|
||||
except ElementTree.ParseError:
|
||||
return []
|
||||
return [
|
||||
(node.text or "").strip()
|
||||
for node in root.iter("version")
|
||||
if node.text and node.text.strip()
|
||||
]
|
||||
|
||||
|
||||
def latest(name: str) -> str | None:
|
||||
for repo in MAVEN_REPOS:
|
||||
url = metadata_url(repo, name)
|
||||
if url is None:
|
||||
return None
|
||||
body = get_text(url)
|
||||
if body is None:
|
||||
continue
|
||||
stable = newest_stable(versions_in(body))
|
||||
if stable is not None:
|
||||
return stable
|
||||
return None
|
||||
@ -11,6 +11,7 @@ import platform
|
||||
|
||||
from dep_freshness._tables import (
|
||||
FLUTTER_RELEASES,
|
||||
GRADLE_RELEASES,
|
||||
NODE_CHANNEL,
|
||||
NODE_RELEASES,
|
||||
)
|
||||
@ -49,3 +50,11 @@ def node_latest(channel: str = NODE_CHANNEL) -> str | None:
|
||||
def python_installed() -> str:
|
||||
"""The interpreter this gate is running under — the Python target."""
|
||||
return platform.python_version()
|
||||
|
||||
|
||||
def gradle_latest() -> str | None:
|
||||
"""Newest Gradle release, from services.gradle.org (never a nightly/RC)."""
|
||||
payload = get_json(GRADLE_RELEASES)
|
||||
if not payload:
|
||||
return None
|
||||
return newest_stable([payload.get("version")])
|
||||
|
||||
@ -11,11 +11,19 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
|
||||
from dep_freshness._tables import (
|
||||
CARGO, GITTAG, GOMOD, MAX_WORKERS, NPM, PUB, PYPI, TOOLCHAIN,
|
||||
CARGO,
|
||||
GITTAG,
|
||||
GOMOD,
|
||||
MAVEN,
|
||||
MAX_WORKERS,
|
||||
NPM,
|
||||
PUB,
|
||||
PYPI,
|
||||
TOOLCHAIN,
|
||||
)
|
||||
from dep_freshness.cache import Cache
|
||||
from dep_freshness.models import Dep
|
||||
from dep_freshness.registries import cargo, gittag, gomod, npmjs, pub, pypi
|
||||
from dep_freshness.registries import cargo, gittag, gomod, maven, npmjs, pub, pypi
|
||||
from dep_freshness.registries import toolchain as tc
|
||||
from dep_freshness.registries.http import Offline
|
||||
|
||||
@ -26,6 +34,7 @@ _LOOKUP = {
|
||||
CARGO: cargo.latest,
|
||||
GOMOD: gomod.latest,
|
||||
GITTAG: gittag.latest,
|
||||
MAVEN: maven.latest,
|
||||
}
|
||||
|
||||
|
||||
@ -47,6 +56,8 @@ def _toolchain_latest(name: str) -> str | None:
|
||||
return tc.node_latest()
|
||||
if name == "python":
|
||||
return tc.python_installed()
|
||||
if name == "gradle":
|
||||
return tc.gradle_latest()
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@ -18,4 +18,5 @@
|
||||
|package\.json|package-lock\.json|pnpm-lock\.yaml
|
||||
|Cargo\.(toml|lock)|go\.(mod|sum)
|
||||
|\.fvmrc|\.nvmrc|\.python-version
|
||||
|libs\.versions\.toml|gradle-wrapper\.properties
|
||||
|\.github/workflows/.*\.ya?ml)$
|
||||
|
||||
@ -56,7 +56,8 @@ jobs:
|
||||
path: ~/.cache/dep-freshness
|
||||
key:
|
||||
dep-freshness-${{ hashFiles('**/pubspec.yaml', '**/pyproject.toml',
|
||||
'**/requirements*.txt', '**/package.json', '**/Cargo.toml', '**/go.mod') }}
|
||||
'**/requirements*.txt', '**/package.json', '**/Cargo.toml', '**/go.mod',
|
||||
'**/libs.versions.toml') }}
|
||||
restore-keys: dep-freshness-
|
||||
|
||||
- name: Every dependency is on newest stable
|
||||
|
||||
@ -15,7 +15,7 @@ PUBSPEC = "name: demo\ndependencies:\n http: 1.6.0\n"
|
||||
@pytest.mark.parametrize("name", [
|
||||
"pubspec.yaml", "pyproject.toml", "package.json", "Cargo.toml", "go.mod",
|
||||
".fvmrc", ".nvmrc", ".python-version", "requirements.txt",
|
||||
"requirements-dev.txt",
|
||||
"requirements-dev.txt", "libs.versions.toml", "gradle-wrapper.properties",
|
||||
])
|
||||
def test_manifest_names_are_recognised(name):
|
||||
assert is_manifest(Path(name))
|
||||
|
||||
139
dep_freshness/tests/test_gradle_parser.py
Normal file
139
dep_freshness/tests/test_gradle_parser.py
Normal file
@ -0,0 +1,139 @@
|
||||
"""Gradle version catalogs and the wrapper: every shape the fleet's forks use."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dep_freshness.parsers import gradle
|
||||
from dep_freshness.tests.conftest import write
|
||||
|
||||
CATALOG = """\
|
||||
[versions]
|
||||
kotlin = "2.4.0"
|
||||
compose = { strictly = "1.9.0" }
|
||||
loose = { require = "[1.0,2.0)" }
|
||||
preferred = { prefer = "3.3.3" }
|
||||
empty = { }
|
||||
|
||||
[libraries]
|
||||
inline = "io.coil-kt:coil:2.7.0"
|
||||
noversion = "io.coil-kt:coil"
|
||||
byref = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
|
||||
split = { group = "androidx.core", name = "core-ktx", version = "1.17.0" }
|
||||
strict = { module = "androidx.compose:runtime", version.ref = "compose" }
|
||||
ranged = { module = "org.example:ranged", version.ref = "loose" }
|
||||
prefer = { module = "org.example:prefer", version.ref = "preferred" }
|
||||
bom = { module = "org.junit.platform:junit-platform-launcher" }
|
||||
dangling = { module = "org.example:dangling", version.ref = "nope" }
|
||||
emptyref = { module = "org.example:emptyref", version.ref = "empty" }
|
||||
nameless = { version = "1.0.0" }
|
||||
number = 42
|
||||
bare = "justaname"
|
||||
richinline = { module = "org.example:rich", version = { strictly = "0.1.0" } }
|
||||
|
||||
[plugins]
|
||||
agp = { id = "com.android.application", version = "9.3.2" }
|
||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
inline = "org.example.plugin:0.5.0"
|
||||
noversion = "org.example.bare"
|
||||
idless = { version = "1.0.0" }
|
||||
|
||||
[bundles]
|
||||
coil = ["inline"]
|
||||
"""
|
||||
|
||||
|
||||
def _by_name(deps):
|
||||
return {d.name: d for d in deps}
|
||||
|
||||
|
||||
def test_every_library_shape(tmp_path):
|
||||
deps = _by_name(
|
||||
gradle.parse_catalog(write(tmp_path, "libs.versions.toml", CATALOG))
|
||||
)
|
||||
assert deps["io.coil-kt:coil"].pinned == "2.7.0"
|
||||
assert deps["org.jetbrains.kotlin:kotlin-reflect"].pinned == "2.4.0"
|
||||
assert deps["androidx.core:core-ktx"].pinned == "1.17.0"
|
||||
assert deps["androidx.compose:runtime"].pinned == "1.9.0"
|
||||
assert deps["org.example:prefer"].pinned == "3.3.3"
|
||||
assert deps["org.example:rich"].pinned == "0.1.0"
|
||||
assert deps["org.example:ranged"].pinned is None
|
||||
assert deps["org.example:ranged"].constraint == "[1.0,2.0)"
|
||||
assert all(d.ecosystem == "maven" for d in deps.values())
|
||||
|
||||
|
||||
def test_bom_managed_and_broken_entries_are_skipped(tmp_path):
|
||||
deps = _by_name(
|
||||
gradle.parse_catalog(write(tmp_path, "libs.versions.toml", CATALOG))
|
||||
)
|
||||
for absent in (
|
||||
"org.junit.platform:junit-platform-launcher",
|
||||
"org.example:dangling",
|
||||
"org.example:emptyref",
|
||||
":",
|
||||
):
|
||||
assert absent not in deps
|
||||
assert not any(d.name.startswith("io.coil-kt:coil:") for d in deps.values())
|
||||
assert "justaname" not in deps
|
||||
|
||||
|
||||
def test_plugins_resolve_as_marker_artifacts(tmp_path):
|
||||
deps = _by_name(
|
||||
gradle.parse_catalog(write(tmp_path, "libs.versions.toml", CATALOG))
|
||||
)
|
||||
agp = deps["com.android.application:com.android.application.gradle.plugin"]
|
||||
assert agp.pinned == "9.3.2"
|
||||
kt = deps["org.jetbrains.kotlin.jvm:org.jetbrains.kotlin.jvm.gradle.plugin"]
|
||||
assert kt.pinned == "2.4.0"
|
||||
assert deps["org.example.plugin:org.example.plugin.gradle.plugin"].pinned == "0.5.0"
|
||||
assert not any("org.example.bare" in n for n in deps)
|
||||
assert not any(n.startswith(":") for n in deps)
|
||||
|
||||
|
||||
def test_a_version_ref_points_the_fix_at_the_versions_table(tmp_path):
|
||||
path = write(tmp_path, "libs.versions.toml", CATALOG)
|
||||
deps = _by_name(gradle.parse_catalog(path))
|
||||
lines = path.read_text().splitlines()
|
||||
assert lines[deps["org.jetbrains.kotlin:kotlin-reflect"].line - 1].startswith(
|
||||
"kotlin ="
|
||||
)
|
||||
assert lines[deps["io.coil-kt:coil"].line - 1].startswith("inline =")
|
||||
|
||||
|
||||
def test_unreadable_or_malformed_catalog_yields_nothing(tmp_path):
|
||||
assert gradle.parse_catalog(tmp_path / "missing.toml") == []
|
||||
assert gradle.parse_catalog(write(tmp_path, "bad.toml", "[versions\n")) == []
|
||||
|
||||
|
||||
def test_wrapper_distribution_is_the_gradle_toolchain_pin(tmp_path):
|
||||
body = (
|
||||
"distributionBase=GRADLE_USER_HOME\n"
|
||||
"distributionUrl=https\\://services.gradle.org/distributions/gradle-9.6.1-bin.zip\n"
|
||||
)
|
||||
(dep,) = gradle.parse_wrapper(write(tmp_path, "gradle-wrapper.properties", body))
|
||||
assert (dep.ecosystem, dep.name, dep.pinned, dep.line) == (
|
||||
"toolchain",
|
||||
"gradle",
|
||||
"9.6.1",
|
||||
2,
|
||||
)
|
||||
|
||||
|
||||
def test_wrapper_accepts_the_all_distribution_and_prereleases(tmp_path):
|
||||
body = "distributionUrl=https\\://x/gradle-9.7.0-rc-1-all.zip\n"
|
||||
(dep,) = gradle.parse_wrapper(write(tmp_path, "gradle-wrapper.properties", body))
|
||||
assert dep.constraint == "9.7.0-rc-1"
|
||||
|
||||
|
||||
def test_wrapper_without_a_recognisable_url_is_unpinned(tmp_path):
|
||||
body = "distributionUrl=https\\://example.com/custom.zip\n"
|
||||
(dep,) = gradle.parse_wrapper(write(tmp_path, "gradle-wrapper.properties", body))
|
||||
assert dep.constraint == "" and dep.pinned is None
|
||||
|
||||
|
||||
def test_wrapper_without_a_url_or_file_yields_nothing(tmp_path):
|
||||
assert (
|
||||
gradle.parse_wrapper(
|
||||
write(tmp_path, "gradle-wrapper.properties", "zipStoreBase=x\n")
|
||||
)
|
||||
== []
|
||||
)
|
||||
assert gradle.parse_wrapper(tmp_path / "missing.properties") == []
|
||||
109
dep_freshness/tests/test_gradle_registry.py
Normal file
109
dep_freshness/tests/test_gradle_registry.py
Normal file
@ -0,0 +1,109 @@
|
||||
"""Maven metadata across three repositories, and the Gradle release feed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from dep_freshness import discover
|
||||
from dep_freshness.registries import maven
|
||||
from dep_freshness.registries import toolchain as tc
|
||||
from dep_freshness.registries.http import Offline
|
||||
from dep_freshness.resolve import _toolchain_latest
|
||||
from dep_freshness.tests.conftest import write
|
||||
|
||||
GOOGLE, CENTRAL, PORTAL = maven.MAVEN_REPOS
|
||||
|
||||
|
||||
def metadata(*versions: str, latest: str | None = None) -> str:
|
||||
body = "".join(f"<version>{v}</version>" for v in versions)
|
||||
head = f"<latest>{latest}</latest>" if latest else ""
|
||||
return f"<metadata><versioning>{head}<versions>{body}</versions></versioning></metadata>"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repos(monkeypatch):
|
||||
"""Canned bodies per metadata URL; anything unlisted is a 404."""
|
||||
table: dict[str, str] = {}
|
||||
monkeypatch.setattr(maven, "get_text", lambda url, *a, **k: table.get(url))
|
||||
return table
|
||||
|
||||
|
||||
def test_metadata_url_shape():
|
||||
assert (
|
||||
maven.metadata_url(CENTRAL, "io.coil-kt:coil")
|
||||
== f"{CENTRAL}io/coil-kt/coil/maven-metadata.xml"
|
||||
)
|
||||
assert maven.metadata_url(CENTRAL, "nocolon") is None
|
||||
assert maven.metadata_url(CENTRAL, ":artifact") is None
|
||||
|
||||
|
||||
def test_versions_in_ignores_blank_nodes_and_bad_xml():
|
||||
assert maven.versions_in(metadata("1.0", " ", "2.0")) == ["1.0", "2.0"]
|
||||
assert maven.versions_in("<metadata><versions>") == []
|
||||
|
||||
|
||||
def test_google_is_asked_first_and_latest_tag_is_not_trusted(repos):
|
||||
repos[maven.metadata_url(GOOGLE, "androidx.core:core-ktx")] = metadata(
|
||||
"1.16.0", "1.17.0", "1.18.0-alpha03", latest="1.18.0-alpha03"
|
||||
)
|
||||
assert maven.latest("androidx.core:core-ktx") == "1.17.0"
|
||||
|
||||
|
||||
def test_central_answers_when_google_has_no_such_artifact(repos):
|
||||
repos[maven.metadata_url(CENTRAL, "io.coil-kt:coil")] = metadata("2.6.0", "2.7.0")
|
||||
assert maven.latest("io.coil-kt:coil") == "2.7.0"
|
||||
|
||||
|
||||
def test_plugin_markers_come_from_the_portal(repos):
|
||||
name = "org.example.plugin:org.example.plugin.gradle.plugin"
|
||||
repos[maven.metadata_url(PORTAL, name)] = metadata("0.5.0")
|
||||
assert maven.latest(name) == "0.5.0"
|
||||
|
||||
|
||||
def test_a_repo_with_only_prereleases_does_not_stop_the_search(repos):
|
||||
repos[maven.metadata_url(GOOGLE, "g:a")] = metadata("1.0.0-beta01")
|
||||
repos[maven.metadata_url(CENTRAL, "g:a")] = metadata("0.9.0")
|
||||
assert maven.latest("g:a") == "0.9.0"
|
||||
|
||||
|
||||
def test_unknown_everywhere_or_malformed_name_is_none(repos):
|
||||
assert maven.latest("g:nowhere") is None
|
||||
assert maven.latest("bare") is None
|
||||
|
||||
|
||||
def test_offline_propagates_from_the_transport(monkeypatch):
|
||||
def down(*_a, **_k):
|
||||
raise Offline("down")
|
||||
|
||||
monkeypatch.setattr(maven, "get_text", down)
|
||||
with pytest.raises(Offline):
|
||||
maven.latest("g:a")
|
||||
|
||||
|
||||
def test_gradle_latest_from_the_release_feed(monkeypatch):
|
||||
monkeypatch.setattr(tc, "get_json", lambda *_a, **_k: {"version": "9.7.1"})
|
||||
assert tc.gradle_latest() == "9.7.1"
|
||||
monkeypatch.setattr(tc, "get_json", lambda *_a, **_k: {"version": "10.0.0-rc-1"})
|
||||
assert tc.gradle_latest() is None
|
||||
monkeypatch.setattr(tc, "get_json", lambda *_a, **_k: None)
|
||||
assert tc.gradle_latest() is None
|
||||
|
||||
|
||||
def test_the_resolver_routes_gradle_to_the_toolchain_feed(monkeypatch):
|
||||
monkeypatch.setattr(tc, "gradle_latest", lambda: "9.7.1")
|
||||
assert _toolchain_latest("gradle") == "9.7.1"
|
||||
|
||||
|
||||
def test_discovery_routes_catalog_and_wrapper(tmp_path: Path):
|
||||
catalog = write(
|
||||
tmp_path, "gradle/libs.versions.toml", '[libraries]\nc = "g:a:1.0.0"\n'
|
||||
)
|
||||
wrapper = write(
|
||||
tmp_path,
|
||||
"gradle/wrapper/gradle-wrapper.properties",
|
||||
"distributionUrl=https\\://x/gradle-9.6.1-bin.zip\n",
|
||||
)
|
||||
assert [d.name for d in discover.parse_manifest(catalog)] == ["g:a"]
|
||||
assert [d.name for d in discover.parse_manifest(wrapper)] == ["gradle"]
|
||||
@ -120,3 +120,12 @@ class _Response:
|
||||
|
||||
def __exit__(self, *_exc):
|
||||
return False
|
||||
|
||||
|
||||
def test_get_text_returns_the_raw_body_and_none_on_404(monkeypatch):
|
||||
_reachable(monkeypatch)
|
||||
monkeypatch.setattr(http, "urlopen", lambda *a, **k: _Response(b"<x/>"))
|
||||
assert http.get_text("https://repo1.maven.org/x") == "<x/>"
|
||||
monkeypatch.setattr(http, "urlopen", _raiser(
|
||||
HTTPError("u", 404, "Not Found", {}, None)))
|
||||
assert http.get_text("https://repo1.maven.org/x") is None
|
||||
|
||||
Loading…
Reference in New Issue
Block a user