Files
hermes-agent/tests/pm/test_update.py
ethernet cd0f97f833 feat(python): pin bundled runtime to 3.14 everywhere (pm, termux lane, CI, installers)
pm python node: 3.14.7+20260901 (freshest python-build-standalone 3.14
build) for the 6 desktop targets; the bionic row moves from the third-party
TUR python3.11 deb to the official termux-main python_3.14.6-1 deb (which
lags PBS by one patch — pinned manually, documented). All 7 digests fetched
from the live sources (PBS release API + termux-main Packages index).
pm/packages.py: main_bin_rel python3.14, deb_package python, bionic fetch
constant, latest_versions guards bionic (no PBS build exists).

termux lane: PYTHON_ABI cp311->cp314, python3.11->python3.14 paths,
libpython3.11.so->3.14, TARGET_ENV 3.11.15->3.14.6 AND sys_platform
linux->android (CPython 3.13+ reports 'android', docs-verified) — linux-
gated markers no longer admit the termux target. runtime_libs.json needs no
change: every python 3.14.6-1 Depends is already staged.

CI: python-version/--python 3.11->3.14 across all 11 workflows incl. the
uv lockfile-check lane. Installers derive the minor from the lock already;
fallbacks bumped. Sandbox images nikolaik/python-nodejs:python3.11-nodejs20
-> python3.14-nodejs22 (tag exists). runtime_repair fall-forward cap now
tracks the <3.15 requires-python window. Docs/README python version claims
updated.
2026-09-07 14:19:18 -04:00

275 lines
9.4 KiB
Python

"""Pure unit tests for pm.update (no network, no store, no lockfile writes).
The upstream index helpers (github_release_tags, npm_dist_tags, ...) are
network I/O — covered by design, never in tests. The resolution engine is
pure: candidate lists in, Resolved decision out.
"""
from __future__ import annotations
from pm.update import (
Resolved,
best_in_minor,
minor_of,
resolve_best,
resolve_package,
version_key,
)
def _pkg(pkg_name="node", version_style="semver", **latest):
"""A stub package whose latest_versions returns canned per-target lists."""
class _P:
def __init__(self):
self.name = pkg_name
self.version_style = version_style
self._latest = dict(latest)
def latest_versions(self, target, locked=None):
return list(self._latest.get(target, []))
return _P()
# ── version parsing / comparison ───────────────────────────────────────────
def test_version_key_sorts_numeric_and_suffixes():
assert version_key("2.53.0+5") > version_key("2.53.0+3")
assert version_key("3.14.7+20260901") > version_key("3.14.7+20260900")
assert version_key("26.8.1") > version_key("26.7.0")
assert version_key("10362") > version_key("10361")
# prerelease-ish segments sort after numerics
assert version_key("9.0.1") < version_key("9.0.1-rc1")
def test_minor_of():
assert minor_of("26.7.0") == (26, 7)
assert minor_of("3.14.7+20260901") == (3, 14)
assert minor_of("10362") is None # single component
def test_best_in_minor():
versions = ["9.0.3", "9.0.1", "9.1.0", "8.4.9"]
assert best_in_minor(versions, (9, 0)) == "9.0.3"
assert best_in_minor(versions, (9, 1)) == "9.1.0"
assert best_in_minor(versions, (10, 0)) is None
# ── semver resolution ──────────────────────────────────────────────────────
def test_semver_picks_highest_shared_version():
r = resolve_best(
"node",
["win32-x64", "linux-x64"],
{"win32-x64": ["26.8.1", "26.7.0"], "linux-x64": ["26.8.1", "26.7.0"]},
locked="26.7.0",
style="semver",
)
assert r.changed
assert r.version == "26.8.1"
assert r.per_target == {"win32-x64": "26.8.1", "linux-x64": "26.8.1"}
def test_semver_no_update_when_up_to_date():
r = resolve_best(
"node",
["win32-x64"],
{"win32-x64": ["26.7.0"]},
locked="26.7.0",
style="semver",
)
assert not r.changed
assert r.version == "26.7.0"
def test_semver_divergent_targets_no_shared_version():
r = resolve_best(
"gh",
["win32-x64", "linux-x64"],
{"win32-x64": ["2.97.0"], "linux-x64": ["2.96.0"]},
locked="2.95.0",
style="semver",
)
assert r.version is None
assert r.reason == "no shared version"
assert not r.changed
# ── minor-style (ffmpeg) resolution ───────────────────────────────────────
def test_minor_style_shared_minor_with_per_target_patches():
"""The cadence-mismatch case: posix and win32 drift in PATCH. The update
moves to the highest shared major.minor; each target pins its own patch."""
r = resolve_best(
"ffmpeg",
["linux-x64", "win32-x64"],
{"linux-x64": ["9.1.2", "9.0.1"], "win32-x64": ["9.1.0", "9.0.1"]},
locked="9.0.1",
style="minor",
)
assert r.changed
assert r.version == "9.1"
assert r.per_target == {"linux-x64": "9.1.2", "win32-x64": "9.1.0"}
def test_minor_style_no_shared_minor_blocks_update():
"""posix on 9.1, win32 still on 9.0 — no minor every target serves."""
r = resolve_best(
"ffmpeg",
["linux-x64", "win32-x64"],
{"linux-x64": ["9.1.2"], "win32-x64": ["9.0.3"]},
locked="9.0.1",
style="minor",
)
assert r.version is None
assert r.reason == "no shared minor"
assert not r.changed
def test_minor_style_patch_drift_within_shared_minor_is_up_to_date():
"""Same minor, patch drift only — the lockfile version label doesn't
move (patches live in per-target urls)."""
r = resolve_best(
"ffmpeg",
["linux-x64", "win32-x64"],
{"linux-x64": ["9.1.2"], "win32-x64": ["9.1.0"]},
locked="9.1",
style="minor",
)
assert not r.changed
assert r.version == "9.1"
# ── source availability ────────────────────────────────────────────────────
def test_no_source_anywhere():
r = resolve_best("chromium", ["win32-x64"], {"win32-x64": []}, locked="1208+145", style="semver")
assert r.version is None
assert r.reason == "no source"
assert not r.changed
def test_missing_source_for_one_target_skipped():
"""A target whose index is unreachable must not block the others."""
r = resolve_best(
"node",
["win32-x64", "linux-x64"],
{"win32-x64": ["26.8.1"], "linux-x64": []},
locked="26.7.0",
style="semver",
)
assert r.changed
assert r.version == "26.8.1"
# ── resolve_package wiring ─────────────────────────────────────────────────
def test_resolve_package_calls_latest_versions_per_target():
pkg = _pkg(
"node",
**{
"win32-x64": ["26.8.1", "26.7.0"],
"linux-x64": ["26.8.1", "26.7.0"],
},
)
r = resolve_package(pkg, ["win32-x64", "linux-x64"], locked="26.7.0")
assert isinstance(r, Resolved)
assert r.changed
assert r.version == "26.8.1"
# ── llama.app installer bucket helpers (pure, monkeypatched) ───────────────
def test_llama_app_latest_parses_build_number(monkeypatch):
from pm import update as u
calls = []
monkeypatch.setattr(
u,
"_get_text",
lambda url, headers=None: (calls.append((url, headers)) or "b10679\n"),
)
assert u.llama_app_latest() == "10679"
url, headers = calls[0]
assert url.endswith("/resolve/latest")
assert headers == {} # no HF_TOKEN → no auth header
def test_llama_app_latest_honors_hf_token(monkeypatch):
from pm import update as u
monkeypatch.setenv("HF_TOKEN", "hf-secret")
seen = {}
monkeypatch.setattr(u, "_get_text", lambda url, headers=None: (seen.update(headers) or "b10612\n"))
assert u.llama_app_latest() == "10612"
assert seen.get("Authorization") == "Bearer hf-secret"
def test_llama_app_latest_unreachable_returns_none(monkeypatch):
from pm import update as u
monkeypatch.setattr(u, "_get_text", lambda url, headers=None: (_ for _ in ()).throw(OSError("down")))
assert u.llama_app_latest() is None
def test_llama_app_bucket_versions_dedupes_and_sorts(monkeypatch):
from pm import update as u
# The HF tree API ignores offset and returns only the FIRST page —
# the oldest ~1000 paths sorted by path ascending. The helper must
# dedupe (the same build appears on many paths) and sort by build
# number descending so the newest of what the tree can see comes first.
page = [
{"path": "b10326/aarch64/linux/cpu/kk/llama-app.zst"},
{"path": "b10098/x86_64/linux/vulkan/kk/llama-app.zst"},
{"path": "b10326/x86_64/windows/cuda/13/kk/llama-app.exe.zst"}, # dup
{"path": "b9733/aarch64/macos/metal/kk/llama-app.zst"},
{"path": "latest/whatever"}, # non-build path must be ignored
]
monkeypatch.setattr(u, "_get_json", lambda url: page)
assert u.llama_app_bucket_versions() == ["10326", "10098", "9733"]
def test_llama_app_bucket_versions_unreachable_returns_empty(monkeypatch):
from pm import update as u
monkeypatch.setattr(u, "_get_json", lambda url: (_ for _ in ()).throw(OSError("down")))
assert u.llama_app_bucket_versions() == []
def test_llamacpp_latest_versions_prefers_bucket(monkeypatch):
"""The real LlamaCppCpu class: latest from the llama.app `latest`
pointer first, then the bucket tree, GitHub only as a fallback.
Patching targets the from-imported names in pm.packages (a from-import
copies the reference at import time — patching pm.update would miss)."""
import pm.packages as pkgs
monkeypatch.setattr(pkgs, "llama_app_latest", lambda: "10679")
monkeypatch.setattr(pkgs, "llama_app_bucket_versions", lambda: ["10679", "10612", "10362"])
called = []
monkeypatch.setattr(pkgs, "github_release_tags", lambda *a, **k: (called.append(a) or ["99999"]))
pkg = pkgs.LlamaCppCpu()
versions = pkg.latest_versions("linux-x64")
assert versions == ["10679", "10679", "10612", "10362"]
assert called == [] # GitHub never consulted when the bucket answered
def test_llamacpp_latest_versions_github_fallback(monkeypatch):
"""Bucket unreachable → fall back to the GitHub releases tags."""
import pm.packages as pkgs
monkeypatch.setattr(pkgs, "llama_app_latest", lambda: None)
monkeypatch.setattr(pkgs, "llama_app_bucket_versions", lambda: [])
monkeypatch.setattr(pkgs, "github_release_tags", lambda *a, **k: ["10362", "10217"])
pkg = pkgs.LlamaCppCpu()
assert pkg.latest_versions("linux-x64") == ["10362", "10217"]