Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@ jobs:

- name: Action | Run gltest tests
run: uv run gltest tests/gltest/

# Unit tests only — integration tests here download the GenVM SDK.
- name: Action | Run direct runner unit tests
run: uv run gltest tests/gltest_direct/test_sdk_loader.py
101 changes: 75 additions & 26 deletions gltest/direct/sdk_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,19 @@
import json
import tarfile
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
from typing import Optional, Dict, List

CACHE_DIR = Path.home() / ".cache" / "gltest-direct"
GITHUB_RELEASES_URL = "https://github.com/genlayerlabs/genvm/releases"
GITHUB_API_RELEASES = "https://api.github.com/repos/genlayerlabs/genvm/releases"

# GenVM 0.3.0 renamed this bundle from genvm-universal.tar.xz; newest name first.
RUNNER_BUNDLE_ASSETS = ("genvm-runners-all.tar.xz", "genvm-universal.tar.xz")
GENVM_VERSION_ENV = "GENVM_VERSION"
FALLBACK_VERSION = "v0.2.16"

RUNNER_TYPE = "py-genlayer"
STD_LIB_TYPE = "py-lib-genlayer-std"
Expand All @@ -43,23 +50,50 @@ def parse_contract_header(contract_path: Path) -> Dict[str, str]:


def get_latest_version() -> str:
"""Get latest genvm release version from GitHub."""
"""Newest non-prerelease GenVM release that ships a known runner bundle."""
try:
req = urllib.request.Request(
f"{GITHUB_RELEASES_URL}/latest",
method="HEAD",
f"{GITHUB_API_RELEASES}?per_page=100",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Paginate release lookup before falling back

get_latest_version() only fetches ?per_page=100 once and then falls back to FALLBACK_VERSION if no matching asset is found in that single page. Since GitHub release listing is paginated (max 100 per page), once there are more than 100 newer releases without genvm-universal.tar.xz, this code will incorrectly return the hardcoded fallback instead of the newest compatible release, leading the direct runner to use a stale pinned version (or fail if that fallback tag/asset is removed).

Useful? React with 👍 / 👎.

headers={
"User-Agent": "gltest-direct",
"Accept": "application/vnd.github+json",
},
)
req.add_header("User-Agent", "gltest-direct")
with urllib.request.urlopen(req, timeout=10) as resp:
final_url = resp.url
version = final_url.split("/")[-1]
return version
except Exception:
return "v0.2.12"
releases = json.loads(resp.read().decode("utf-8"))
for release in releases: # GitHub returns releases newest-first
if release.get("prerelease") or release.get("draft"):
continue
asset_names = {asset.get("name") for asset in release.get("assets", [])}
if asset_names.intersection(RUNNER_BUNDLE_ASSETS):
return release["tag_name"]
except Exception as exc:
print(
f"Warning: could not resolve latest GenVM version ({exc}); "
f"falling back to {FALLBACK_VERSION}",
file=sys.stderr,
)
return FALLBACK_VERSION


def resolve_version() -> str:
"""GenVM version to use: GENVM_VERSION env var > newest cached > latest release."""
pinned = os.environ.get(GENVM_VERSION_ENV)
if pinned:
return pinned
cached = list_cached_versions()
if cached:
return cached[0]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return get_latest_version()


def _version_sort_key(version: str) -> tuple:
"""Numeric-component sort key so v0.2.16 ranks above v0.2.9."""
return tuple(int(n) for n in re.findall(r"\d+", version))


def list_cached_versions() -> List[str]:
"""List all cached genvm versions."""
"""List all cached genvm versions, newest first."""
if not CACHE_DIR.exists():
return []

Expand All @@ -68,20 +102,11 @@ def list_cached_versions() -> List[str]:
match = re.search(r"genvm-universal-(.+)\.tar\.xz", f.name)
if match:
versions.append(match.group(1))
return sorted(versions, reverse=True)


def download_artifacts(version: str) -> Path:
"""Download genvm release tarball if not cached."""
CACHE_DIR.mkdir(parents=True, exist_ok=True)

tarball_name = f"genvm-universal-{version}.tar.xz"
tarball_path = CACHE_DIR / tarball_name
return sorted(versions, key=_version_sort_key, reverse=True)

if tarball_path.exists():
return tarball_path

url = f"{GITHUB_RELEASES_URL}/download/{version}/genvm-universal.tar.xz"
def _download_to(url: str, dest: Path) -> None:
"""Stream ``url`` to ``dest``, replacing it atomically on success."""
print(f"Downloading {url}...")

req = urllib.request.Request(url)
Expand All @@ -105,8 +130,33 @@ def download_artifacts(version: str) -> Path:
tmp_path = tmp.name

print()
os.rename(tmp_path, tarball_path)
return tarball_path
os.rename(tmp_path, dest)


def download_artifacts(version: str) -> Path:
"""Download the GenVM runner bundle for ``version`` if not cached."""
CACHE_DIR.mkdir(parents=True, exist_ok=True)

tarball_path = CACHE_DIR / f"genvm-universal-{version}.tar.xz"
if tarball_path.exists():
return tarball_path

last_error: Optional[Exception] = None
for asset in RUNNER_BUNDLE_ASSETS:
url = f"{GITHUB_RELEASES_URL}/download/{version}/{asset}"
try:
_download_to(url, tarball_path)
return tarball_path
except urllib.error.HTTPError as e:
if e.code == 404:
last_error = e
continue
raise

raise FileNotFoundError(
f"No GenVM runner bundle for {version}; tried "
f"{', '.join(RUNNER_BUNDLE_ASSETS)}"
) from last_error


def extract_runner(
Expand Down Expand Up @@ -208,8 +258,7 @@ def setup_sdk_paths(
contract_deps = parse_contract_header(contract_path)

if version is None:
cached = list_cached_versions()
version = cached[0] if cached else get_latest_version()
version = resolve_version()

tarball = download_artifacts(version)

Expand Down
188 changes: 188 additions & 0 deletions tests/gltest_direct/test_sdk_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
"""Unit tests for direct-runner GenVM version and artifact resolution."""

import json
import urllib.error

from gltest.direct import sdk_loader


class _FakeResponse:
"""Minimal context-manager stand-in for urllib's HTTP response."""

def __init__(self, payload):
self._payload = payload

def __enter__(self):
return self

def __exit__(self, *exc):
return False

def read(self):
return json.dumps(self._payload).encode("utf-8")


class TestResolveVersion:
"""resolve_version() precedence: env var > cache > latest release."""

def test_env_var_takes_precedence(self, monkeypatch):
monkeypatch.setenv(sdk_loader.GENVM_VERSION_ENV, "v1.2.3")
monkeypatch.setattr(sdk_loader, "list_cached_versions", lambda: ["v0.2.16"])
monkeypatch.setattr(sdk_loader, "get_latest_version", lambda: "v0.9.9")

assert sdk_loader.resolve_version() == "v1.2.3"

def test_falls_back_to_newest_cached_version(self, monkeypatch):
monkeypatch.delenv(sdk_loader.GENVM_VERSION_ENV, raising=False)
monkeypatch.setattr(sdk_loader, "list_cached_versions", lambda: ["v0.2.16"])
monkeypatch.setattr(sdk_loader, "get_latest_version", lambda: "v0.9.9")

assert sdk_loader.resolve_version() == "v0.2.16"

def test_falls_back_to_latest_when_no_cache(self, monkeypatch):
monkeypatch.delenv(sdk_loader.GENVM_VERSION_ENV, raising=False)
monkeypatch.setattr(sdk_loader, "list_cached_versions", lambda: [])
monkeypatch.setattr(sdk_loader, "get_latest_version", lambda: "v0.9.9")

assert sdk_loader.resolve_version() == "v0.9.9"


class TestGetLatestVersion:
"""get_latest_version() skips pre-releases and assetless releases."""

def test_skips_releases_without_a_bundle_asset(self, monkeypatch):
releases = [
{"tag_name": "v0.9.9", "prerelease": False, "assets": [
{"name": "genvm-linux-amd64.tar.xz"},
]},
{"tag_name": "v0.2.16", "prerelease": False, "assets": [
{"name": "genvm-universal.tar.xz"},
]},
]
monkeypatch.setattr(
"urllib.request.urlopen", lambda *a, **k: _FakeResponse(releases)
)

assert sdk_loader.get_latest_version() == "v0.2.16"

def test_accepts_renamed_runners_all_asset(self, monkeypatch):
releases = [
{"tag_name": "v0.3.0", "prerelease": False, "assets": [
{"name": "genvm-runners-all.tar.xz"},
]},
{"tag_name": "v0.2.16", "prerelease": False, "assets": [
{"name": "genvm-universal.tar.xz"},
]},
]
monkeypatch.setattr(
"urllib.request.urlopen", lambda *a, **k: _FakeResponse(releases)
)

assert sdk_loader.get_latest_version() == "v0.3.0"

def test_skips_prereleases(self, monkeypatch):
releases = [
{"tag_name": "v0.3.0-rc0", "prerelease": True, "assets": [
{"name": "genvm-runners-all.tar.xz"},
]},
{"tag_name": "v0.2.16", "prerelease": False, "assets": [
{"name": "genvm-universal.tar.xz"},
]},
]
monkeypatch.setattr(
"urllib.request.urlopen", lambda *a, **k: _FakeResponse(releases)
)

assert sdk_loader.get_latest_version() == "v0.2.16"

def test_returns_fallback_when_api_unreachable(self, monkeypatch, capsys):
def _boom(*a, **k):
raise OSError("network down")

monkeypatch.setattr("urllib.request.urlopen", _boom)

assert sdk_loader.get_latest_version() == sdk_loader.FALLBACK_VERSION
assert "could not resolve latest GenVM version" in capsys.readouterr().err


class TestListCachedVersions:
"""list_cached_versions() orders versions numerically, newest first."""

def test_orders_versions_numerically(self, monkeypatch, tmp_path):
monkeypatch.setattr(sdk_loader, "CACHE_DIR", tmp_path)
for version in ("v0.2.9", "v0.2.16", "v0.10.0"):
(tmp_path / f"genvm-universal-{version}.tar.xz").write_bytes(b"")

assert sdk_loader.list_cached_versions() == ["v0.10.0", "v0.2.16", "v0.2.9"]


class TestDownloadArtifacts:
"""download_artifacts() resolves whichever bundle asset a release ships."""

@staticmethod
def _http_404(url):
return urllib.error.HTTPError(url, 404, "Not Found", {}, None)

def test_returns_cached_tarball_without_downloading(self, monkeypatch, tmp_path):
monkeypatch.setattr(sdk_loader, "CACHE_DIR", tmp_path)
cached = tmp_path / "genvm-universal-v0.2.16.tar.xz"
cached.write_bytes(b"cached")

def _fail(*a, **k):
raise AssertionError("should not download a cached tarball")

monkeypatch.setattr(sdk_loader, "_download_to", _fail)

assert sdk_loader.download_artifacts("v0.2.16") == cached

def test_uses_first_available_asset(self, monkeypatch, tmp_path):
monkeypatch.setattr(sdk_loader, "CACHE_DIR", tmp_path)
tried = []

def _download(url, dest):
tried.append(url)
dest.write_bytes(b"bundle")

monkeypatch.setattr(sdk_loader, "_download_to", _download)

result = sdk_loader.download_artifacts("v0.3.0")

assert result == tmp_path / "genvm-universal-v0.3.0.tar.xz"
assert result.read_bytes() == b"bundle"
assert tried == [
f"{sdk_loader.GITHUB_RELEASES_URL}/download/v0.3.0/genvm-runners-all.tar.xz"
]

def test_falls_back_to_old_asset_on_404(self, monkeypatch, tmp_path):
monkeypatch.setattr(sdk_loader, "CACHE_DIR", tmp_path)
tried = []

def _download(url, dest):
tried.append(url)
if url.endswith("genvm-runners-all.tar.xz"):
raise self._http_404(url)
dest.write_bytes(b"bundle")

monkeypatch.setattr(sdk_loader, "_download_to", _download)

result = sdk_loader.download_artifacts("v0.2.16")

assert result.read_bytes() == b"bundle"
assert [u.rsplit("/", 1)[-1] for u in tried] == list(
sdk_loader.RUNNER_BUNDLE_ASSETS
)

def test_raises_when_no_asset_found(self, monkeypatch, tmp_path):
monkeypatch.setattr(sdk_loader, "CACHE_DIR", tmp_path)

def _download(url, dest):
raise self._http_404(url)

monkeypatch.setattr(sdk_loader, "_download_to", _download)

try:
sdk_loader.download_artifacts("v9.9.9")
except FileNotFoundError as e:
assert "v9.9.9" in str(e)
else:
raise AssertionError("expected FileNotFoundError")
Loading