-
Notifications
You must be signed in to change notification settings - Fork 42
feat: SDK reflection for faster schema extraction #1410
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
851ff0e
d4bb1e9
ec59c2e
eab656f
499045e
2226148
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,4 @@ | ||
| # backend/services/__init__.py | ||
| from backend.services.sdk_schema import extract_schema_via_sdk | ||
|
|
||
| __all__ = ["extract_schema_via_sdk"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| """ | ||
| SDK-based contract schema extraction using Python reflection. | ||
|
|
||
| This module provides a faster alternative to GenVM-based schema extraction | ||
| by using the genlayer-py-std SDK's reflection capabilities directly. | ||
| Performance improvement: ~50-100ms vs ~200-300ms with GenVM. | ||
|
|
||
| The approach: | ||
| 1. Mock _genlayer_wasi (the WASI module that provides storage/balance ops) | ||
| 2. Set GENERATING_DOCS=true to enable doc-generation mode in the SDK | ||
| 3. Import the contract source and find the Contract class | ||
| 4. Use genlayer.py.get_schema() for reflection-based schema extraction | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import importlib.util | ||
| import logging | ||
| import os | ||
| import sys | ||
| import tempfile | ||
| import time | ||
| from pathlib import Path | ||
| from unittest.mock import MagicMock | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| # SDK paths relative to GENVMROOT | ||
| _SDK_SRC_SUBPATH = "runners/genlayer-py-std/src" | ||
| _SDK_EMB_SUBPATH = "runners/genlayer-py-std/src-emb" | ||
|
|
||
|
|
||
| def _get_sdk_paths() -> tuple[Path, Path] | None: | ||
| """Get SDK paths from GENVMROOT environment variable.""" | ||
| genvmroot = os.environ.get("GENVMROOT") | ||
| if not genvmroot: | ||
| logger.debug("GENVMROOT not set, SDK schema extraction unavailable") | ||
| return None | ||
|
|
||
| root = Path(genvmroot) | ||
| sdk_src = root / _SDK_SRC_SUBPATH | ||
| sdk_emb = root / _SDK_EMB_SUBPATH | ||
|
|
||
| # In Docker, GenVM is extracted to /genvm but SDK paths may not exist | ||
| # Fall back to checking if the executor contains the SDK | ||
| if not sdk_src.exists(): | ||
| # Try alternate location in executor | ||
| tag = os.environ.get("GENVM_TAG", "") | ||
| alt_root = root / "executor" / tag | ||
| sdk_src_alt = alt_root / "runners" / "genlayer-py-std" / "src" | ||
| if sdk_src_alt.exists(): | ||
| sdk_src = sdk_src_alt | ||
| sdk_emb = alt_root / "runners" / "genlayer-py-std" / "src-emb" | ||
|
|
||
| if not sdk_src.exists(): | ||
| logger.debug(f"SDK source path not found: {sdk_src}") | ||
| return None | ||
|
|
||
| return sdk_src, sdk_emb | ||
|
|
||
|
|
||
| def _setup_wasi_mocks() -> None: | ||
| """Mock the _genlayer_wasi module that provides WASI bindings.""" | ||
| if "_genlayer_wasi" in sys.modules: | ||
| return # Already mocked | ||
|
|
||
| wasi_mock = MagicMock() | ||
| wasi_mock.storage_read = MagicMock(return_value=None) | ||
| wasi_mock.storage_write = MagicMock(return_value=None) | ||
| wasi_mock.get_balance = MagicMock(return_value=0) | ||
| wasi_mock.get_self_balance = MagicMock(return_value=0) | ||
| wasi_mock.gl_call = MagicMock(return_value=0) | ||
| sys.modules["_genlayer_wasi"] = wasi_mock | ||
|
|
||
|
|
||
| def extract_schema_via_sdk(contract_code: bytes) -> dict | None: | ||
| """ | ||
| Extract contract schema using SDK reflection. | ||
|
|
||
| Args: | ||
| contract_code: Contract source code as bytes (UTF-8 encoded Python) | ||
|
|
||
| Returns: | ||
| Schema dict if successful, None if SDK extraction failed. | ||
| Caller should fall back to GenVM-based extraction on None. | ||
|
|
||
| Performance: ~50-100ms vs ~200-300ms with GenVM | ||
| """ | ||
| sdk_paths = _get_sdk_paths() | ||
| if sdk_paths is None: | ||
| return None | ||
|
|
||
| sdk_src, sdk_emb = sdk_paths | ||
| start_time = time.time() | ||
|
|
||
| try: | ||
| # Setup environment | ||
| _setup_wasi_mocks() | ||
| os.environ["GENERATING_DOCS"] = "true" | ||
|
Comment on lines
+96
to
+99
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The environment variable is set but not cleaned up after extraction. This could affect subsequent operations in the same process that depend on this variable being unset. 🐛 Proposed fix try:
# Setup environment
_setup_wasi_mocks()
+ original_generating_docs = os.environ.get("GENERATING_DOCS")
os.environ["GENERATING_DOCS"] = "true"
# Add SDK paths to sys.path temporarily
original_path = sys.path.copy()
sys.path.insert(0, str(sdk_src))
if sdk_emb.exists():
sys.path.insert(0, str(sdk_emb))
try:
# ... existing code ...
finally:
# Restore sys.path
sys.path = original_path
+ # Restore GENERATING_DOCS
+ if original_generating_docs is None:
+ os.environ.pop("GENERATING_DOCS", None)
+ else:
+ os.environ["GENERATING_DOCS"] = original_generating_docs🤖 Prompt for AI Agents |
||
|
|
||
| # Add SDK paths to sys.path temporarily | ||
| original_path = sys.path.copy() | ||
| sys.path.insert(0, str(sdk_src)) | ||
| if sdk_emb.exists(): | ||
| sys.path.insert(0, str(sdk_emb)) | ||
|
|
||
| try: | ||
| # Import the schema extraction function | ||
| from genlayer.py.get_schema import get_schema | ||
|
|
||
| # Write contract to temp file and load it as a module | ||
| with tempfile.NamedTemporaryFile( | ||
| mode="wb", suffix=".py", delete=False | ||
| ) as f: | ||
| f.write(contract_code) | ||
| temp_path = f.name | ||
|
|
||
| try: | ||
| # Load contract module | ||
| spec = importlib.util.spec_from_file_location( | ||
| "__sdk_contract__", temp_path | ||
| ) | ||
| if spec is None or spec.loader is None: | ||
| logger.debug("Failed to create module spec for contract") | ||
| return None | ||
|
|
||
| module = importlib.util.module_from_spec(spec) | ||
| sys.modules["__sdk_contract__"] = module | ||
| spec.loader.exec_module(module) | ||
|
|
||
| # Find the Contract class | ||
| contract_class = None | ||
| for name, obj in vars(module).items(): | ||
| if isinstance(obj, type) and name != "Contract": | ||
| # Check if it inherits from Contract | ||
| bases = [b.__name__ for b in obj.__mro__] | ||
| if "Contract" in bases: | ||
| contract_class = obj | ||
| break | ||
|
|
||
| if contract_class is None: | ||
| logger.debug("No Contract class found in module") | ||
| return None | ||
|
|
||
| # Extract schema using SDK reflection | ||
| schema = get_schema(contract_class) | ||
|
|
||
| elapsed_ms = int((time.time() - start_time) * 1000) | ||
| logger.info( | ||
| f"SDK schema extraction succeeded in {elapsed_ms}ms " | ||
| f"for {contract_class.__name__}" | ||
| ) | ||
|
|
||
| return schema | ||
|
|
||
| finally: | ||
| # Cleanup temp file | ||
| try: | ||
| os.unlink(temp_path) | ||
| except Exception: | ||
| pass | ||
| # Remove from sys.modules | ||
| sys.modules.pop("__sdk_contract__", None) | ||
|
|
||
| finally: | ||
| # Restore sys.path | ||
| sys.path = original_path | ||
|
Comment on lines
+96
to
+167
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thread safety concern with global state modifications. This function modifies global state ( Consider adding a lock around the entire extraction process or documenting that concurrent extractions of different contracts may interfere with each other. 🔒️ Proposed fix - add extraction lockAdd a module-level lock: _extraction_lock = threading.Lock()Then wrap the extraction in the lock: sdk_src, sdk_emb = sdk_paths
start_time = time.time()
- try:
+ with _extraction_lock:
+ try:
# Setup environment
_setup_wasi_mocks()
# ... rest of the try block ...
+ except Exception as e:
+ # ... exception handling ...🧰 Tools🪛 Ruff (0.14.14)213-214: (S110) 213-213: Do not catch blind exception: (BLE001) 🤖 Prompt for AI Agents |
||
|
|
||
| except Exception as e: | ||
| elapsed_ms = int((time.time() - start_time) * 1000) | ||
| logger.debug( | ||
| f"SDK schema extraction failed after {elapsed_ms}ms: {e}", | ||
| exc_info=True, | ||
| ) | ||
| return None | ||
|
Comment on lines
+76
to
+175
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing The pipeline error indicates 🩹 Suggested compatibility shim+def clear_cache() -> None:
+ """No-op: cache removed, retained for backward compatibility."""
+ return None🧰 Tools🪛 Ruff (0.14.14)160-161: (S110) 160-160: Do not catch blind exception: (BLE001) 169-169: Do not catch blind exception: (BLE001) 🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| """Tests for SDK-based contract schema extraction.""" | ||
|
|
||
| import pytest | ||
| import os | ||
| from unittest.mock import patch, MagicMock | ||
| from backend.services.sdk_schema import ( | ||
| extract_schema_via_sdk, | ||
| _get_sdk_paths, | ||
| ) | ||
|
|
||
|
|
||
| class TestSdkSchemaExtraction: | ||
| """Test cases for SDK schema extraction.""" | ||
|
|
||
| def test_get_sdk_paths_without_genvmroot(self): | ||
| """Test that _get_sdk_paths returns None when GENVMROOT is not set.""" | ||
| with patch.dict(os.environ, {}, clear=True): | ||
| # Remove GENVMROOT if present | ||
| os.environ.pop("GENVMROOT", None) | ||
| result = _get_sdk_paths() | ||
| assert result is None | ||
|
|
||
| def test_get_sdk_paths_with_invalid_genvmroot(self, tmp_path): | ||
| """Test that _get_sdk_paths returns None when paths don't exist.""" | ||
| with patch.dict(os.environ, {"GENVMROOT": str(tmp_path)}): | ||
| result = _get_sdk_paths() | ||
| assert result is None | ||
|
|
||
| def test_extract_schema_returns_none_without_sdk(self): | ||
| """Test that extraction returns None when SDK is not available.""" | ||
| with patch.dict(os.environ, {}, clear=True): | ||
| os.environ.pop("GENVMROOT", None) | ||
| result = extract_schema_via_sdk(b"some contract code") | ||
| assert result is None | ||
|
|
||
|
|
||
| class TestSdkSchemaIntegration: | ||
| """Integration tests that require the SDK to be available.""" | ||
|
|
||
| @pytest.fixture | ||
| def has_sdk(self): | ||
| """Check if SDK is available for testing.""" | ||
| paths = _get_sdk_paths() | ||
| if paths is None: | ||
| pytest.skip("SDK not available (GENVMROOT not set or paths don't exist)") | ||
| return paths | ||
|
|
||
| def test_extract_simple_contract(self, has_sdk): | ||
| """Test extraction of a simple contract (requires SDK).""" | ||
| simple_contract = b"""from genlayer import * | ||
|
|
||
| class SimpleContract(Contract): | ||
| def __init__(self): | ||
| pass | ||
|
|
||
| @public | ||
| def hello(self) -> str: | ||
| return "Hello" | ||
| """ | ||
| result = extract_schema_via_sdk(simple_contract) | ||
|
|
||
| # Should return a schema or None (if SDK import fails) | ||
| # We can't assert the exact schema without the full SDK setup | ||
| if result is not None: | ||
| assert "ctor" in result | ||
| assert "methods" in result |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix NameError in schema decode path (blocker).
Line 825 references
contract_account, which is undefined in this scope. This raises immediately and prevents both SDK extraction and GenVM fallback.🐛 Proposed fix
🧰 Tools
🪛 Ruff (0.15.6)
[error] 825-825: Undefined name
contract_account(F821)
🤖 Prompt for AI Agents