diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e9ad140..54cfe3e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,7 +3,7 @@ name: Publish to PyPI on: push: branches: - - main + - 'v[0-9]+.[0-9]+' workflow_dispatch: jobs: @@ -54,4 +54,4 @@ jobs: env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} - run: python -m twine upload dist/* \ No newline at end of file + run: python -m twine upload dist/* diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..1c1689c --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,32 @@ +name: Tests + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + push: + branches: + - "**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + tests: + name: Unit tests + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Oldest supported (requires-python) and current, so a version-specific + # regression cannot hide behind a single interpreter. + python-version: ["3.10", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - run: pip install -e ".[dev]" + - name: Run tests + run: pytest tests/ -q diff --git a/src/genvm_linter/cli.py b/src/genvm_linter/cli.py index af732d5..12598fc 100644 --- a/src/genvm_linter/cli.py +++ b/src/genvm_linter/cli.py @@ -329,7 +329,7 @@ def setup(version, contract, json_output): click.echo(f"Latest: {version}") if not json_output: - click.echo(f"Setting up GenVM SDK...") + click.echo("Setting up GenVM SDK...") def progress(downloaded: int, total: int): if not json_output and total > 0: diff --git a/src/genvm_linter/lint/ast_utils.py b/src/genvm_linter/lint/ast_utils.py new file mode 100644 index 0000000..98e2107 --- /dev/null +++ b/src/genvm_linter/lint/ast_utils.py @@ -0,0 +1,31 @@ +"""Shared AST helpers for lint checks.""" + +import ast + +CONTRACT_BASE_NAMES = frozenset( + { + "Contract", + "gl.Contract", + "gl.contract.Contract", + "genlayer.Contract", + "genlayer.contract.Contract", + } +) + + +def dotted_name(node: ast.expr) -> str | None: + """Return a dotted name for Name/Attribute expressions.""" + parts: list[str] = [] + current = node + while isinstance(current, ast.Attribute): + parts.append(current.attr) + current = current.value + if not isinstance(current, ast.Name): + return None + parts.append(current.id) + return ".".join(reversed(parts)) + + +def is_contract_subclass(node: ast.ClassDef) -> bool: + """Return whether a class uses a supported GenLayer Contract base spelling.""" + return any(dotted_name(base) in CONTRACT_BASE_NAMES for base in node.bases) diff --git a/src/genvm_linter/lint/safety.py b/src/genvm_linter/lint/safety.py index 93362f0..9dd9006 100644 --- a/src/genvm_linter/lint/safety.py +++ b/src/genvm_linter/lint/safety.py @@ -4,6 +4,8 @@ from dataclasses import dataclass from pathlib import Path +from .ast_utils import is_contract_subclass + # Modules that are forbidden in GenLayer contracts (non-deterministic) FORBIDDEN_MODULES = frozenset({ "random", @@ -171,14 +173,8 @@ def _get_call_name(self, node: ast.Call) -> str: return "" def _is_contract_class(self, node: ast.ClassDef) -> bool: - """Check if a class inherits from gl.Contract / genlayer.Contract / Contract.""" - for base in node.bases: - if isinstance(base, ast.Attribute) and base.attr == "Contract": - if isinstance(base.value, ast.Name) and base.value.id in ("gl", "genlayer"): - return True - if isinstance(base, ast.Name) and base.id == "Contract": - return True - return False + """Check if the class inherits from a supported Contract base.""" + return is_contract_subclass(node) def _get_exception_name(self, node: ast.expr) -> str: """Extract the exception class name from a raise target.""" diff --git a/src/genvm_linter/lint/structure.py b/src/genvm_linter/lint/structure.py index 3bf3aa7..9d6c268 100644 --- a/src/genvm_linter/lint/structure.py +++ b/src/genvm_linter/lint/structure.py @@ -4,6 +4,8 @@ from dataclasses import dataclass from pathlib import Path +from .ast_utils import is_contract_subclass + @dataclass class StructureWarning: @@ -50,19 +52,8 @@ def visit_ClassDef(self, node: ast.ClassDef): self.is_contract_class = old_is_contract def _is_contract_subclass(self, node: ast.ClassDef) -> bool: - """Check if class inherits from gl.Contract or Contract.""" - for base in node.bases: - if isinstance(base, ast.Attribute): - # gl.Contract - if (isinstance(base.value, ast.Name) and - base.value.id == "gl" and - base.attr == "Contract"): - return True - elif isinstance(base, ast.Name): - # Contract (direct import) - if base.id == "Contract": - return True - return False + """Check if the class inherits from a supported Contract base.""" + return is_contract_subclass(node) def _check_contract_class(self, node: ast.ClassDef): """Check all rules for a contract class.""" @@ -296,16 +287,7 @@ def visit_ClassDef(self, node: ast.ClassDef): self.generic_visit(node) def _is_contract_subclass(self, node: ast.ClassDef) -> bool: - for base in node.bases: - if isinstance(base, ast.Attribute): - if (isinstance(base.value, ast.Name) and - base.value.id == "gl" and - base.attr == "Contract"): - return True - elif isinstance(base, ast.Name): - if base.id == "Contract": - return True - return False + return is_contract_subclass(node) def _collect_storage_types(self, annotation: ast.expr | None): """Collect custom class types used in storage annotations.""" diff --git a/src/genvm_linter/stubs.py b/src/genvm_linter/stubs.py index cc4a95e..f4f8060 100644 --- a/src/genvm_linter/stubs.py +++ b/src/genvm_linter/stubs.py @@ -1,13 +1,11 @@ """Generate type stubs for GenLayer SDK.""" import os -import shutil import subprocess import sys from pathlib import Path from .validate.artifacts import download_artifacts, get_latest_version -from .validate.sdk_loader import get_sdk_paths_from_genvmroot, extract_sdk_paths, parse_contract_header CACHE_DIR = Path.home() / ".cache" / "genvm-linter" / "stubs" @@ -332,7 +330,7 @@ def generate_stubs( # Extract SDK from .validate.sdk_loader import extract_sdk_paths - sdk_paths = extract_sdk_paths(tarball_path, {}) + sdk_paths, _upgrade_notes = extract_sdk_paths(tarball_path, {}) if not sdk_paths: raise RuntimeError("Could not extract SDK paths") @@ -344,19 +342,19 @@ def generate_stubs( stubs_path.mkdir(parents=True, exist_ok=True) # Try to run stubgen - stubgen_success = False try: env = os.environ.copy() env["PYTHONPATH"] = str(src_path) - result = subprocess.run( + subprocess.run( [sys.executable, "-m", "mypy.stubgen", "-p", "genlayer", "-o", str(stubs_path)], env=env, capture_output=True, text=True, + check=False, ) - stubgen_success = result.returncode == 0 except Exception: + # stubgen is best-effort; the stubs are created from scratch below. pass # Post-process or create from scratch diff --git a/src/genvm_linter/validate/artifacts.py b/src/genvm_linter/validate/artifacts.py index ea1fef0..f878ad0 100644 --- a/src/genvm_linter/validate/artifacts.py +++ b/src/genvm_linter/validate/artifacts.py @@ -11,13 +11,19 @@ from pathlib import Path CACHE_DIR = Path.home() / ".cache" / "genvm-linter" -GITHUB_RELEASES_URL = "https://github.com/genlayerlabs/genvm/releases" -GITHUB_API_RELEASES = "https://api.github.com/repos/genlayerlabs/genvm/releases" +GENVM_REPO = os.environ.get("GENVM_REPO", "genlayerlabs/genvm-manager") +GITHUB_RELEASES_URL = f"https://github.com/{GENVM_REPO}/releases" +GITHUB_API_RELEASES = f"https://api.github.com/repos/{GENVM_REPO}/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" +GENVM_ALLOW_PRERELEASE_ENV = "GENVM_ALLOW_PRERELEASE" +GENVM_SOURCE_MODE_ENV = "GENVM_SOURCE_MODE" +GENVM_PREBUILT_DIR_ENV = "GENVM_PREBUILT_DIR" +GENVMROOT_ENV = "GENVMROOT" +FALLBACK_VERSION = "v0.6.0-rc1" +RUNNER_INDEX_VERSION = 2 def get_cache_dir() -> Path: @@ -27,7 +33,12 @@ def get_cache_dir() -> Path: def get_latest_version() -> str: - """Newest non-prerelease GenVM release that ships a known runner bundle.""" + """Resolve the newest suitable GenVM release that ships a known runner bundle. + + Stable releases are preferred by default. If none exist, the newest prerelease + is used so the prerelease-only genvm-manager repository works out of the box. + GENVM_ALLOW_PRERELEASE=1 opts into prereleases even when a stable release exists. + """ try: req = urllib.request.Request( f"{GITHUB_API_RELEASES}?per_page=100", @@ -38,16 +49,33 @@ def get_latest_version() -> str: ) with urllib.request.urlopen(req, timeout=10) as response: releases = json.loads(response.read().decode("utf-8")) + prerelease_candidate: str | None = None + allow_prerelease = os.environ.get(GENVM_ALLOW_PRERELEASE_ENV) == "1" for release in releases: - if release.get("prerelease") or release.get("draft"): + if 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"] + if not asset_names.intersection(RUNNER_BUNDLE_ASSETS): + continue + if release.get("prerelease"): + if allow_prerelease: + return release["tag_name"] + if prerelease_candidate is None: + prerelease_candidate = release["tag_name"] + continue + return release["tag_name"] + if prerelease_candidate is not None: + return prerelease_candidate + print( + f"Warning: no release in {GENVM_REPO} ships a known runner bundle " + f"({', '.join(RUNNER_BUNDLE_ASSETS)}); falling back to " + f"{FALLBACK_VERSION}, which may not exist in that repository", + file=sys.stderr, + ) except Exception as exc: print( - f"Warning: could not resolve latest GenVM version ({exc}); " - f"falling back to {FALLBACK_VERSION}", + f"Warning: could not resolve latest GenVM version from {GENVM_REPO} " + f"({exc}); falling back to {FALLBACK_VERSION}", file=sys.stderr, ) return FALLBACK_VERSION @@ -58,12 +86,30 @@ def _version_sort_key(version: str) -> tuple[int, ...]: return tuple(int(n) for n in re.findall(r"\d+", version)) +def _cache_repo_slug() -> str: + """Filesystem-safe form of GENVM_REPO, used to namespace cached bundles.""" + return GENVM_REPO.replace("/", "-") + + +def _cache_prefix() -> str: + return f"genvm-universal-{_cache_repo_slug()}-" + + def list_cached_versions() -> list[str]: - """List all cached GenVM versions, newest first.""" + """List cached GenVM versions for the CURRENT repo, newest first. + + Bundles are namespaced by repository. Versions are not unique across + repositories, and their contents differ (runner sets, SDK layout), so an + unqualified cache would let a bundle downloaded from one repo satisfy a + request aimed at another. Legacy unqualified files are deliberately + ignored: they predate the genvm -> genvm-manager split and are exactly + the bundles that must not be reused. + """ cache_dir = get_cache_dir() + prefix = _cache_prefix() versions = [] - for f in cache_dir.glob("genvm-universal-*.tar.xz"): - version = f.name.replace("genvm-universal-", "").replace(".tar.xz", "") + for f in cache_dir.glob(f"{prefix}*.tar.xz"): + version = f.name[len(prefix) :].replace(".tar.xz", "") versions.append(version) return sorted(versions, key=_version_sort_key, reverse=True) @@ -80,8 +126,8 @@ def resolve_version() -> str: def get_tarball_path(version: str) -> Path: - """Get path to cached tarball for a version.""" - return get_cache_dir() / f"genvm-universal-{version}.tar.xz" + """Get path to cached tarball for a version of the current repo.""" + return get_cache_dir() / f"{_cache_prefix()}{version}.tar.xz" def _download_to(url: str, dest: Path, progress_callback=None) -> None: @@ -146,14 +192,82 @@ def download_artifacts(version: str | None = None, progress_callback=None) -> Pa ) from last_error +def _configured_prebuilt_root() -> tuple[str, Path] | None: + """Return the configured prebuilt root and the env var that selected it.""" + for env_var in (GENVM_PREBUILT_DIR_ENV, GENVMROOT_ENV): + value = os.environ.get(env_var) + if value: + return env_var, Path(value).expanduser() + return None + + +def _prebuilt_root_error(root: Path) -> str | None: + """Describe why a path is not a usable GenVM tree.""" + if not root.is_dir(): + return "the path is not a directory" + + missing = [] + if not (root / "bin" / "genvm-modules").is_file(): + missing.append("bin/genvm-modules") + if not (root / "runners").is_dir(): + missing.append("runners/") + if missing: + return f"missing {', '.join(missing)}" + return None + + +def resolve_artifact_source( + version: str | None = None, + progress_callback=None, +) -> Path: + """Resolve a prebuilt GenVM tree or a downloaded release bundle. + + GENVM_SOURCE_MODE=prebuilt requires a usable tree and never downloads. + GENVM_SOURCE_MODE=release always uses the release bundle. With no explicit + mode, a usable configured tree wins and an absent or unusable tree falls + back to the existing release behavior. + """ + mode = os.environ.get(GENVM_SOURCE_MODE_ENV, "").strip() + if mode not in {"", "prebuilt", "release"}: + raise ValueError( + f"Invalid {GENVM_SOURCE_MODE_ENV}={mode!r}; expected 'prebuilt', " + "'release', or an empty value for auto mode" + ) + + configured = _configured_prebuilt_root() + if mode != "release" and configured is not None: + env_var, root = configured + error = _prebuilt_root_error(root) + if error is None: + return root + if mode == "prebuilt": + raise RuntimeError( + f"{GENVM_SOURCE_MODE_ENV}=prebuilt selected {env_var}={str(root)!r}, " + f"but that GenVM tree is unusable: {error}. Build GenVM first or " + f"point {GENVM_PREBUILT_DIR_ENV} at a tree containing " + "bin/genvm-modules and runners/." + ) + + if mode == "prebuilt": + raise RuntimeError( + f"{GENVM_SOURCE_MODE_ENV}=prebuilt requires {GENVM_PREBUILT_DIR_ENV} " + f"(preferred) or {GENVMROOT_ENV} to point at a GenVM tree containing " + "bin/genvm-modules and runners/." + ) + + return download_artifacts(version, progress_callback=progress_callback) + + def list_available_versions() -> list[dict]: """Fetch all available GenVM release versions from GitHub. Returns a list of dicts with 'tag', 'date', and 'prerelease' keys, sorted newest first. """ - url = "https://api.github.com/repos/genlayerlabs/genvm/releases" - req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) + req = urllib.request.Request( + GITHUB_API_RELEASES, + headers={"Accept": "application/vnd.github+json"}, + ) with urllib.request.urlopen(req) as response: releases = json.loads(response.read().decode("utf-8")) @@ -181,13 +295,15 @@ def hash_to_tar_path(runner_type: str, hash_val: str) -> str: return f"runners/{runner_type}/{dir_prefix}/{file_suffix}.tar" -def _get_runner_index(tarball_path: Path) -> dict[str, list[str]]: +def _get_bundle_runner_index(tarball_path: Path) -> dict[str, list[str]]: """Get or build a cached index of runner members in the tarball. First call decompresses the tarball and saves the index as JSON. Subsequent calls read the JSON file instantly. """ - index_path = tarball_path.with_suffix(tarball_path.suffix + ".index.json") + index_path = tarball_path.with_suffix( + tarball_path.suffix + f".index-v{RUNNER_INDEX_VERSION}.json" + ) if index_path.exists(): return json.loads(index_path.read_text()) @@ -196,60 +312,131 @@ def _get_runner_index(tarball_path: Path) -> dict[str, list[str]]: for m in tar.getmembers(): if m.name.startswith("runners/") and m.name.endswith(".tar"): parts = m.name.split("/") - if len(parts) >= 3: + if len(parts) >= 4: runner_type = parts[1] index.setdefault(runner_type, []).append(m.name) + elif m.name.startswith("executor/") and m.name.endswith(".tar"): + parts = m.name.split("/") + if len(parts) >= 6 and parts[2] == "legacy-runners": + runner_type = parts[3] + index.setdefault(runner_type, []).append(m.name) index_path.write_text(json.dumps(index)) return index -def find_latest_runner(tarball_path: Path, runner_type: str) -> str | None: - """Find the latest version hash for a runner type in the tarball. +def _get_prebuilt_runner_index(root: Path) -> dict[str, list[str]]: + """Index runner tarballs directly from a prebuilt GenVM tree.""" + index: dict[str, list[str]] = {} + + current_root = root / "runners" + for runner_path in sorted(current_root.glob("*/*/*.tar")): + relative_path = runner_path.relative_to(root).as_posix() + runner_type = relative_path.split("/")[1] + index.setdefault(runner_type, []).append(relative_path) + + executor_root = root / "executor" + for runner_path in sorted(executor_root.glob("*/legacy-runners/*/*/*.tar")): + relative_path = runner_path.relative_to(root).as_posix() + runner_type = relative_path.split("/")[3] + index.setdefault(runner_type, []).append(relative_path) + + return index + + +def _get_runner_index(artifact_path: Path) -> dict[str, list[str]]: + """Get the runner index for either a release bundle or prebuilt tree.""" + if artifact_path.is_dir(): + return _get_prebuilt_runner_index(artifact_path) + return _get_bundle_runner_index(artifact_path) - Uses a cached index file — no tarball decompression after first call. + +def _runner_hash_from_tar_path(tar_member_path: str) -> str: + """Recover a runner hash from either supported bundle member layout.""" + parts = tar_member_path.split("/") + return parts[-2] + parts[-1].removesuffix(".tar") + + +def _select_runner_tar_path( + runner_paths: list[str], + runner_type: str, + hash_val: str, +) -> str: + """Select a runner path, preferring the current layout over legacy.""" + current_path = hash_to_tar_path(runner_type, hash_val) + if current_path in runner_paths: + return current_path + + legacy_suffix = f"/legacy-runners/{runner_type}/{hash_val[:2]}/{hash_val[2:]}.tar" + for runner_path in runner_paths: + if runner_path.startswith("executor/") and runner_path.endswith(legacy_suffix): + return runner_path + return current_path + + +def _find_runner_tar_path( + artifact_path: Path, + runner_type: str, + hash_val: str, +) -> str: + """Find a runner in either source, preferring current layout over legacy.""" + runners = _get_runner_index(artifact_path).get(runner_type, []) + return _select_runner_tar_path(runners, runner_type, hash_val) + + +def find_latest_runner(artifact_path: Path, runner_type: str) -> str | None: + """Find the latest version hash for a runner type in the artifact source. + + Release bundles use a cached index file, avoiding repeat decompression. """ - index = _get_runner_index(tarball_path) + index = _get_runner_index(artifact_path) runners = index.get(runner_type, []) if runners: - latest = runners[-1] - parts = latest.replace(f"runners/{runner_type}/", "").replace(".tar", "") - dir_part, file_part = parts.split("/") - return dir_part + file_part + current_runners = [path for path in runners if path.startswith("runners/")] + latest = (current_runners or runners)[-1] + return _runner_hash_from_tar_path(latest) return None -def extract_runner(tarball_path: Path, runner_type: str, hash_val: str) -> Path: - """Extract a specific runner from genvm-universal.tar.xz. +def extract_runner(artifact_path: Path, runner_type: str, hash_val: str) -> Path: + """Extract a specific runner from a bundle or prebuilt GenVM tree. - Only decompresses the tarball if the runner isn't already cached on disk. + Only extracts the runner if it is not already cached on disk. """ - version = tarball_path.stem.replace("genvm-universal-", "") - extract_base = get_cache_dir() / "extracted" / version + if artifact_path.is_dir(): + source_namespace = "prebuilt" + else: + source_namespace = artifact_path.stem.replace("genvm-universal-", "") + extract_base = get_cache_dir() / "extracted" / source_namespace runner_path = extract_base / runner_type / hash_val if runner_path.exists() and any(runner_path.iterdir()): return runner_path runner_path.mkdir(parents=True, exist_ok=True) - tar_member_path = hash_to_tar_path(runner_type, hash_val) try: - with tarfile.open(tarball_path, "r:xz") as outer_tar: - inner_tar_member = outer_tar.getmember(tar_member_path) - inner_tar_file = outer_tar.extractfile(inner_tar_member) + tar_member_path = _find_runner_tar_path(artifact_path, runner_type, hash_val) + if artifact_path.is_dir(): + with tarfile.open(artifact_path / tar_member_path, mode="r:") as inner_tar: + inner_tar.extractall(runner_path, filter="data") + else: + with tarfile.open(artifact_path, "r:xz") as outer_tar: + inner_tar_member = outer_tar.getmember(tar_member_path) + inner_tar_file = outer_tar.extractfile(inner_tar_member) - if inner_tar_file is None: - raise RuntimeError(f"Could not extract {tar_member_path}") + if inner_tar_file is None: + raise RuntimeError(f"Could not extract {tar_member_path}") - with tarfile.open(fileobj=inner_tar_file, mode="r:") as inner_tar: - inner_tar.extractall(runner_path, filter="data") + with tarfile.open(fileobj=inner_tar_file, mode="r:") as inner_tar: + inner_tar.extractall(runner_path, filter="data") return runner_path except Exception: if runner_path.exists(): import shutil + shutil.rmtree(runner_path) raise diff --git a/src/genvm_linter/validate/sdk_loader.py b/src/genvm_linter/validate/sdk_loader.py index a7c0674..0f71e80 100644 --- a/src/genvm_linter/validate/sdk_loader.py +++ b/src/genvm_linter/validate/sdk_loader.py @@ -9,10 +9,11 @@ from unittest.mock import MagicMock from .artifacts import ( - download_artifacts, + GITHUB_RELEASES_URL, extract_runner, find_latest_runner, parse_runner_manifest, + resolve_artifact_source, ) @@ -81,7 +82,7 @@ def extract_sdk_paths( if latest_hash and latest_hash != genlayer_hash: notes.append( f"py-genlayer: a newer runner is available ({latest_hash}). " - f"See https://github.com/genlayerlabs/genvm/releases for changes." + f"See {GITHUB_RELEASES_URL} for changes." ) else: genlayer_hash = find_latest_runner(tarball_path, "py-genlayer") @@ -119,7 +120,7 @@ def extract_sdk_paths( if latest_emb and latest_emb != emb_hash: notes.append( f"py-lib-genlayer-embeddings: a newer runner is available ({latest_emb}). " - f"See https://github.com/genlayerlabs/genvm/releases for changes." + f"See {GITHUB_RELEASES_URL} for changes." ) emb_path = extract_runner(tarball_path, "py-lib-genlayer-embeddings", emb_hash) paths.append(emb_path) @@ -127,32 +128,15 @@ def extract_sdk_paths( return paths, notes -def get_sdk_paths_from_genvmroot() -> list[Path] | None: - """ - Get SDK paths from GENVMROOT environment variable if available. - - This reuses GenVM artifacts already downloaded by studio/other tools. - Returns None if GENVMROOT is not set or SDK not found. - """ - genvmroot = os.environ.get("GENVMROOT") - if not genvmroot: - return None - - genvm_path = Path(genvmroot) - if not genvm_path.exists(): - return None - - # Studio layout: /genvm/runners/genlayer-py-std/src - sdk_path = genvm_path / "runners" / "genlayer-py-std" / "src" - if sdk_path.exists(): - return [sdk_path] - - # Alternative: /genvm/runners/py-lib-genlayer-std/src - alt_path = genvm_path / "runners" / "py-lib-genlayer-std" / "src" - if alt_path.exists(): - return [alt_path] - - return None +def _import_get_schema() -> Callable[[type], dict[str, Any]]: + """Import get_schema from the current SDK or its legacy location.""" + try: + from genlayer._internal.get_schema import get_schema + except ModuleNotFoundError as exc: + if exc.name not in {"genlayer._internal", "genlayer._internal.get_schema"}: + raise + from genlayer.py.get_schema import get_schema + return get_schema def load_sdk( @@ -170,8 +154,8 @@ def load_sdk( Tuple of (get_schema function, upgrade_notes list) Note: - If GENVMROOT env var is set, reuses SDK from that location - instead of downloading. This is useful in studio/CI environments. + GENVM_SOURCE_MODE controls whether artifacts come from a prebuilt + GenVM tree or a downloaded release bundle. """ # 1. CRITICAL: Import numpy BEFORE SDK # SDK's _internal/numpy.py only registers numpy types if numpy is already imported @@ -180,27 +164,22 @@ def load_sdk( # 2. Mock WASI setup_wasi_mocks() - # 3. Try to use GENVMROOT if available (reuse studio's GenVM) - sdk_paths = get_sdk_paths_from_genvmroot() - upgrade_notes: list[str] = [] - - if sdk_paths is None: - # 4. Parse contract header for version info - dependencies = parse_contract_header(contract_path) + # 3. Parse contract header for runner hashes + dependencies = parse_contract_header(contract_path) - # 5. Download artifacts if needed - tarball_path = download_artifacts(progress_callback=progress_callback) + # 4. Resolve a prebuilt tree or download a release bundle + artifact_path = resolve_artifact_source(progress_callback=progress_callback) - # 6. Extract SDK paths - sdk_paths, upgrade_notes = extract_sdk_paths(tarball_path, dependencies) + # 5. Extract SDK paths from the selected source + sdk_paths, upgrade_notes = extract_sdk_paths(artifact_path, dependencies) - # 7. Add SDK to path + # 6. Add SDK to path for path in reversed(sdk_paths): src_path = path / "src" if (path / "src").exists() else path sys.path.insert(0, str(src_path)) - # 8. Import get_schema - from genlayer.py.get_schema import get_schema + # 7. Import get_schema from the current SDK, falling back to the legacy layout. + get_schema = _import_get_schema() return get_schema, upgrade_notes diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py index 31d6cf1..57a0ca6 100644 --- a/tests/test_artifacts.py +++ b/tests/test_artifacts.py @@ -1,6 +1,9 @@ """Unit tests for GenVM artifact version and bundle resolution.""" +import importlib +import io import json +import tarfile import urllib.error from genvm_linter.validate import artifacts @@ -50,9 +53,10 @@ def test_falls_back_to_latest_when_no_cache(self, monkeypatch): class TestGetLatestVersion: - """get_latest_version() skips pre-releases and assetless releases.""" + """get_latest_version() resolves stable and prerelease manager releases.""" def test_skips_releases_without_a_bundle_asset(self, monkeypatch): + monkeypatch.delenv(artifacts.GENVM_ALLOW_PRERELEASE_ENV, raising=False) releases = [ { "tag_name": "v0.9.9", @@ -65,13 +69,12 @@ def test_skips_releases_without_a_bundle_asset(self, monkeypatch): "assets": [{"name": "genvm-universal.tar.xz"}], }, ] - monkeypatch.setattr( - "urllib.request.urlopen", lambda *a, **k: _FakeResponse(releases) - ) + monkeypatch.setattr("urllib.request.urlopen", lambda *a, **k: _FakeResponse(releases)) assert artifacts.get_latest_version() == "v0.2.16" def test_accepts_renamed_runners_all_asset(self, monkeypatch): + monkeypatch.delenv(artifacts.GENVM_ALLOW_PRERELEASE_ENV, raising=False) releases = [ { "tag_name": "v0.3.0", @@ -84,13 +87,12 @@ def test_accepts_renamed_runners_all_asset(self, monkeypatch): "assets": [{"name": "genvm-universal.tar.xz"}], }, ] - monkeypatch.setattr( - "urllib.request.urlopen", lambda *a, **k: _FakeResponse(releases) - ) + monkeypatch.setattr("urllib.request.urlopen", lambda *a, **k: _FakeResponse(releases)) assert artifacts.get_latest_version() == "v0.3.0" def test_skips_prereleases(self, monkeypatch): + monkeypatch.delenv(artifacts.GENVM_ALLOW_PRERELEASE_ENV, raising=False) releases = [ { "tag_name": "v0.3.0-rc0", @@ -103,12 +105,46 @@ def test_skips_prereleases(self, monkeypatch): "assets": [{"name": "genvm-universal.tar.xz"}], }, ] - monkeypatch.setattr( - "urllib.request.urlopen", lambda *a, **k: _FakeResponse(releases) - ) + monkeypatch.setattr("urllib.request.urlopen", lambda *a, **k: _FakeResponse(releases)) assert artifacts.get_latest_version() == "v0.2.16" + def test_uses_prerelease_when_repo_has_no_stable_release(self, monkeypatch): + monkeypatch.delenv(artifacts.GENVM_ALLOW_PRERELEASE_ENV, raising=False) + releases = [ + { + "tag_name": "v0.6.0-rc1", + "prerelease": True, + "assets": [{"name": "genvm-universal.tar.xz"}], + }, + { + "tag_name": "v0.6.0-rc0", + "prerelease": True, + "assets": [{"name": "genvm-universal.tar.xz"}], + }, + ] + monkeypatch.setattr("urllib.request.urlopen", lambda *a, **k: _FakeResponse(releases)) + + assert artifacts.get_latest_version() == "v0.6.0-rc1" + + def test_prerelease_opt_in_prefers_newer_prerelease(self, monkeypatch): + monkeypatch.setenv(artifacts.GENVM_ALLOW_PRERELEASE_ENV, "1") + releases = [ + { + "tag_name": "v0.7.0-rc0", + "prerelease": True, + "assets": [{"name": "genvm-universal.tar.xz"}], + }, + { + "tag_name": "v0.6.0", + "prerelease": False, + "assets": [{"name": "genvm-universal.tar.xz"}], + }, + ] + monkeypatch.setattr("urllib.request.urlopen", lambda *a, **k: _FakeResponse(releases)) + + assert artifacts.get_latest_version() == "v0.7.0-rc0" + def test_returns_fallback_when_api_unreachable(self, monkeypatch, capsys): def _boom(*a, **k): raise OSError("network down") @@ -119,13 +155,47 @@ def _boom(*a, **k): assert "could not resolve latest GenVM version" in capsys.readouterr().err +class TestRepositoryResolution: + """Release URLs share the manager repository source of truth.""" + + def test_defaults_to_genvm_manager(self, monkeypatch): + try: + with monkeypatch.context() as context: + context.delenv("GENVM_REPO", raising=False) + reloaded = importlib.reload(artifacts) + assert reloaded.GENVM_REPO == "genlayerlabs/genvm-manager" + assert reloaded.GITHUB_RELEASES_URL == ( + "https://github.com/genlayerlabs/genvm-manager/releases" + ) + assert reloaded.GITHUB_API_RELEASES == ( + "https://api.github.com/repos/genlayerlabs/genvm-manager/releases" + ) + finally: + importlib.reload(artifacts) + + def test_genvm_repo_environment_override(self, monkeypatch): + try: + with monkeypatch.context() as context: + context.setenv("GENVM_REPO", "example/custom-genvm") + reloaded = importlib.reload(artifacts) + assert reloaded.GENVM_REPO == "example/custom-genvm" + assert reloaded.GITHUB_RELEASES_URL == ( + "https://github.com/example/custom-genvm/releases" + ) + assert reloaded.GITHUB_API_RELEASES == ( + "https://api.github.com/repos/example/custom-genvm/releases" + ) + finally: + importlib.reload(artifacts) + + class TestListCachedVersions: """list_cached_versions() orders versions numerically, newest first.""" def test_orders_versions_numerically(self, monkeypatch, tmp_path): monkeypatch.setattr(artifacts, "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"") + (tmp_path / f"{artifacts._cache_prefix()}{version}.tar.xz").write_bytes(b"") assert artifacts.list_cached_versions() == ["v0.10.0", "v0.2.16", "v0.2.9"] @@ -154,9 +224,7 @@ def test_includes_releases_with_any_supported_bundle_asset(self, monkeypatch): "assets": [{"name": "genvm-linux-amd64.tar.xz"}], }, ] - monkeypatch.setattr( - "urllib.request.urlopen", lambda *a, **k: _FakeResponse(releases) - ) + monkeypatch.setattr("urllib.request.urlopen", lambda *a, **k: _FakeResponse(releases)) assert [release["tag"] for release in artifacts.list_available_versions()] == [ "v0.3.0", @@ -173,7 +241,7 @@ def _http_404(url): def test_returns_cached_tarball_without_downloading(self, monkeypatch, tmp_path): monkeypatch.setattr(artifacts, "CACHE_DIR", tmp_path) - cached = tmp_path / "genvm-universal-v0.2.16.tar.xz" + cached = tmp_path / f"{artifacts._cache_prefix()}v0.2.16.tar.xz" cached.write_bytes(b"cached") def _fail(*a, **k): @@ -195,7 +263,7 @@ def _download(url, dest, progress_callback=None): result = artifacts.download_artifacts("v0.3.0") - assert result == tmp_path / "genvm-universal-v0.3.0.tar.xz" + assert result == tmp_path / f"{artifacts._cache_prefix()}v0.3.0.tar.xz" assert result.read_bytes() == b"bundle" assert tried == [ f"{artifacts.GITHUB_RELEASES_URL}/download/v0.3.0/genvm-runners-all.tar.xz" @@ -216,9 +284,7 @@ def _download(url, dest, progress_callback=None): result = artifacts.download_artifacts("v0.2.16") assert result.read_bytes() == b"bundle" - assert [u.rsplit("/", 1)[-1] for u in tried] == list( - artifacts.RUNNER_BUNDLE_ASSETS - ) + assert [u.rsplit("/", 1)[-1] for u in tried] == list(artifacts.RUNNER_BUNDLE_ASSETS) def test_raises_when_no_asset_found(self, monkeypatch, tmp_path): monkeypatch.setattr(artifacts, "CACHE_DIR", tmp_path) @@ -234,3 +300,100 @@ def _download(url, dest, progress_callback=None): assert "v9.9.9" in str(e) else: raise AssertionError("expected FileNotFoundError") + + +class TestRunnerPathLookup: + """Runner lookup supports current and manager legacy bundle layouts.""" + + def test_extract_runner_falls_back_to_legacy_prefix(self, monkeypatch, tmp_path): + monkeypatch.setattr(artifacts, "CACHE_DIR", tmp_path / "cache") + runner_hash = "1jb45aa8legacyhash" + legacy_path = ( + f"executor/v0.2.17/legacy-runners/py-genlayer/{runner_hash[:2]}/{runner_hash[2:]}.tar" + ) + + inner_bytes = io.BytesIO() + with tarfile.open(fileobj=inner_bytes, mode="w") as inner_tar: + payload = b'{"Seq": []}' + member = tarfile.TarInfo("runner.json") + member.size = len(payload) + inner_tar.addfile(member, io.BytesIO(payload)) + + tarball_path = tmp_path / f"{artifacts._cache_prefix()}v0.6.0-rc1.tar.xz" + with tarfile.open(tarball_path, mode="w:xz") as outer_tar: + member = tarfile.TarInfo(legacy_path) + member.size = len(inner_bytes.getvalue()) + outer_tar.addfile(member, io.BytesIO(inner_bytes.getvalue())) + + index = artifacts._get_runner_index(tarball_path) + extracted = artifacts.extract_runner( + tarball_path, + "py-genlayer", + runner_hash, + ) + + assert index["py-genlayer"] == [legacy_path] + assert (extracted / "runner.json").read_bytes() == b'{"Seq": []}' + + def test_find_latest_runner_prefers_current_layout(self, monkeypatch, tmp_path): + tarball_path = tmp_path / "bundle.tar.xz" + monkeypatch.setattr( + artifacts, + "_get_runner_index", + lambda _path: { + "py-genlayer": [ + "executor/v0.2.17/legacy-runners/py-genlayer/1j/legacy.tar", + "runners/py-genlayer/9b/current.tar", + ] + }, + ) + + assert artifacts.find_latest_runner(tarball_path, "py-genlayer") == "9bcurrent" + + +class TestCacheRepositoryNamespacing: + """Cached bundles must not be shared across repositories. + + Versions are not unique across repos and their contents differ, so an + unqualified cache would let an old genvm bundle satisfy a genvm-manager + request -- silently validating against the wrong SDK era. + """ + + def test_ignores_bundles_cached_for_another_repo(self, tmp_path, monkeypatch): + monkeypatch.setattr(artifacts, "get_cache_dir", lambda: tmp_path) + # A bundle cached under a different repo's namespace. + (tmp_path / "genvm-universal-example-other-v9.9.9.tar.xz").write_bytes(b"") + assert artifacts.list_cached_versions() == [] + + def test_ignores_legacy_unqualified_bundles(self, tmp_path, monkeypatch): + monkeypatch.setattr(artifacts, "get_cache_dir", lambda: tmp_path) + # Pre-split layout: exactly the bundles that must never be reused. + (tmp_path / "genvm-universal-v0.3.0-rc3.tar.xz").write_bytes(b"") + assert artifacts.list_cached_versions() == [] + + def test_lists_bundles_for_the_current_repo(self, tmp_path, monkeypatch): + monkeypatch.setattr(artifacts, "get_cache_dir", lambda: tmp_path) + prefix = artifacts._cache_prefix() + (tmp_path / f"{prefix}v0.6.0-rc1.tar.xz").write_bytes(b"") + assert artifacts.list_cached_versions() == ["v0.6.0-rc1"] + + def test_stale_foreign_cache_does_not_win_resolution(self, tmp_path, monkeypatch): + """The regression this guards: an existing user's old-repo cache must + not short-circuit resolution and defeat the repo repoint.""" + monkeypatch.setattr(artifacts, "get_cache_dir", lambda: tmp_path) + monkeypatch.delenv(artifacts.GENVM_VERSION_ENV, raising=False) + (tmp_path / "genvm-universal-v0.3.0-rc3.tar.xz").write_bytes(b"") + monkeypatch.setattr(artifacts, "get_latest_version", lambda: "v0.6.0-rc1") + assert artifacts.resolve_version() == "v0.6.0-rc1" + + +class TestNoBundledReleaseWarning: + def test_warns_when_no_release_ships_a_bundle(self, monkeypatch, capsys): + payload = [{"tag_name": "v1.0.0", "prerelease": False, "assets": []}] + monkeypatch.setattr( + artifacts.urllib.request, + "urlopen", + lambda *a, **k: _FakeResponse(payload), + ) + assert artifacts.get_latest_version() == artifacts.FALLBACK_VERSION + assert "ships a known runner bundle" in capsys.readouterr().err diff --git a/tests/test_contract_detection.py b/tests/test_contract_detection.py new file mode 100644 index 0000000..b550da2 --- /dev/null +++ b/tests/test_contract_detection.py @@ -0,0 +1,58 @@ +"""Regression tests for contract base detection across SDK eras.""" + +import ast + +import pytest + +from genvm_linter.lint.ast_utils import is_contract_subclass +from genvm_linter.lint.safety import check_safety +from genvm_linter.lint.structure import check_structure + + +@pytest.mark.parametrize( + "base", + [ + "Contract", + "gl.Contract", + "gl.contract.Contract", + "genlayer.Contract", + "genlayer.contract.Contract", + ], +) +def test_all_contract_base_spellings_enable_contract_scoped_rules(base): + source = f"""# {{ "Depends": "py-genlayer:test" }} +class StoredValue: + pass + +class Storage({base}): + count: int + value: StoredValue + + def missing_self(): + raise ValueError("bad") +""" + + structure_codes = {warning.code for warning in check_structure(source)} + safety_codes = {warning.code for warning in check_safety(source)} + + assert {"E014", "E015", "E022"} <= structure_codes + assert "W004" in safety_codes + + +class TestNonContractBases: + """Guards against future over-broadening of CONTRACT_BASE_NAMES.""" + + @pytest.mark.parametrize( + "source", + [ + "class C(object): pass", + "class C: pass", + "class C(foo.bar.Baz): pass", + "class C(gl.contract.Other): pass", + "class C(other.contract.Contract): pass", + "class C(Contractish): pass", + ], + ) + def test_unrelated_bases_are_not_contracts(self, source): + node = ast.parse(source).body[0] + assert is_contract_subclass(node) is False diff --git a/tests/test_sdk_loader.py b/tests/test_sdk_loader.py new file mode 100644 index 0000000..1a848b1 --- /dev/null +++ b/tests/test_sdk_loader.py @@ -0,0 +1,318 @@ +"""Unit tests for SDK layout compatibility.""" + +import io +import json +import sys +import tarfile +from pathlib import Path +from types import ModuleType + +import pytest + +from genvm_linter.validate import artifacts, sdk_loader + + +def _package(name: str) -> ModuleType: + package = ModuleType(name) + package.__path__ = [] + return package + + +def _clear_genlayer_modules(monkeypatch): + for name in list(sys.modules): + if name == "genlayer" or name.startswith("genlayer."): + monkeypatch.delitem(sys.modules, name) + + +def _make_usable_prebuilt_root(tmp_path: Path) -> Path: + root = tmp_path / "genvm" + (root / "bin").mkdir(parents=True) + (root / "bin" / "genvm-modules").write_bytes(b"") + (root / "runners").mkdir() + return root + + +def _write_runner_tar( + root: Path, + runner_type: str, + runner_hash: str, + files: dict[str, bytes], + *, + legacy_version: str | None = None, +) -> Path: + if legacy_version is None: + tar_path = ( + root + / "runners" + / runner_type + / runner_hash[:2] + / f"{runner_hash[2:]}.tar" + ) + else: + tar_path = ( + root + / "executor" + / legacy_version + / "legacy-runners" + / runner_type + / runner_hash[:2] + / f"{runner_hash[2:]}.tar" + ) + tar_path.parent.mkdir(parents=True, exist_ok=True) + + with tarfile.open(tar_path, mode="w") as runner_tar: + for name, payload in files.items(): + member = tarfile.TarInfo(name) + member.size = len(payload) + runner_tar.addfile(member, io.BytesIO(payload)) + return tar_path + + +def _write_sdk_runners( + root: Path, + *, + legacy_version: str | None = None, +) -> tuple[str, str]: + genlayer_hash = "abgenlayerhash" + std_hash = "cdstdlibhash" + runner_manifest = { + "Seq": [{"Depends": f"py-lib-genlayer-std:{std_hash}"}], + } + _write_runner_tar( + root, + "py-genlayer", + genlayer_hash, + {"runner.json": json.dumps(runner_manifest).encode()}, + legacy_version=legacy_version, + ) + _write_runner_tar( + root, + "py-lib-genlayer-std", + std_hash, + {"src/genlayer/__init__.py": b""}, + legacy_version=legacy_version, + ) + return genlayer_hash, std_hash + + +def _clear_source_environment(monkeypatch) -> None: + for env_var in ( + artifacts.GENVM_SOURCE_MODE_ENV, + artifacts.GENVM_PREBUILT_DIR_ENV, + artifacts.GENVMROOT_ENV, + ): + monkeypatch.delenv(env_var, raising=False) + + +def test_prebuilt_tree_is_preferred_and_resolves_sdk(monkeypatch, tmp_path): + _clear_source_environment(monkeypatch) + root = _make_usable_prebuilt_root(tmp_path) + genlayer_hash, std_hash = _write_sdk_runners(root) + monkeypatch.setenv(artifacts.GENVM_PREBUILT_DIR_ENV, str(root)) + monkeypatch.setattr(artifacts, "CACHE_DIR", tmp_path / "cache") + + def _fail_download(*args, **kwargs): + raise AssertionError("a usable prebuilt tree must prevent release download") + + monkeypatch.setattr(artifacts, "download_artifacts", _fail_download) + + artifact_path = artifacts.resolve_artifact_source() + sdk_paths, notes = sdk_loader.extract_sdk_paths( + artifact_path, + {"py-genlayer": genlayer_hash}, + ) + + assert artifact_path == root + assert notes == [] + assert sdk_paths == [ + tmp_path + / "cache" + / "extracted" + / "prebuilt" + / "py-lib-genlayer-std" + / std_hash + ] + assert (sdk_paths[0] / "src" / "genlayer" / "__init__.py").is_file() + + +def test_explicit_broken_prebuilt_tree_hard_fails(monkeypatch, tmp_path): + _clear_source_environment(monkeypatch) + broken_root = tmp_path / "incomplete-genvm" + broken_root.mkdir() + monkeypatch.setenv(artifacts.GENVM_SOURCE_MODE_ENV, "prebuilt") + monkeypatch.setenv(artifacts.GENVM_PREBUILT_DIR_ENV, str(broken_root)) + + def _fail_download(*args, **kwargs): + raise AssertionError("explicit prebuilt mode must never fall back to release") + + monkeypatch.setattr(artifacts, "download_artifacts", _fail_download) + + with pytest.raises(RuntimeError, match="GENVM_SOURCE_MODE=prebuilt.*unusable"): + artifacts.resolve_artifact_source() + + +def test_auto_mode_without_tree_downloads_release(monkeypatch, tmp_path): + _clear_source_environment(monkeypatch) + bundle = tmp_path / "bundle.tar.xz" + calls = [] + + def _download(version=None, progress_callback=None): + calls.append((version, progress_callback)) + return bundle + + monkeypatch.setattr(artifacts, "download_artifacts", _download) + + assert artifacts.resolve_artifact_source() == bundle + assert calls == [(None, None)] + + +def test_prebuilt_legacy_layout_resolves_sdk(monkeypatch, tmp_path): + _clear_source_environment(monkeypatch) + root = _make_usable_prebuilt_root(tmp_path) + genlayer_hash, std_hash = _write_sdk_runners( + root, + legacy_version="v0.2.17", + ) + monkeypatch.setenv(artifacts.GENVM_SOURCE_MODE_ENV, "prebuilt") + monkeypatch.setenv(artifacts.GENVM_PREBUILT_DIR_ENV, str(root)) + monkeypatch.setattr(artifacts, "CACHE_DIR", tmp_path / "cache") + + artifact_path = artifacts.resolve_artifact_source() + sdk_paths, _notes = sdk_loader.extract_sdk_paths( + artifact_path, + {"py-genlayer": genlayer_hash}, + ) + + assert artifact_path == root + assert sdk_paths[0].name == std_hash + assert (sdk_paths[0] / "src" / "genlayer" / "__init__.py").is_file() + + +def test_genvmroot_is_a_backwards_compatible_prebuilt_alias(monkeypatch, tmp_path): + _clear_source_environment(monkeypatch) + root = _make_usable_prebuilt_root(tmp_path) + monkeypatch.setenv(artifacts.GENVMROOT_ENV, str(root)) + + assert artifacts.resolve_artifact_source() == root + + +def test_genvm_prebuilt_dir_takes_precedence_over_genvmroot(monkeypatch, tmp_path): + _clear_source_environment(monkeypatch) + preferred_root = _make_usable_prebuilt_root(tmp_path / "preferred") + alias_root = _make_usable_prebuilt_root(tmp_path / "alias") + monkeypatch.setenv(artifacts.GENVM_PREBUILT_DIR_ENV, str(preferred_root)) + monkeypatch.setenv(artifacts.GENVMROOT_ENV, str(alias_root)) + + assert artifacts.resolve_artifact_source() == preferred_root + + +def test_release_mode_ignores_configured_prebuilt_tree(monkeypatch, tmp_path): + _clear_source_environment(monkeypatch) + root = _make_usable_prebuilt_root(tmp_path) + bundle = tmp_path / "bundle.tar.xz" + monkeypatch.setenv(artifacts.GENVM_SOURCE_MODE_ENV, "release") + monkeypatch.setenv(artifacts.GENVM_PREBUILT_DIR_ENV, str(root)) + monkeypatch.setattr( + artifacts, + "download_artifacts", + lambda version=None, progress_callback=None: bundle, + ) + + assert artifacts.resolve_artifact_source() == bundle + + +def test_import_get_schema_prefers_current_sdk_layout(monkeypatch): + _clear_genlayer_modules(monkeypatch) + + def expected(contract): + return {"contract": contract} + + module = ModuleType("genlayer._internal.get_schema") + module.get_schema = expected + monkeypatch.setitem(sys.modules, "genlayer", _package("genlayer")) + monkeypatch.setitem(sys.modules, "genlayer._internal", _package("genlayer._internal")) + monkeypatch.setitem(sys.modules, "genlayer._internal.get_schema", module) + + assert sdk_loader._import_get_schema() is expected + + +def test_import_get_schema_falls_back_to_legacy_sdk_layout(monkeypatch): + _clear_genlayer_modules(monkeypatch) + + def expected(contract): + return {"contract": contract} + + module = ModuleType("genlayer.py.get_schema") + module.get_schema = expected + monkeypatch.setitem(sys.modules, "genlayer", _package("genlayer")) + monkeypatch.setitem(sys.modules, "genlayer.py", _package("genlayer.py")) + monkeypatch.setitem(sys.modules, "genlayer.py.get_schema", module) + + assert sdk_loader._import_get_schema() is expected + + +def test_broken_new_sdk_is_not_masked_by_legacy_fallback(monkeypatch): + """A new-layout SDK whose own imports are broken must surface that error. + + Falling back to the legacy path here would hide a genuinely broken SDK + behind a confusing 'no module named genlayer.py' error. + """ + _clear_genlayer_modules(monkeypatch) + + genlayer = _package("genlayer") + internal = _package("genlayer._internal") + + def _explode(): + raise ModuleNotFoundError( + "No module named 'genlayer._internal.calldata'", + name="genlayer._internal.calldata", + ) + + get_schema_mod = ModuleType("genlayer._internal.get_schema") + get_schema_mod.__getattr__ = lambda _name: _explode() + + monkeypatch.setitem(sys.modules, "genlayer", genlayer) + monkeypatch.setitem(sys.modules, "genlayer._internal", internal) + # Importing the module raises a ModuleNotFoundError for a DIFFERENT module. + monkeypatch.setitem(sys.modules, "genlayer._internal.get_schema", None) + + def _fake_import(name, *args, **kwargs): + if name == "genlayer._internal.get_schema": + _explode() + raise AssertionError(f"legacy fallback must not be reached (tried {name})") + + monkeypatch.setattr("builtins.__import__", _fake_import) + + try: + sdk_loader._import_get_schema() + except ModuleNotFoundError as exc: + assert exc.name == "genlayer._internal.calldata" + else: + raise AssertionError("expected the underlying ModuleNotFoundError to propagate") + + +def test_generate_stubs_unpacks_extract_sdk_paths(monkeypatch, tmp_path): + """extract_sdk_paths returns (paths, notes). + + stubs.py used to bind the whole tuple, so `sdk_paths[0]` was the list and + `sdk_path / "src"` raised TypeError -- and the `if not sdk_paths` guard + above it could never fire, because a 2-tuple is always truthy. + """ + from genvm_linter import stubs + + sdk_dir = tmp_path / "sdk" + (sdk_dir / "src" / "genlayer").mkdir(parents=True) + + monkeypatch.setattr(stubs, "get_latest_version", lambda: "v0.6.0-rc1") + monkeypatch.setattr(stubs, "download_artifacts", lambda *a, **k: tmp_path / "bundle.tar.xz") + monkeypatch.setattr( + sdk_loader, "extract_sdk_paths", lambda *a, **k: ([sdk_dir], ["a note"]) + ) + + # Only assert we get past the unpacking/path arithmetic without a TypeError. + try: + stubs.generate_stubs(output_path=tmp_path / "out") + except TypeError as exc: # pragma: no cover - this is the regression + raise AssertionError(f"stub generation still mishandles the tuple: {exc}") from exc + except Exception: + pass