genlayer-test 0.10.1__py3-none-any.whl → 0.12.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,288 @@
1
+ """
2
+ Contract loader for direct test runner.
3
+
4
+ Handles:
5
+ - SDK version setup based on contract headers
6
+ - Message context injection via stdin
7
+ - WASI mock installation
8
+ - Contract class discovery and instantiation
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ import hashlib
15
+ import importlib.util
16
+ from pathlib import Path
17
+ from typing import Any, Optional, Type, TYPE_CHECKING
18
+
19
+ if TYPE_CHECKING:
20
+ from .vm import VMContext
21
+
22
+
23
+ def load_contract_class(
24
+ contract_path: Path,
25
+ vm: "VMContext",
26
+ sdk_version: Optional[str] = None,
27
+ ) -> Type[Any]:
28
+ """
29
+ Load a contract class from file.
30
+
31
+ Sets up SDK paths, WASI mock, and message context.
32
+ """
33
+ contract_path = Path(contract_path).resolve()
34
+
35
+ if not contract_path.exists():
36
+ raise FileNotFoundError(f"Contract not found: {contract_path}")
37
+
38
+ # 1. Setup WASI mock FIRST (before genlayer is imported)
39
+ from . import wasi_mock
40
+ wasi_mock.set_vm(vm)
41
+ sys.modules['_genlayer_wasi'] = wasi_mock
42
+
43
+ # 2. Setup SDK paths (this adds genlayer to sys.path)
44
+ from .sdk_loader import setup_sdk_paths
45
+ setup_sdk_paths(contract_path, sdk_version)
46
+
47
+ # 3. Inject message context into fd 0 BEFORE importing contract
48
+ # (genlayer reads message from fd 0 at import time)
49
+ _inject_message_to_fd0(vm)
50
+
51
+ # 4. Load the contract module
52
+ module = _load_module(contract_path)
53
+
54
+ contract_cls = _find_contract_class(module)
55
+
56
+ if contract_cls is None:
57
+ raise ValueError(f"No contract class found in {contract_path}")
58
+
59
+ return contract_cls
60
+
61
+
62
+ def deploy_contract(
63
+ contract_path: Path,
64
+ vm: "VMContext",
65
+ *args: Any,
66
+ sdk_version: Optional[str] = None,
67
+ **kwargs: Any,
68
+ ) -> Any:
69
+ """Deploy a contract and return an instance."""
70
+ contract_path = Path(contract_path).resolve()
71
+
72
+ if vm._contract_address is None:
73
+ addr_hash = hashlib.sha256(str(contract_path).encode()).digest()[:20]
74
+ vm._contract_address = addr_hash
75
+
76
+ contract_cls = load_contract_class(contract_path, vm, sdk_version)
77
+
78
+ instance = _allocate_contract(contract_cls, vm, *args, **kwargs)
79
+
80
+ return instance
81
+
82
+
83
+ def _inject_message_to_fd0(vm: "VMContext") -> None:
84
+ """Inject message context by replacing stdin (fd 0) with encoded message."""
85
+ import os
86
+ import tempfile
87
+
88
+ try:
89
+ from genlayer.py import calldata
90
+ from genlayer.py.types import Address
91
+ except ImportError:
92
+ return
93
+
94
+ # Convert addresses to Address type
95
+ sender_addr = vm.sender
96
+ if isinstance(sender_addr, bytes):
97
+ sender_addr = Address(sender_addr)
98
+
99
+ contract_addr = vm._contract_address
100
+ if isinstance(contract_addr, bytes):
101
+ contract_addr = Address(contract_addr)
102
+
103
+ origin_addr = vm.origin
104
+ if isinstance(origin_addr, bytes):
105
+ origin_addr = Address(origin_addr)
106
+
107
+ # Build message dict
108
+ message_data = {
109
+ 'contract_address': contract_addr,
110
+ 'sender_address': sender_addr,
111
+ 'origin_address': origin_addr,
112
+ 'stack': [],
113
+ 'value': vm._value,
114
+ 'datetime': vm._datetime,
115
+ 'is_init': False,
116
+ 'chain_id': vm._chain_id,
117
+ 'entry_kind': 0,
118
+ 'entry_data': b'',
119
+ 'entry_stage_data': None,
120
+ }
121
+
122
+ # Encode the message
123
+ encoded = calldata.encode(message_data)
124
+
125
+ # Create a temp file with the encoded message
126
+ fd, path = tempfile.mkstemp()
127
+ try:
128
+ os.write(fd, encoded)
129
+ os.lseek(fd, 0, os.SEEK_SET) # Reset to beginning
130
+
131
+ # Save original stdin fd
132
+ original_stdin = os.dup(0)
133
+ vm._original_stdin_fd = original_stdin
134
+
135
+ # Replace stdin with our temp file
136
+ os.dup2(fd, 0)
137
+ finally:
138
+ os.close(fd)
139
+ os.unlink(path)
140
+
141
+
142
+ def _load_module(contract_path: Path) -> Any:
143
+ """Load a Python module from file path."""
144
+ module_name = f"_contract_{contract_path.stem}"
145
+
146
+ if module_name in sys.modules:
147
+ del sys.modules[module_name]
148
+
149
+ spec = importlib.util.spec_from_file_location(module_name, contract_path)
150
+ if spec is None or spec.loader is None:
151
+ raise ImportError(f"Cannot load module from {contract_path}")
152
+
153
+ module = importlib.util.module_from_spec(spec)
154
+ sys.modules[module_name] = module
155
+
156
+ try:
157
+ spec.loader.exec_module(module)
158
+ except Exception as e:
159
+ if module_name in sys.modules:
160
+ del sys.modules[module_name]
161
+ raise ImportError(f"Failed to load contract: {e}") from e
162
+
163
+ return module
164
+
165
+
166
+ def _find_contract_class(module: Any) -> Optional[Type[Any]]:
167
+ """Find the contract class in a module."""
168
+ import dataclasses
169
+
170
+ candidates = []
171
+
172
+ for name in dir(module):
173
+ if name.startswith('_'):
174
+ continue
175
+
176
+ obj = getattr(module, name)
177
+
178
+ if not isinstance(obj, type):
179
+ continue
180
+
181
+ # Skip dataclasses - they're storage types, not contracts
182
+ if dataclasses.is_dataclass(obj):
183
+ continue
184
+
185
+ # Highest priority: explicit __gl_contract__ marker
186
+ if getattr(obj, '__gl_contract__', False):
187
+ return obj
188
+
189
+ # Second priority: inherits from Contract
190
+ for base in obj.__mro__:
191
+ if base.__name__ in ('Contract', 'gl.Contract'):
192
+ return obj
193
+
194
+ # Third priority: has storage-like annotations
195
+ # Collect as candidates but don't return immediately
196
+ if hasattr(obj, '__annotations__'):
197
+ annotations = obj.__annotations__
198
+ storage_types = ('TreeMap', 'DynArray', 'Array', 'u256', 'Address')
199
+ for ann in annotations.values():
200
+ ann_str = str(ann)
201
+ if any(st in ann_str for st in storage_types):
202
+ candidates.append(obj)
203
+ break
204
+
205
+ # Return first candidate if no explicit contract found
206
+ if candidates:
207
+ return candidates[0]
208
+
209
+ return None
210
+
211
+
212
+ def _allocate_contract(
213
+ contract_cls: Type[Any],
214
+ vm: "VMContext",
215
+ *args: Any,
216
+ **kwargs: Any,
217
+ ) -> Any:
218
+ """Allocate and initialize a contract instance."""
219
+ try:
220
+ from genlayer.py.storage import Root, ROOT_SLOT_ID
221
+ from genlayer.py.storage._internal.generate import (
222
+ ORIGINAL_INIT_ATTR,
223
+ _storage_build,
224
+ Lit,
225
+ )
226
+
227
+ # Build the storage type descriptor
228
+ td = _storage_build(contract_cls, {})
229
+ assert not isinstance(td, Lit)
230
+
231
+ # Use the VM's storage manager
232
+ slot = vm._storage.get_store_slot(ROOT_SLOT_ID)
233
+ instance = td.get(slot, 0)
234
+
235
+ # Find and call the original __init__
236
+ init = getattr(td, 'cls', None)
237
+ if init is None:
238
+ init = getattr(contract_cls, '__init__', None)
239
+ else:
240
+ init = getattr(init, '__init__', None)
241
+ if init is not None:
242
+ if hasattr(init, ORIGINAL_INIT_ATTR):
243
+ init = getattr(init, ORIGINAL_INIT_ATTR)
244
+ init(instance, *args, **kwargs)
245
+
246
+ return instance
247
+
248
+ except ImportError:
249
+ pass
250
+
251
+ try:
252
+ from genlayer.py.storage import Root
253
+
254
+ Root.MANAGER = vm._storage
255
+
256
+ root_slot = vm._storage.get_store_slot(b'\x00' * 32)
257
+
258
+ instance = contract_cls.__new__(contract_cls)
259
+
260
+ if hasattr(instance, '_storage_slot'):
261
+ instance._storage_slot = root_slot.indirect(0)
262
+ instance._off = 0
263
+
264
+ if args or kwargs:
265
+ instance.__init__(*args, **kwargs)
266
+
267
+ return instance
268
+
269
+ except ImportError:
270
+ pass
271
+
272
+ return contract_cls(*args, **kwargs)
273
+
274
+
275
+ def create_address(seed: str) -> Any:
276
+ """Create a deterministic address from seed string."""
277
+ addr_bytes = hashlib.sha256(seed.encode()).digest()[:20]
278
+
279
+ try:
280
+ from genlayer.py.types import Address
281
+ return Address(addr_bytes)
282
+ except ImportError:
283
+ return addr_bytes
284
+
285
+
286
+ def create_test_addresses(count: int = 10) -> list:
287
+ """Create a list of test addresses."""
288
+ return [create_address(f"test_address_{i}") for i in range(count)]
@@ -0,0 +1,117 @@
1
+ """
2
+ Pytest plugin for direct GenLayer contract testing.
3
+
4
+ Provides fixtures:
5
+ - direct_vm: VMContext for Foundry-style cheatcodes
6
+ - direct_deploy: Factory for deploying contracts
7
+
8
+ Usage:
9
+ def test_transfer(direct_vm, direct_deploy):
10
+ token = direct_deploy("contracts/Token.py")
11
+ direct_vm.sender = alice
12
+ token.transfer(bob, 100)
13
+ assert token.balances[bob] == 100
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import pytest
19
+ from pathlib import Path
20
+ from typing import Any, Optional, Callable
21
+
22
+ from .vm import VMContext
23
+ from .loader import deploy_contract, create_address, create_test_addresses
24
+
25
+
26
+ @pytest.fixture
27
+ def direct_vm() -> VMContext:
28
+ """
29
+ Provides a fresh VMContext for each test.
30
+ The VM is automatically activated for the test scope.
31
+ """
32
+ ctx = VMContext()
33
+ ctx.sender = create_address("default_sender")
34
+
35
+ with ctx.activate():
36
+ yield ctx
37
+
38
+
39
+ @pytest.fixture
40
+ def direct_deploy(direct_vm: VMContext) -> Callable[..., Any]:
41
+ """
42
+ Factory fixture for deploying contracts directly.
43
+
44
+ Usage:
45
+ def test_example(direct_deploy):
46
+ token = direct_deploy("path/to/Token.py", initial_supply=1000)
47
+ """
48
+ def _deploy(
49
+ contract_path: str,
50
+ *args: Any,
51
+ sdk_version: Optional[str] = None,
52
+ **kwargs: Any,
53
+ ) -> Any:
54
+ path = Path(contract_path)
55
+
56
+ if not path.is_absolute():
57
+ if path.exists():
58
+ path = path.resolve()
59
+ else:
60
+ for base in [
61
+ Path.cwd(),
62
+ Path.cwd() / "contracts",
63
+ Path.cwd() / "intelligent-contracts",
64
+ ]:
65
+ candidate = base / contract_path
66
+ if candidate.exists():
67
+ path = candidate.resolve()
68
+ break
69
+
70
+ return deploy_contract(path, direct_vm, *args, sdk_version=sdk_version, **kwargs)
71
+
72
+ return _deploy
73
+
74
+
75
+ @pytest.fixture
76
+ def direct_alice() -> Any:
77
+ """Test address: Alice."""
78
+ return create_address("alice")
79
+
80
+
81
+ @pytest.fixture
82
+ def direct_bob() -> Any:
83
+ """Test address: Bob."""
84
+ return create_address("bob")
85
+
86
+
87
+ @pytest.fixture
88
+ def direct_charlie() -> Any:
89
+ """Test address: Charlie."""
90
+ return create_address("charlie")
91
+
92
+
93
+ @pytest.fixture
94
+ def direct_owner() -> Any:
95
+ """Test address: Owner (default sender)."""
96
+ return create_address("default_sender")
97
+
98
+
99
+ @pytest.fixture
100
+ def direct_accounts() -> list:
101
+ """List of 10 test addresses."""
102
+ return create_test_addresses(10)
103
+
104
+
105
+ def pytest_configure(config):
106
+ """Register markers for direct tests."""
107
+ config.addinivalue_line(
108
+ "markers",
109
+ "direct: mark test as using direct contract execution (no simulator)",
110
+ )
111
+
112
+
113
+ def pytest_collection_modifyitems(config, items):
114
+ """Auto-mark tests using direct fixtures."""
115
+ for item in items:
116
+ if 'direct_vm' in item.fixturenames or 'direct_deploy' in item.fixturenames:
117
+ item.add_marker(pytest.mark.direct)
@@ -0,0 +1,260 @@
1
+ """
2
+ SDK version loader for direct test runner.
3
+
4
+ Handles downloading and extracting the correct genlayer-py-std version
5
+ based on contract header dependencies, similar to genvm-linter.
6
+ """
7
+
8
+ import os
9
+ import re
10
+ import sys
11
+ import json
12
+ import tarfile
13
+ import tempfile
14
+ import urllib.request
15
+ from pathlib import Path
16
+ from typing import Optional, Dict, List
17
+
18
+ CACHE_DIR = Path.home() / ".cache" / "gltest-direct"
19
+ GITHUB_RELEASES_URL = "https://github.com/genlayerlabs/genvm/releases"
20
+
21
+ RUNNER_TYPE = "py-genlayer"
22
+ STD_LIB_TYPE = "py-lib-genlayer-std"
23
+ EMBEDDINGS_TYPE = "py-lib-genlayer-embeddings"
24
+ PROTOBUF_TYPE = "py-lib-protobuf"
25
+
26
+
27
+ def parse_contract_header(contract_path: Path) -> Dict[str, str]:
28
+ """
29
+ Parse contract file header to extract dependency hashes.
30
+
31
+ Returns dict mapping dependency name to hash.
32
+ """
33
+ deps = {}
34
+ with open(contract_path, "r") as f:
35
+ content = f.read(2000)
36
+
37
+ pattern = r'"Depends":\s*"([^:]+):([^"]+)"'
38
+ for match in re.finditer(pattern, content):
39
+ name, hash_val = match.groups()
40
+ deps[name] = hash_val
41
+
42
+ return deps
43
+
44
+
45
+ def get_latest_version() -> str:
46
+ """Get latest genvm release version from GitHub."""
47
+ try:
48
+ req = urllib.request.Request(
49
+ f"{GITHUB_RELEASES_URL}/latest",
50
+ method="HEAD",
51
+ )
52
+ req.add_header("User-Agent", "gltest-direct")
53
+ with urllib.request.urlopen(req, timeout=10) as resp:
54
+ final_url = resp.url
55
+ version = final_url.split("/")[-1]
56
+ return version
57
+ except Exception:
58
+ return "v0.2.12"
59
+
60
+
61
+ def list_cached_versions() -> List[str]:
62
+ """List all cached genvm versions."""
63
+ if not CACHE_DIR.exists():
64
+ return []
65
+
66
+ versions = []
67
+ for f in CACHE_DIR.glob("genvm-universal-*.tar.xz"):
68
+ match = re.search(r"genvm-universal-(.+)\.tar\.xz", f.name)
69
+ if match:
70
+ versions.append(match.group(1))
71
+ return sorted(versions, reverse=True)
72
+
73
+
74
+ def download_artifacts(version: str) -> Path:
75
+ """Download genvm release tarball if not cached."""
76
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
77
+
78
+ tarball_name = f"genvm-universal-{version}.tar.xz"
79
+ tarball_path = CACHE_DIR / tarball_name
80
+
81
+ if tarball_path.exists():
82
+ return tarball_path
83
+
84
+ url = f"{GITHUB_RELEASES_URL}/download/{version}/genvm-universal.tar.xz"
85
+ print(f"Downloading {url}...")
86
+
87
+ req = urllib.request.Request(url)
88
+ req.add_header("User-Agent", "gltest-direct")
89
+
90
+ with urllib.request.urlopen(req, timeout=300) as resp:
91
+ total = int(resp.headers.get("Content-Length", 0))
92
+ downloaded = 0
93
+
94
+ with tempfile.NamedTemporaryFile(delete=False, dir=CACHE_DIR) as tmp:
95
+ while True:
96
+ chunk = resp.read(1024 * 1024)
97
+ if not chunk:
98
+ break
99
+ tmp.write(chunk)
100
+ downloaded += len(chunk)
101
+ if total:
102
+ pct = downloaded * 100 // total
103
+ print(f"\r {pct}% ({downloaded // 1024 // 1024}MB)", end="", flush=True)
104
+
105
+ tmp_path = tmp.name
106
+
107
+ print()
108
+ os.rename(tmp_path, tarball_path)
109
+ return tarball_path
110
+
111
+
112
+ def extract_runner(
113
+ tarball_path: Path,
114
+ runner_type: str,
115
+ runner_hash: Optional[str] = None,
116
+ version: Optional[str] = None,
117
+ ) -> Path:
118
+ """Extract a runner from the tarball."""
119
+ if version is None:
120
+ match = re.search(r"genvm-universal-(.+)\.tar\.xz", tarball_path.name)
121
+ version = match.group(1) if match else "unknown"
122
+
123
+ extract_base = CACHE_DIR / "extracted" / version / runner_type
124
+
125
+ # Fast path: if hash specified and already extracted, skip tarball entirely
126
+ if runner_hash and runner_hash.lower() != "latest":
127
+ extract_dir = extract_base / runner_hash
128
+ if extract_dir.exists():
129
+ return extract_dir
130
+
131
+ # Check if any version already extracted (for "latest" case)
132
+ if extract_base.exists():
133
+ existing = sorted(extract_base.iterdir(), reverse=True)
134
+ if existing and (not runner_hash or runner_hash.lower() == "latest"):
135
+ return existing[0]
136
+
137
+ # Need to open tarball - this is slow (~13s for xz)
138
+ with tarfile.open(tarball_path, "r:xz") as outer_tar:
139
+ prefix = f"runners/{runner_type}/"
140
+ runner_tars = [
141
+ m.name for m in outer_tar.getmembers()
142
+ if m.name.startswith(prefix) and m.name.endswith(".tar")
143
+ ]
144
+
145
+ if not runner_tars:
146
+ raise ValueError(f"No {runner_type} runners found in tarball")
147
+
148
+ # Treat "latest" as no specific hash
149
+ if runner_hash and runner_hash.lower() != "latest":
150
+ target = f"runners/{runner_type}/{runner_hash[:2]}/{runner_hash[2:]}.tar"
151
+ if target not in runner_tars:
152
+ raise ValueError(f"Runner hash {runner_hash} not found")
153
+ runner_tar_name = target
154
+ extract_dir = extract_base / runner_hash
155
+ else:
156
+ runner_tar_name = sorted(runner_tars)[-1]
157
+ parts = runner_tar_name.split("/")
158
+ runner_hash = parts[-2] + parts[-1].replace(".tar", "")
159
+ extract_dir = extract_base / runner_hash
160
+
161
+ if extract_dir.exists():
162
+ return extract_dir
163
+
164
+ inner_tar_file = outer_tar.extractfile(runner_tar_name)
165
+ if inner_tar_file is None:
166
+ raise ValueError(f"Failed to read {runner_tar_name}")
167
+
168
+ extract_dir.mkdir(parents=True, exist_ok=True)
169
+
170
+ with tarfile.open(fileobj=inner_tar_file, mode="r:") as inner_tar:
171
+ inner_tar.extractall(extract_dir)
172
+
173
+ return extract_dir
174
+
175
+
176
+ def parse_runner_manifest(runner_dir: Path) -> Dict[str, str]:
177
+ """Parse runner.json to get transitive dependencies."""
178
+ manifest_path = runner_dir / "runner.json"
179
+ if not manifest_path.exists():
180
+ return {}
181
+
182
+ with open(manifest_path) as f:
183
+ manifest = json.load(f)
184
+
185
+ deps = {}
186
+ seq = manifest.get("Seq", [])
187
+ for item in seq:
188
+ if "Depends" in item:
189
+ dep = item["Depends"]
190
+ if ":" in dep:
191
+ name, hash_val = dep.split(":", 1)
192
+ deps[name] = hash_val
193
+
194
+ return deps
195
+
196
+
197
+ def setup_sdk_paths(
198
+ contract_path: Optional[Path] = None,
199
+ version: Optional[str] = None,
200
+ ) -> List[Path]:
201
+ """
202
+ Setup sys.path with correct SDK versions for a contract.
203
+
204
+ Returns list of paths added to sys.path.
205
+ """
206
+ contract_deps = {}
207
+ if contract_path and contract_path.exists():
208
+ contract_deps = parse_contract_header(contract_path)
209
+
210
+ if version is None:
211
+ cached = list_cached_versions()
212
+ version = cached[0] if cached else get_latest_version()
213
+
214
+ tarball = download_artifacts(version)
215
+
216
+ runner_hash = contract_deps.get(RUNNER_TYPE)
217
+ runner_dir = extract_runner(tarball, RUNNER_TYPE, runner_hash, version)
218
+
219
+ runner_deps = parse_runner_manifest(runner_dir)
220
+
221
+ std_hash = runner_deps.get(STD_LIB_TYPE)
222
+ std_dir: Optional[Path] = None
223
+ if std_hash:
224
+ std_dir = extract_runner(tarball, STD_LIB_TYPE, std_hash, version)
225
+
226
+ embeddings_hash = contract_deps.get(EMBEDDINGS_TYPE)
227
+ embeddings_dir: Optional[Path] = None
228
+ proto_dir: Optional[Path] = None
229
+ if embeddings_hash:
230
+ embeddings_dir = extract_runner(tarball, EMBEDDINGS_TYPE, embeddings_hash, version)
231
+ proto_hash = runner_deps.get(PROTOBUF_TYPE)
232
+ if proto_hash:
233
+ proto_dir = extract_runner(tarball, PROTOBUF_TYPE, proto_hash, version)
234
+
235
+ added_paths = []
236
+
237
+ # Helper to add path - tries both 'src' subdirectory and direct directory
238
+ def add_sdk_path(sdk_dir: Path) -> None:
239
+ src_path = sdk_dir / "src"
240
+ if src_path.exists():
241
+ path_to_add = src_path
242
+ else:
243
+ path_to_add = sdk_dir
244
+
245
+ if str(path_to_add) not in sys.path:
246
+ sys.path.insert(0, str(path_to_add))
247
+ added_paths.append(path_to_add)
248
+
249
+ add_sdk_path(runner_dir)
250
+
251
+ if std_dir:
252
+ add_sdk_path(std_dir)
253
+
254
+ if embeddings_dir:
255
+ add_sdk_path(embeddings_dir)
256
+
257
+ if proto_dir:
258
+ add_sdk_path(proto_dir)
259
+
260
+ return added_paths
gltest/direct/types.py ADDED
@@ -0,0 +1,18 @@
1
+ """
2
+ Type definitions for direct test runner.
3
+
4
+ Reuses MockedLLMResponse and MockedWebResponse from gltest.types.
5
+ """
6
+
7
+ # Re-export from parent package for convenience
8
+ from ..types import (
9
+ MockedLLMResponse,
10
+ MockedWebResponse,
11
+ MockedWebResponseData,
12
+ )
13
+
14
+ __all__ = [
15
+ "MockedLLMResponse",
16
+ "MockedWebResponse",
17
+ "MockedWebResponseData",
18
+ ]