diff --git a/EvoScientist/update_check.py b/EvoScientist/update_check.py index 41200f1..c4c3c33 100644 --- a/EvoScientist/update_check.py +++ b/EvoScientist/update_check.py @@ -3,13 +3,28 @@ Compares the installed version against PyPI and caches the result (see ``CACHE_TTL``). All errors are silently swallowed so startup is never blocked or degraded. + +Also exposes a Gitea-based checker (``get_update_info``) used by the +``/internal/system/version`` HTTP route. It resolves "latest" as the +max semver across the instance's releases and tags — Gitea orders +``releases/latest`` by tag creation date, not semver, so a single +endpoint cannot be trusted. + +``download_update`` stages a release artifact under the update staging +dir (``~/.evoscientist/updates/`` by default) and returns the suggested +install command; it never applies the update itself. """ from __future__ import annotations +import hashlib import json import logging +import os import time +from pathlib import Path +from typing import Any, TypedDict +from urllib.parse import urlparse from .config.settings import get_config_dir @@ -91,3 +106,343 @@ def is_update_available() -> tuple[bool, str | None]: logger.debug("Failed to compare versions", exc_info=True) return False, None + + +# --------------------------------------------------------------------------- +# Gitea-based checker (powers /internal/system/version) +# --------------------------------------------------------------------------- + +GITEA_CACHE_TTL = 1_200 # 20 minutes + +_UPDATE_CACHE: dict[str, Any] = {"info": None, "fetched_at": 0.0} + + +class UpdateInfo(TypedDict): + current_version: str + latest_version: str + has_update: bool + release_url: str | None + release_notes: str | None + published_at: str | None + cached: bool + warning: str | None + + +def _semver_key(v: str) -> tuple[int, int, int]: + """Map a version string to a comparable 3-tuple; junk segments count as 0.""" + parts = v.strip().lstrip("vV").split(".")[:3] + out = [] + for p in parts: + try: + out.append(int(p)) + except ValueError: + out.append(0) + while len(out) < 3: + out.append(0) + return tuple(out) # type: ignore[return-value] + + +def _gitea_base() -> str: + return os.environ.get("EVOSCIENTIST_UPDATE_BASE_URL", "https://git.foksai.com").rstrip("/") + + +def _gitea_repo() -> str: + return os.environ.get("EVOSCIENTIST_UPDATE_REPO", "ouyangbo/EvoScientist") + + +def _http_get_json(url: str, *, timeout: float = 10.0) -> Any: + import httpx + + headers = {"User-Agent": "EvoScientist update-check"} + token = os.environ.get("EVOSCIENTIST_UPDATE_TOKEN") + if token: + headers["Authorization"] = f"token {token}" + resp = httpx.get(url, headers=headers, timeout=timeout, follow_redirects=True) + if resp.status_code == 404: + return None + resp.raise_for_status() + return resp.json() + + +def _current_version() -> str: + try: + return _installed_version() + except Exception: + return "0.0.0-dev" + + +def _fetch_latest() -> tuple[str, dict | None]: + """Return (latest_version, release_dict_or_None) as max semver over releases+tags.""" + base, repo = _gitea_base(), _gitea_repo() + releases = _http_get_json(f"{base}/api/v1/repos/{repo}/releases?limit=20") or [] + tags = _http_get_json(f"{base}/api/v1/repos/{repo}/tags?limit=20") or [] + + candidates: list[tuple[tuple[int, int, int], str, dict | None]] = [] + for r in releases: + tag = r.get("tag_name", "") + if tag: + candidates.append((_semver_key(tag), tag, r)) + for t in tags: + name = t.get("name", "") + if name: + candidates.append((_semver_key(name), name, None)) + + if not candidates: + return _current_version(), None + + candidates.sort(key=lambda c: c[0]) + _key, tag, release = candidates[-1] + if release is None: + # the newest candidate may still have a release lower in the list; + # prefer its metadata only when it matches the winning tag + release = next((r for k, tg, r in candidates if r and tg == tag), None) + return tag.lstrip("vV"), release + + +def get_update_info(*, force: bool = False) -> UpdateInfo: + """Resolve current vs latest published version, with a 20-minute cache.""" + current = _current_version() + + def stale(latest: str | None = None, warning: str | None = None, cached: bool = False) -> UpdateInfo: + return UpdateInfo( + current_version=current, + latest_version=latest or current, + has_update=False, + release_url=None, + release_notes=None, + published_at=None, + cached=cached, + warning=warning, + ) + + if os.environ.get("EVOSCIENTIST_UPDATE_CHECK_DISABLED") == "1": + return stale() + + now = time.time() + cached_info: UpdateInfo | None = _UPDATE_CACHE["info"] + if cached_info is not None and not force: + age = now - _UPDATE_CACHE["fetched_at"] + if age < GITEA_CACHE_TTL: + return UpdateInfo( + **{**cached_info, "cached": True, "current_version": current} + ) + + try: + latest, release = _fetch_latest() + except Exception as exc: # network/HTTP failure: serve stale cache if any + logger.debug("Gitea update check failed", exc_info=True) + if cached_info is not None: + return UpdateInfo( + **{ + **cached_info, + "cached": True, + "current_version": current, + "warning": f"update check failed: {exc}", + } + ) + return stale(warning=f"update check failed: {exc}") + + info = UpdateInfo( + current_version=current, + latest_version=latest, + has_update=_semver_key(latest) > _semver_key(current), + release_url=release.get("html_url") if release else None, + release_notes=release.get("body") if release else None, + published_at=release.get("published_at") if release else None, + cached=False, + warning=None, + ) + _UPDATE_CACHE["info"] = info + _UPDATE_CACHE["fetched_at"] = now + return info + + +# --------------------------------------------------------------------------- +# Update download (powers POST /internal/system/version/download) +# --------------------------------------------------------------------------- + +MAX_DOWNLOAD_BYTES = 200 * 1024 * 1024 + + +class UpdateDownloadError(RuntimeError): + """A user-safe download failure (message may reach the browser).""" + + +class DownloadResult(TypedDict): + version: str + file: str + path: str + suggested_command: str + + +def _staging_dir() -> Path: + override = os.environ.get("EVOSCIENTIST_UPDATE_STAGING_DIR", "").strip() + if override: + return Path(override) + return get_config_dir() / "updates" + + +def _updates_dir(version: str) -> Path: + return _staging_dir() / f"v{version}" + + +def _http_download( + url: str, dest: Path, *, max_bytes: int, timeout: float = 120.0 +) -> Path: + import httpx + + headers = {"User-Agent": "EvoScientist update-check"} + token = os.environ.get("EVOSCIENTIST_UPDATE_TOKEN") + if token: + headers["Authorization"] = f"token {token}" + with httpx.stream( + "GET", url, headers=headers, timeout=timeout, follow_redirects=True + ) as resp: + resp.raise_for_status() + total = 0 + with open(dest, "wb") as fh: + for chunk in resp.iter_bytes(): + total += len(chunk) + if total > max_bytes: + raise UpdateDownloadError( + f"download exceeds the {max_bytes}-byte cap" + ) + fh.write(chunk) + return dest + + +def _check_asset_url(url: str) -> None: + """SSRF guard: only download from the configured Gitea host (any port).""" + if urlparse(url).hostname != urlparse(_gitea_base()).hostname: + raise UpdateDownloadError(f"download URL host is not allowed: {url!r}") + + +def _pick_asset(release: dict) -> tuple[str, str]: + """Choose (filename, url): wheel > sdist > repo tarball.""" + assets = release.get("assets") or [] + wheels = [a for a in assets if str(a.get("name", "")).endswith(".whl")] + sdists = [a for a in assets if str(a.get("name", "")).endswith(".tar.gz")] + for asset in (*wheels, *sdists): + return asset["name"], asset["browser_download_url"] + tarball = release.get("tarball_url") + if tarball: + return f"{release.get('tag_name', 'release')}.tar.gz", tarball + raise UpdateDownloadError("release has no downloadable assets") + + +def _verify_local_checksum(sums_path: Path, filename: str, path: Path) -> None: + """Verify path against the checksums.txt entry for filename (if listed).""" + expected = None + for line in sums_path.read_text(encoding="utf-8").splitlines(): + parts = line.split() + if len(parts) == 2 and parts[1] == filename: + expected = parts[0] + break + if expected is None: + return # artifact not listed — nothing to verify against + actual = hashlib.sha256(path.read_bytes()).hexdigest() + if actual != expected: + raise UpdateDownloadError(f"checksum mismatch for {filename}") + + +def _verify_checksum(release: dict, updates: Path, filename: str, path: Path) -> None: + """If the release ships checksums.txt with an entry for filename, verify it.""" + sums = next( + (a for a in (release.get("assets") or []) if a.get("name") == "checksums.txt"), + None, + ) + if sums is None: + return + _check_asset_url(sums["browser_download_url"]) + sums_path = updates / "checksums.txt" + _http_download(sums["browser_download_url"], sums_path, max_bytes=MAX_DOWNLOAD_BYTES) + _verify_local_checksum(sums_path, filename, path) + + +def _find_verified_local_artifact(updates: Path) -> tuple[str, Path] | None: + """Return (filename, path) of a staged artifact that passes checksums.txt.""" + sums_path = updates / "checksums.txt" + if not sums_path.exists(): + return None + for line in sums_path.read_text(encoding="utf-8").splitlines(): + parts = line.split() + if len(parts) != 2: + continue + filename = parts[1] + if not (filename.endswith(".whl") or filename.endswith(".tar.gz")): + continue + dest = updates / filename + if not dest.exists(): + continue + try: + _verify_local_checksum(sums_path, filename, dest) + except UpdateDownloadError: + dest.unlink(missing_ok=True) # corrupt staging — fall through to download + continue + return filename, dest + return None + + +def _resolve_release(version: str | None) -> tuple[str, dict]: + base, repo = _gitea_base(), _gitea_repo() + if version is not None: + tag = version if version.startswith(("v", "V")) else f"v{version}" + release = _http_get_json(f"{base}/api/v1/repos/{repo}/releases/tags/{tag}") + if not release: + raise UpdateDownloadError(f"no release found for version {version}") + return tag.lstrip("vV"), release + latest, release = _fetch_latest() + if release is None: + # newest candidate is a bare tag — fall back to its source archive + release = { + "tag_name": f"v{latest}", + "assets": [], + "tarball_url": f"{base}/api/v1/repos/{repo}/archive/v{latest}.tar.gz", + } + return latest, release + + +def download_update(version: str | None = None) -> DownloadResult: + """Stage a release artifact locally; never installs it.""" + try: + resolved_version, release = _resolve_release(version) + except UpdateDownloadError: + raise + except Exception as exc: + raise UpdateDownloadError(f"could not resolve release: {exc}") from exc + + updates = _updates_dir(resolved_version) + updates.mkdir(parents=True, exist_ok=True) + + reused = _find_verified_local_artifact(updates) + if reused is not None: + filename, dest = reused + return DownloadResult( + version=resolved_version, + file=filename, + path=str(dest), + suggested_command=f"uv pip install {dest} # then restart the backend", + ) + + try: + filename, url = _pick_asset(release) + _check_asset_url(url) + except UpdateDownloadError: + raise + except Exception as exc: + raise UpdateDownloadError(f"could not resolve release: {exc}") from exc + + dest = updates / filename + try: + _http_download(url, dest, max_bytes=MAX_DOWNLOAD_BYTES) + _verify_checksum(release, updates, filename, dest) + except Exception: + dest.unlink(missing_ok=True) + raise + + return DownloadResult( + version=resolved_version, + file=filename, + path=str(dest), + suggested_command=f"uv pip install {dest} # then restart the backend", + ) diff --git a/tests/test_update_check.py b/tests/test_update_check.py index 29914e1..b7245fe 100644 --- a/tests/test_update_check.py +++ b/tests/test_update_check.py @@ -4,6 +4,9 @@ import json import time from unittest.mock import MagicMock, patch +import pytest + +from EvoScientist import update_check from EvoScientist.update_check import ( CACHE_TTL, _parse_version, @@ -155,3 +158,341 @@ class TestIsUpdateAvailable: ): available, _latest = is_update_available() assert available is False + + +@pytest.fixture(autouse=True) +def _reset_gitea_cache(monkeypatch): + monkeypatch.setattr(update_check, "_UPDATE_CACHE", {"info": None, "fetched_at": 0.0}) + for var in ( + "EVOSCIENTIST_UPDATE_BASE_URL", + "EVOSCIENTIST_UPDATE_REPO", + "EVOSCIENTIST_UPDATE_TOKEN", + "EVOSCIENTIST_UPDATE_CHECK_DISABLED", + ): + monkeypatch.delenv(var, raising=False) + yield + + +def _release(tag, **kw): + r = { + "tag_name": tag, + "html_url": f"https://git.foksai.com/ouyangbo/EvoScientist/releases/tag/{tag}", + "body": f"notes for {tag}", + "published_at": "2026-08-01T00:00:00Z", + "assets": [], + "tarball_url": f"https://git.foksai.com/api/v1/repos/ouyangbo/EvoScientist/archive/{tag}.tar.gz", + } + r.update(kw) + return r + + +class TestSemverKey: + def test_ordering(self): + assert update_check._semver_key("v0.1.19") < update_check._semver_key("0.2.2") + assert update_check._semver_key("1.0.0") > update_check._semver_key("0.9.9") + + def test_equal(self): + assert update_check._semver_key("v0.2.2") == update_check._semver_key("0.2.2") + + def test_malformed_segments_are_zero(self): + assert update_check._semver_key("0.2.x") == update_check._semver_key("0.2.0") + assert update_check._semver_key("garbage") == (0, 0, 0) + + def test_short_versions_pad(self): + assert update_check._semver_key("1.2") == (1, 2, 0) + + +class TestGetUpdateInfo: + def _patch_fetch(self, monkeypatch, releases, tags): + def fake_get(url, **kw): + if "/releases" in url: + return releases + if "/tags" in url: + return [{"name": t} for t in tags] + raise AssertionError(f"unexpected url {url}") + + monkeypatch.setattr(update_check, "_http_get_json", fake_get) + + def test_latest_from_release(self, monkeypatch): + self._patch_fetch(monkeypatch, [_release("v9.9.9")], ["v0.1.0"]) + with patch("EvoScientist.update_check._installed_version", return_value="0.2.2"): + info = update_check.get_update_info() + assert info["latest_version"] == "9.9.9" + assert info["has_update"] is True + assert info["release_url"].endswith("/v9.9.9") + assert info["release_notes"] == "notes for v9.9.9" + assert info["warning"] is None + assert info["cached"] is False + + def test_tag_newer_than_release_wins(self, monkeypatch): + # Gitea orders releases/latest by tag creation date, not semver; + # the checker must take the max across releases AND tags. + self._patch_fetch(monkeypatch, [_release("v0.1.19")], ["v0.1.20", "v0.2.2"]) + with patch("EvoScientist.update_check._installed_version", return_value="0.2.1"): + info = update_check.get_update_info() + assert info["latest_version"] == "0.2.2" + assert info["has_update"] is True + # tag-only version has no release metadata + assert info["release_url"] is None + + def test_no_releases_no_tags(self, monkeypatch): + self._patch_fetch(monkeypatch, [], []) + with patch("EvoScientist.update_check._installed_version", return_value="0.2.2"): + info = update_check.get_update_info() + assert info["latest_version"] == "0.2.2" + assert info["has_update"] is False + + def test_up_to_date(self, monkeypatch): + self._patch_fetch(monkeypatch, [_release("v0.2.2")], []) + with patch("EvoScientist.update_check._installed_version", return_value="0.2.2"): + info = update_check.get_update_info() + assert info["has_update"] is False + + def test_cache_within_ttl(self, monkeypatch): + calls = [] + + def fake_get(url, **kw): + calls.append(url) + return [_release("v9.9.9")] if "/releases" in url else [] + + monkeypatch.setattr(update_check, "_http_get_json", fake_get) + with patch("EvoScientist.update_check._installed_version", return_value="0.2.2"): + first = update_check.get_update_info() + second = update_check.get_update_info() + assert len(calls) == 2 # releases + tags, once + assert first["cached"] is False + assert second["cached"] is True + + def test_force_bypasses_cache(self, monkeypatch): + calls = [] + + def fake_get(url, **kw): + calls.append(url) + return [_release("v9.9.9")] if "/releases" in url else [] + + monkeypatch.setattr(update_check, "_http_get_json", fake_get) + with patch("EvoScientist.update_check._installed_version", return_value="0.2.2"): + update_check.get_update_info() + forced = update_check.get_update_info(force=True) + assert len(calls) == 4 + assert forced["cached"] is False + + def test_network_error_without_cache(self, monkeypatch): + def boom(url, **kw): + raise OSError("network down") + + monkeypatch.setattr(update_check, "_http_get_json", boom) + with patch("EvoScientist.update_check._installed_version", return_value="0.2.2"): + info = update_check.get_update_info() + assert info["has_update"] is False + assert info["latest_version"] == "0.2.2" + assert info["warning"] + + def test_network_error_serves_stale_cache(self, monkeypatch): + self._patch_fetch(monkeypatch, [_release("v9.9.9")], []) + with patch("EvoScientist.update_check._installed_version", return_value="0.2.2"): + update_check.get_update_info() + + def boom(url, **kw): + raise OSError("network down") + + monkeypatch.setattr(update_check, "_http_get_json", boom) + monkeypatch.setattr( + update_check, + "_UPDATE_CACHE", + {**update_check._UPDATE_CACHE, "fetched_at": time.time() - 10_000}, + ) + with patch("EvoScientist.update_check._installed_version", return_value="0.2.2"): + info = update_check.get_update_info() + assert info["latest_version"] == "9.9.9" + assert info["cached"] is True + assert info["warning"] + + def test_disabled_env_short_circuits(self, monkeypatch): + monkeypatch.setenv("EVOSCIENTIST_UPDATE_CHECK_DISABLED", "1") + + def boom(url, **kw): + raise AssertionError("network must not be touched") + + monkeypatch.setattr(update_check, "_http_get_json", boom) + with patch("EvoScientist.update_check._installed_version", return_value="0.2.2"): + info = update_check.get_update_info() + assert info["latest_version"] == "0.2.2" + assert info["has_update"] is False + + +def _asset(name, url=None): + return { + "name": name, + "browser_download_url": url + or f"https://git.foksai.com:8443/attachments/{name}-uuid", + } + + +class TestDownloadUpdate: + def _patch(self, monkeypatch, tmp_path, releases, files=None): + monkeypatch.setenv("EVOSCIENTIST_UPDATE_STAGING_DIR", str(tmp_path)) + monkeypatch.setattr( + update_check, "_http_get_json", + lambda url, **kw: releases if "/releases" in url else [], + ) + written = {} + + def fake_download(url, dest, *, max_bytes, timeout=120.0): + data = (files or {}).get(url, b"payload-" + url.encode()[:8]) + if len(data) > max_bytes: + raise update_check.UpdateDownloadError("too large") + dest.write_bytes(data) + return dest + + monkeypatch.setattr(update_check, "_http_download", fake_download) + return written + + def test_prefers_wheel_over_sdist(self, monkeypatch, tmp_path): + rel = _release( + "v9.9.9", + assets=[ + _asset("EvoScientist-9.9.9.tar.gz"), + _asset("evoscientist-9.9.9-py3-none-any.whl"), + ], + ) + self._patch(monkeypatch, tmp_path, [rel]) + result = update_check.download_update() + assert result["version"] == "9.9.9" + assert result["file"].endswith(".whl") + assert "v9.9.9" in result["path"] + assert "uv pip install" in result["suggested_command"] + + def test_sdist_when_no_wheel(self, monkeypatch, tmp_path): + rel = _release("v9.9.9", assets=[_asset("EvoScientist-9.9.9.tar.gz")]) + self._patch(monkeypatch, tmp_path, [rel]) + result = update_check.download_update() + assert result["file"].endswith(".tar.gz") + + def test_tarball_fallback_when_no_assets(self, monkeypatch, tmp_path): + rel = _release("v9.9.9", assets=[]) + self._patch(monkeypatch, tmp_path, [rel]) + result = update_check.download_update() + assert result["file"].endswith(".tar.gz") + + def test_specific_version_uses_tag_endpoint(self, monkeypatch, tmp_path): + monkeypatch.setenv("EVOSCIENTIST_UPDATE_STAGING_DIR", str(tmp_path)) + seen = [] + + def fake_get(url, **kw): + seen.append(url) + if url.endswith("/releases/tags/v0.2.2"): + return _release("v0.2.2", assets=[_asset("evoscientist-0.2.2-py3-none-any.whl")]) + return [] + + monkeypatch.setattr(update_check, "_http_get_json", fake_get) + monkeypatch.setattr( + update_check, "_http_download", + lambda url, dest, **kw: dest.write_bytes(b"x") or dest, + ) + result = update_check.download_update("0.2.2") + assert result["version"] == "0.2.2" + assert any("/releases/tags/v0.2.2" in u for u in seen) + + def test_rejects_foreign_host(self, monkeypatch, tmp_path): + rel = _release( + "v9.9.9", + assets=[_asset("evil.whl", url="https://evil.com/payload.whl")], + ) + self._patch(monkeypatch, tmp_path, [rel]) + with pytest.raises(update_check.UpdateDownloadError, match="host"): + update_check.download_update() + + def test_size_cap_aborts_and_removes_file(self, monkeypatch, tmp_path): + rel = _release("v9.9.9", assets=[_asset("big-py3-none-any.whl")]) + self._patch(monkeypatch, tmp_path, [rel], files={ + "https://git.foksai.com:8443/attachments/big-py3-none-any.whl-uuid": b"x" * 10, + }) + monkeypatch.setattr(update_check, "MAX_DOWNLOAD_BYTES", 4) + with pytest.raises(update_check.UpdateDownloadError): + update_check.download_update() + target = tmp_path / "v9.9.9" + assert not any(target.glob("*.whl")) + + def test_checksum_mismatch_deletes_file(self, monkeypatch, tmp_path): + import hashlib + + wheel_url = "https://git.foksai.com:8443/attachments/evoscientist-9.9.9-py3-none-any.whl-uuid" + sums_url = "https://git.foksai.com:8443/attachments/checksums.txt-uuid" + rel = _release( + "v9.9.9", + assets=[ + _asset("evoscientist-9.9.9-py3-none-any.whl"), + _asset("checksums.txt"), + ], + ) + bad = hashlib.sha256(b"different").hexdigest() + self._patch(monkeypatch, tmp_path, [rel], files={ + wheel_url: b"wheel-bytes", + sums_url: f"{bad} evoscientist-9.9.9-py3-none-any.whl\n".encode(), + }) + with pytest.raises(update_check.UpdateDownloadError, match="checksum"): + update_check.download_update() + target = tmp_path / "v9.9.9" + assert not any(target.glob("*.whl")) + + def test_checksum_ok_when_matching(self, monkeypatch, tmp_path): + import hashlib + + wheel_url = "https://git.foksai.com:8443/attachments/evoscientist-9.9.9-py3-none-any.whl-uuid" + sums_url = "https://git.foksai.com:8443/attachments/checksums.txt-uuid" + rel = _release( + "v9.9.9", + assets=[ + _asset("evoscientist-9.9.9-py3-none-any.whl"), + _asset("checksums.txt"), + ], + ) + good = hashlib.sha256(b"wheel-bytes").hexdigest() + self._patch(monkeypatch, tmp_path, [rel], files={ + wheel_url: b"wheel-bytes", + sums_url: f"{good} evoscientist-9.9.9-py3-none-any.whl\n".encode(), + }) + result = update_check.download_update() + assert (tmp_path / "v9.9.9" / result["file"]).exists() + + def test_unknown_version_raises(self, monkeypatch, tmp_path): + monkeypatch.setenv("EVOSCIENTIST_UPDATE_STAGING_DIR", str(tmp_path)) + monkeypatch.setattr(update_check, "_http_get_json", lambda url, **kw: None) + with pytest.raises(update_check.UpdateDownloadError): + update_check.download_update("1.2.3") + + +class TestStagingDir: + def test_updates_dir_defaults_to_config_dir(self, monkeypatch, tmp_path): + monkeypatch.delenv("EVOSCIENTIST_UPDATE_STAGING_DIR", raising=False) + monkeypatch.delenv("EVOSCIENTIST_WORKSPACE_DIR", raising=False) + monkeypatch.setattr(update_check, "get_config_dir", lambda: tmp_path) + assert update_check._updates_dir("1.2.3") == tmp_path / "updates" / "v1.2.3" + + def test_updates_dir_env_override(self, monkeypatch, tmp_path): + monkeypatch.setenv("EVOSCIENTIST_UPDATE_STAGING_DIR", str(tmp_path / "staging")) + assert update_check._updates_dir("1.2.3") == tmp_path / "staging" / "v1.2.3" + + def test_download_update_reuses_verified_local_artifact(self, monkeypatch, tmp_path): + monkeypatch.setenv("EVOSCIENTIST_UPDATE_STAGING_DIR", str(tmp_path)) + version, wheel = "9.9.9", "EvoScientist-9.9.9-py3-none-any.whl" + staged = tmp_path / "v9.9.9" + staged.mkdir(parents=True) + payload = b"fake wheel bytes" + (staged / wheel).write_bytes(payload) + import hashlib + + digest = hashlib.sha256(payload).hexdigest() + (staged / "checksums.txt").write_text(f"{digest} {wheel}\n", encoding="utf-8") + + def _boom(*a, **k): # any network call fails the test + raise AssertionError("network must not be touched") + + monkeypatch.setattr(update_check, "_http_download", _boom) + monkeypatch.setattr( + update_check, "_resolve_release", lambda v: (v or "9.9.9", {"assets": []}) + ) + result = update_check.download_update("9.9.9") + assert result["file"] == wheel + assert result["path"] == str(staged / wheel)