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.
- {genlayer_test-0.10.1.dist-info → genlayer_test-0.12.0.dist-info}/METADATA +271 -6
- {genlayer_test-0.10.1.dist-info → genlayer_test-0.12.0.dist-info}/RECORD +16 -9
- {genlayer_test-0.10.1.dist-info → genlayer_test-0.12.0.dist-info}/WHEEL +1 -1
- {genlayer_test-0.10.1.dist-info → genlayer_test-0.12.0.dist-info}/entry_points.txt +1 -0
- {genlayer_test-0.10.1.dist-info → genlayer_test-0.12.0.dist-info}/licenses/LICENSE +2 -2
- gltest/direct/__init__.py +31 -0
- gltest/direct/loader.py +288 -0
- gltest/direct/pytest_plugin.py +117 -0
- gltest/direct/sdk_loader.py +260 -0
- gltest/direct/types.py +18 -0
- gltest/direct/vm.py +432 -0
- gltest/direct/wasi_mock.py +219 -0
- gltest/types.py +14 -0
- gltest/validators/validator_factory.py +37 -11
- gltest_cli/config/plugin.py +8 -6
- {genlayer_test-0.10.1.dist-info → genlayer_test-0.12.0.dist-info}/top_level.txt +0 -0
gltest/direct/loader.py
ADDED
|
@@ -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
|
+
]
|