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:
Krzysztof Rudnicki 2026-09-12 19:10:42 +02:00
parent 3b5c247f05
commit a4ea305164
13 changed files with 520 additions and 13 deletions

View File

@ -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$"

View File

@ -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,
}

View 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 []

View File

@ -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

View 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

View File

@ -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")])

View File

@ -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

View File

@ -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)$

View File

@ -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

View File

@ -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))

View 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") == []

View 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"]

View File

@ -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