"""An update reads each successful index/artifact once, not once per target.""" from __future__ import annotations from argparse import Namespace from collections import Counter import hashlib import json from urllib.error import HTTPError from urllib.parse import urlsplit import pytest from pm import cli, downloader, packages, update from pm.lock import Lockfile from pm.registry import get_package from pm.store import ALL_TARGETS from tests.pm._fixtures import make_tar from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401 @pytest.fixture def upstream(dl_server, monkeypatch): from hermes_cli import urllib_security calls = [] failures = {} original_get = RangeHandler.do_GET def record(handler): calls.append((handler.path, handler.headers.get("Authorization"))) pending = failures.get(handler.path, []) if pending: handler.send_error(pending.pop(0)) return original_get(handler) def route(request): parsed = urlsplit(request.full_url) request.full_url = url(dl_server, parsed.path + (f"?{parsed.query}" if parsed.query else "")) return request real_open = urllib_security.open_credentialed_url real_artifact_open = downloader._OPENER.open monkeypatch.setattr(RangeHandler, "do_GET", record) monkeypatch.setattr(urllib_security, "open_credentialed_url", lambda req, **kw: real_open(route(req), **kw)) monkeypatch.setattr(downloader._OPENER, "open", lambda req, **kw: real_artifact_open(route(req), **kw)) return calls, failures @pytest.mark.parametrize("name", ["npm", "node", "uv", "python", "llamacpp-cpu", "ffmpeg"]) def test_resolution_reuses_successful_responses_but_refreshes_next_operation(upstream, monkeypatch, tmp_path, name): calls, failures = upstream package = get_package(name) targets = [t for t in ALL_TARGETS if package.missing_reason(t) is None] for generation in (1, 2): monkeypatch.setenv("HERMES_HOME", str(tmp_path / f"profile-{generation}")) monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / f"store-{generation}")) monkeypatch.setenv("GH_TOKEN", f"github-{generation}") monkeypatch.setenv("HF_TOKEN", f"huggingface-{generation}") calls.clear() payloads = {} expected_targets = targets version = f"2.0.{generation}" locked = "1.0.0" artifacts = None if name == "npm": payloads["/-/package/npm/dist-tags"] = {"latest": version} elif name == "node": payloads["/dist/index.json"] = [{"version": f"v{version}"}, {"version": "nightly"}] elif name == "uv": # Three populated pages exercise the real pagination loop. for page in range(1, 4): payloads[f"/repos/astral-sh/uv/releases?per_page=30&page={page}"] = [ {"tag_name": version}, *[{"tag_name": "9.0.0", "prerelease": True}] * 29, ] elif name == "python": version = f"3.14.{generation}+20990102" locked = "3.14.0+20990101" # Only one architecture is advertised: sharing JSON must not share its candidates. expected_targets = ["linux-x64"] payloads["/repos/astral-sh/python-build-standalone/releases?per_page=30&page=1"] = [{ "tag_name": "20990102", "assets": [ {"name": f"cpython-{version}-x86_64-unknown-linux-gnu-install_only.tar.gz"}, {"name": "cpython-3.15.0+20990102-aarch64-apple-darwin-install_only.tar.gz"}, ], }] elif name == "llamacpp-cpu": version = str(100 + generation) locked = "100" payloads["/buckets/ggml-org/install.sh/resolve/latest"] = f"b{version}\n" payloads["/api/buckets/ggml-org/install.sh/tree?limit=1000&offset=0"] = [] else: locked = version = "9.1.2" artifacts = {} expected_targets = [t for t in targets if t != "linux-arm64-bionic"] payloads["/"] = "\n".join( f'build' for osname in ("linux", "macos") for arch in ("amd64", "arm64") ) btbn_assets = [] for arch in ("64", "arm64"): btbn_assets.append( {"name": f"ffmpeg-n{version}-1-gabcdef-win{arch}-gpl-9.1.zip"}) # Linux comes from BtbN too (Windows .zip, Linux .tar.xz). btbn_assets.append( {"name": f"ffmpeg-n{version}-1-gabcdef-linux{arch}-gpl-9.1.tar.xz"}) btbn_tag = f"autobuild-2026-{generation:02d}-28-12-00" payloads["/repos/BtbN/FFmpeg-Builds/releases?per_page=30&page=1"] = [ # Newest tag is the in-progress month's daily, never pinned. {"tag_name": "autobuild-2026-12-01-12-00", "assets": []}, {"tag_name": btbn_tag, "assets": btbn_assets}, ] RangeHandler.payloads = {path: (value if isinstance(value, str) else json.dumps(value)).encode() for path, value in payloads.items()} # A failed transfer still retries; only the eventual success is reusable. retry_path = next(iter(payloads)) failures[retry_path] = [503] decision = update.resolve_package(package, targets, locked, artifacts=artifacts) assert decision.version == version assert decision.per_target == dict.fromkeys(expected_targets, version) assert decision.changed if name == "ffmpeg": assert set(decision.artifact_updates) == set(expected_targets) assert all(f"/{generation}_{version}/" in urls[0] or f"/{btbn_tag}/" in urls[0] for urls in decision.artifact_updates.values()) expected = Counter(dict.fromkeys(payloads, 1)) expected[retry_path] += 1 assert Counter(path for path, _ in calls) == expected for path, auth in calls: token = (f"github-{generation}" if path.startswith("/repos/") else f"huggingface-{generation}" if "ggml-org/install.sh" in path else None) assert auth == (f"Bearer {token}" if token else None) # Recovery semantics differ: npm propagates, while llama's optional pointer # falls back and must try again on the next target, not memoize its failure. calls.clear() failures[retry_path] = [404] if name == "npm": with pytest.raises(HTTPError, match="404"): update.resolve_package(package, targets, locked) assert len(calls) == 1 assert update.resolve_package(package, targets, locked).version == version assert Counter(path for path, _ in calls) == {retry_path: 2} elif name == "llamacpp-cpu": fallback = "/repos/ggml-org/llama.cpp/releases?per_page=30&page=1" RangeHandler.payloads[fallback] = json.dumps([{"tag_name": f"b{version}"}]).encode() assert update.resolve_package(package, targets, locked).version == version assert Counter(path for path, _ in calls) == {**expected, fallback: 1} elif name == "uv": failures.clear() endpoint = "https://api.github.com" + retry_path with update.reuse_index_responses(): for credential in ("first", "second"): monkeypatch.setenv("GH_TOKEN", credential) for _ in range(2): assert update._get_json(endpoint) == payloads[retry_path] assert calls == [(retry_path, "Bearer first"), (retry_path, "Bearer second")] elif name == "ffmpeg": # Manual re-pinning also needs a short-lived index scope after removing # the old process-wide FFmpeg indexes. Pin real bytes into a real lock. failures.clear() monkeypatch.setattr(package, "gaps", {**package.gaps, "linux-arm64-bionic": "separate supplier"}) body = b"advertised ffmpeg artifact" for urls in decision.artifact_updates.values(): RangeHandler.payloads[urlsplit(urls[0]).path] = body lock = Lockfile(tmp_path / "lock.json") monkeypatch.setattr(cli, "_lockfile", lambda: lock) assert cli.cmd_lock(Namespace(name=name, version=version)) == 0 pinned = Lockfile(lock.path) assert pinned.version(name) == version for target, urls in decision.artifact_updates.items(): assert pinned.artifacts(name, target) == [{"url": urls[0], "sha256": hashlib.sha256(body).hexdigest()}] assert Counter(path for path, _ in calls) == dict.fromkeys(RangeHandler.payloads, 1) @pytest.mark.parametrize("pointer, tree, expected", [ ("b10679\n", [{"path": "b10326/linux"}, {"path": "b10098/win"}, {"path": "b10326/mac"}, {"path": "b9733/mac"}, {"path": "latest/other"}], ["10679", "10326", "10098", "9733"]), (None, [], ["10362", "10217"]), ("malformed", [{"path": "not-a-build"}, {}], ["10362", "10217"]), ]) def test_llama_real_index_fallback(upstream, monkeypatch, pointer, tree, expected): calls, _ = upstream monkeypatch.delenv("HF_TOKEN", raising=False) monkeypatch.delenv("GH_TOKEN", raising=False) monkeypatch.delenv("GITHUB_TOKEN", raising=False) latest = "/buckets/ggml-org/install.sh/resolve/latest" if pointer is not None: RangeHandler.payloads[latest] = pointer.encode() RangeHandler.payloads["/api/buckets/ggml-org/install.sh/tree?limit=1000&offset=0"] = json.dumps(tree).encode() github = "/repos/ggml-org/llama.cpp/releases?per_page=30&page=1" RangeHandler.payloads[github] = b'[{"tag_name":"b10362"},{"tag_name":"b10217"}]' assert packages.LlamaCppCpu().latest_versions("linux-x64") == expected assert all(auth is None for _, auth in calls) assert any(path == github for path, _ in calls) is (pointer != "b10679\n") def test_pinning_hashes_new_npm_url_once_and_keeps_existing_rows(upstream, tmp_path, monkeypatch): calls, failures = upstream monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "store")) package = packages.Npm() version = "2.0.0" artifact_url = package.fetch_url(version, ALL_TARGETS[0]) artifact_path = urlsplit(artifact_url).path filename, digest = make_tar(tmp_path, "npm.tgz", {"package/package.json": '{"version":"2.0.0"}'}) RangeHandler.payloads[artifact_path] = (tmp_path / filename).read_bytes() old = {"url": package.fetch_url("1.0.0", ALL_TARGETS[0]), "sha256": "a" * 64} current = {"any": old, "unresolved-target": old} decision = update.Resolved("npm", "1.0.0", "semver", version, per_target=dict.fromkeys(ALL_TARGETS, version)) pinned = cli._pin_artifacts(package, decision, current) assert current == {"any": old, "unresolved-target": old} assert pinned == {**current, **dict.fromkeys(ALL_TARGETS, {"url": artifact_url, "sha256": digest})} assert Counter(path for path, _ in calls) == {artifact_path: 1} # Existing pins need no download; a separate pin invocation must not retain new hashes. calls.clear() assert cli._pin_artifacts(package, decision, pinned) == pinned assert not calls replacement = b"changed upstream bytes" RangeHandler.payloads[artifact_path] = replacement failures[artifact_path] = [404] with pytest.raises(HTTPError, match="404"): cli._pin_artifacts(package, decision, current) assert current == {"any": old, "unresolved-target": old} calls.clear() repinned = cli._pin_artifacts(package, decision, current) assert repinned[ALL_TARGETS[-1]]["sha256"] == hashlib.sha256(replacement).hexdigest() assert Counter(path for path, _ in calls) == {artifact_path: 1}