diff --git a/dep_freshness/_tables.py b/dep_freshness/_tables.py index 33cb9e7..3eabe3e 100644 --- a/dep_freshness/_tables.py +++ b/dep_freshness/_tables.py @@ -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$" diff --git a/dep_freshness/discover.py b/dep_freshness/discover.py index 407239b..3bf16b3 100644 --- a/dep_freshness/discover.py +++ b/dep_freshness/discover.py @@ -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, } diff --git a/dep_freshness/parsers/gradle.py b/dep_freshness/parsers/gradle.py new file mode 100644 index 0000000..1bba9cf --- /dev/null +++ b/dep_freshness/parsers/gradle.py @@ -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\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 [] diff --git a/dep_freshness/registries/http.py b/dep_freshness/registries/http.py index 02cb7a9..2f60c96 100644 --- a/dep_freshness/registries/http.py +++ b/dep_freshness/registries/http.py @@ -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 diff --git a/dep_freshness/registries/maven.py b/dep_freshness/registries/maven.py new file mode 100644 index 0000000..f99bafe --- /dev/null +++ b/dep_freshness/registries/maven.py @@ -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 +`:.gradle.plugin`). A 404 means "not here", not "not anywhere", so +it moves on; anything else surfaces as `Offline` from the HTTP layer. + +`` and `` in the metadata are not trusted: AndroidX writes +`1.2.0-alpha03` into `` routinely. The full `` 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 `` 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 diff --git a/dep_freshness/registries/toolchain.py b/dep_freshness/registries/toolchain.py index f07d2ee..bcf211e 100644 --- a/dep_freshness/registries/toolchain.py +++ b/dep_freshness/registries/toolchain.py @@ -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")]) diff --git a/dep_freshness/resolve.py b/dep_freshness/resolve.py index e4b392a..c74e30f 100644 --- a/dep_freshness/resolve.py +++ b/dep_freshness/resolve.py @@ -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 diff --git a/dep_freshness/templates/precommit-hook.yaml b/dep_freshness/templates/precommit-hook.yaml index dc8c3cf..c7a5457 100644 --- a/dep_freshness/templates/precommit-hook.yaml +++ b/dep_freshness/templates/precommit-hook.yaml @@ -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)$ diff --git a/dep_freshness/templates/workflow.yml b/dep_freshness/templates/workflow.yml index 2c08708..856c6e7 100644 --- a/dep_freshness/templates/workflow.yml +++ b/dep_freshness/templates/workflow.yml @@ -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 diff --git a/dep_freshness/tests/test_discover.py b/dep_freshness/tests/test_discover.py index a569ffe..2985755 100644 --- a/dep_freshness/tests/test_discover.py +++ b/dep_freshness/tests/test_discover.py @@ -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)) diff --git a/dep_freshness/tests/test_gradle_parser.py b/dep_freshness/tests/test_gradle_parser.py new file mode 100644 index 0000000..6fa21d0 --- /dev/null +++ b/dep_freshness/tests/test_gradle_parser.py @@ -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") == [] diff --git a/dep_freshness/tests/test_gradle_registry.py b/dep_freshness/tests/test_gradle_registry.py new file mode 100644 index 0000000..6271624 --- /dev/null +++ b/dep_freshness/tests/test_gradle_registry.py @@ -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"{v}" for v in versions) + head = f"{latest}" if latest else "" + return f"{head}{body}" + + +@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("") == [] + + +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"] diff --git a/dep_freshness/tests/test_http.py b/dep_freshness/tests/test_http.py index d21a3f5..47b3cf4 100644 --- a/dep_freshness/tests/test_http.py +++ b/dep_freshness/tests/test_http.py @@ -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"")) + assert http.get_text("https://repo1.maven.org/x") == "" + monkeypatch.setattr(http, "urlopen", _raiser( + HTTPError("u", 404, "Not Found", {}, None))) + assert http.get_text("https://repo1.maven.org/x") is None