From 97d78d4a462db008287e7a5c4977d6d8c37fa937 Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Thu, 5 Jun 2025 20:59:08 +0200 Subject: [PATCH 01/14] Fixes potential issues with starting a new loop --- async_substrate_interface/async_substrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/async_substrate_interface/async_substrate.py b/async_substrate_interface/async_substrate.py index 3a6c225..d88ae22 100644 --- a/async_substrate_interface/async_substrate.py +++ b/async_substrate_interface/async_substrate.py @@ -539,7 +539,7 @@ def __init__( "You are instantiating the AsyncSubstrateInterface Websocket outside of an event loop. " "Verify this is intended." ) - now = asyncio.new_event_loop().time() + now = 0.0 self.last_received = now self.last_sent = now From cf243958bf5820c8890a41fe54d5fe4ae8917938 Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Thu, 5 Jun 2025 21:00:09 +0200 Subject: [PATCH 02/14] Websockets potential not started when started (issue #95). --- async_substrate_interface/async_substrate.py | 30 ++++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/async_substrate_interface/async_substrate.py b/async_substrate_interface/async_substrate.py index d88ae22..17c3e2d 100644 --- a/async_substrate_interface/async_substrate.py +++ b/async_substrate_interface/async_substrate.py @@ -544,9 +544,8 @@ def __init__( self.last_sent = now async def __aenter__(self): - async with self._lock: - self._in_use += 1 - await self.connect() + self._in_use += 1 + await self.connect() return self @staticmethod @@ -559,18 +558,19 @@ async def connect(self, force=False): self.last_sent = now if self._exit_task: self._exit_task.cancel() - if not self._initialized or force: - self._initialized = True - try: - self._receiving_task.cancel() - await self._receiving_task - await self.ws.close() - except (AttributeError, asyncio.CancelledError): - pass - self.ws = await asyncio.wait_for( - connect(self.ws_url, **self._options), timeout=10 - ) - self._receiving_task = asyncio.create_task(self._start_receiving()) + async with self._lock: + if not self._initialized or force: + try: + self._receiving_task.cancel() + await self._receiving_task + await self.ws.close() + except (AttributeError, asyncio.CancelledError): + pass + self.ws = await asyncio.wait_for( + connect(self.ws_url, **self._options), timeout=10 + ) + self._receiving_task = asyncio.create_task(self._start_receiving()) + self._initialized = True async def __aexit__(self, exc_type, exc_val, exc_tb): async with self._lock: # TODO is this actually what I want to happen? From 7420723daec00248b7c23336c8fe8538fed199a7 Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Thu, 5 Jun 2025 21:00:23 +0200 Subject: [PATCH 03/14] Fixes potential cache issues. --- async_substrate_interface/async_substrate.py | 21 ++++---- async_substrate_interface/utils/cache.py | 56 ++++++++++++++++++++ 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/async_substrate_interface/async_substrate.py b/async_substrate_interface/async_substrate.py index 17c3e2d..7cbfbde 100644 --- a/async_substrate_interface/async_substrate.py +++ b/async_substrate_interface/async_substrate.py @@ -22,7 +22,6 @@ TYPE_CHECKING, ) -import asyncstdlib as a from bt_decode import MetadataV15, PortableRegistry, decode as decode_by_type_string from scalecodec.base import ScaleBytes, ScaleType, RuntimeConfigurationObject from scalecodec.types import ( @@ -58,7 +57,7 @@ get_next_id, rng as random, ) -from async_substrate_interface.utils.cache import async_sql_lru_cache +from async_substrate_interface.utils.cache import async_sql_lru_cache, CachedFetcher from async_substrate_interface.utils.decoding import ( _determine_if_old_runtime_call, _bt_decode_to_dict_or_list, @@ -748,6 +747,12 @@ def __init__( self.registry_type_map = {} self.type_id_to_name = {} self._mock = _mock + self._block_hash_fetcher = CachedFetcher(512, self._get_block_hash) + self._parent_hash_fetcher = CachedFetcher(512, self._get_parent_block_hash) + self._runtime_info_fetcher = CachedFetcher(16, self._get_block_runtime_info) + self._runtime_version_for_fetcher = CachedFetcher( + 512, self._get_block_runtime_version_for + ) async def __aenter__(self): if not self._mock: @@ -1869,9 +1874,8 @@ async def get_metadata(self, block_hash=None) -> MetadataV15: return runtime.metadata_v15 - @a.lru_cache(maxsize=512) async def get_parent_block_hash(self, block_hash): - return await self._get_parent_block_hash(block_hash) + return await self._parent_hash_fetcher.execute(block_hash) async def _get_parent_block_hash(self, block_hash): block_header = await self.rpc_request("chain_getHeader", [block_hash]) @@ -1916,9 +1920,8 @@ async def get_storage_by_key(self, block_hash: str, storage_key: str) -> Any: "Unknown error occurred during retrieval of events" ) - @a.lru_cache(maxsize=16) async def get_block_runtime_info(self, block_hash: str) -> dict: - return await self._get_block_runtime_info(block_hash) + return await self._runtime_info_fetcher.execute(block_hash) get_block_runtime_version = get_block_runtime_info @@ -1929,9 +1932,8 @@ async def _get_block_runtime_info(self, block_hash: str) -> dict: response = await self.rpc_request("state_getRuntimeVersion", [block_hash]) return response.get("result") - @a.lru_cache(maxsize=512) async def get_block_runtime_version_for(self, block_hash: str): - return await self._get_block_runtime_version_for(block_hash) + return await self._runtime_version_for_fetcher.execute(block_hash) async def _get_block_runtime_version_for(self, block_hash: str): """ @@ -2240,9 +2242,8 @@ async def rpc_request( else: raise SubstrateRequestException(result[payload_id][0]) - @a.lru_cache(maxsize=512) async def get_block_hash(self, block_id: int) -> str: - return await self._get_block_hash(block_id) + return await self._block_hash_fetcher.execute(block_id) async def _get_block_hash(self, block_id: int) -> str: return (await self.rpc_request("chain_getBlockHash", [block_id]))["result"] diff --git a/async_substrate_interface/utils/cache.py b/async_substrate_interface/utils/cache.py index 9d16411..fa4be3c 100644 --- a/async_substrate_interface/utils/cache.py +++ b/async_substrate_interface/utils/cache.py @@ -1,10 +1,15 @@ +import asyncio +from collections import OrderedDict import functools import os import pickle import sqlite3 from pathlib import Path +from typing import Callable, Any + import asyncstdlib as a + USE_CACHE = True if os.getenv("NO_CACHE") != "1" else False CACHE_LOCATION = ( os.path.expanduser( @@ -139,3 +144,54 @@ async def inner(self, *args, **kwargs): return inner return decorator + + +class LRUCache: + def __init__(self, max_size: int): + self.max_size = max_size + self.cache = OrderedDict() + + def set(self, key, value): + if key in self.cache: + self.cache.move_to_end(key) + self.cache[key] = value + if len(self.cache) > self.max_size: + self.cache.popitem(last=False) + + def get(self, key): + if key in self.cache: + # Mark as recently used + self.cache.move_to_end(key) + return self.cache[key] + return None + + +class CachedFetcher: + def __init__(self, max_size: int, method: Callable): + self._inflight: dict[int, asyncio.Future] = {} + self._method = method + self._cache = LRUCache(max_size=max_size) + + async def execute(self, single_arg: Any) -> str: + if item := self._cache.get(single_arg): + return item + + if single_arg in self._inflight: + result = await self._inflight[single_arg] + return result + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._inflight[single_arg] = future + + try: + result = await self._method(single_arg) + self._cache.set(single_arg, result) + future.set_result(result) + return result + except Exception as e: + # Propagate errors + future.set_exception(e) + raise + finally: + self._inflight.pop(single_arg, None) From ce855dac261e9ba38d6964586f80f10af058ccd8 Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Thu, 5 Jun 2025 22:16:03 +0200 Subject: [PATCH 04/14] Adjust sleep wait --- async_substrate_interface/async_substrate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/async_substrate_interface/async_substrate.py b/async_substrate_interface/async_substrate.py index 7cbfbde..b2ec11f 100644 --- a/async_substrate_interface/async_substrate.py +++ b/async_substrate_interface/async_substrate.py @@ -673,7 +673,7 @@ async def retrieve(self, item_id: int) -> Optional[dict]: self.max_subscriptions.release() return item except KeyError: - await asyncio.sleep(0.001) + await asyncio.sleep(0.1) return None @@ -2151,14 +2151,14 @@ async def _make_rpc_request( and current_time - self.ws.last_sent >= self.retry_timeout ): if attempt >= self.max_retries: - logger.warning( + logger.error( f"Timed out waiting for RPC requests {attempt} times. Exiting." ) raise MaxRetriesExceeded("Max retries reached.") else: self.ws.last_received = time.time() await self.ws.connect(force=True) - logger.error( + logger.warning( f"Timed out waiting for RPC requests. " f"Retrying attempt {attempt + 1} of {self.max_retries}" ) From b1f91577702f0e1d9c6c80b47c01490ebb7c7d96 Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Thu, 5 Jun 2025 22:16:32 +0200 Subject: [PATCH 05/14] Ensure ids cannot be reused while still in use. --- async_substrate_interface/async_substrate.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/async_substrate_interface/async_substrate.py b/async_substrate_interface/async_substrate.py index b2ec11f..61f05bb 100644 --- a/async_substrate_interface/async_substrate.py +++ b/async_substrate_interface/async_substrate.py @@ -541,6 +541,7 @@ def __init__( now = 0.0 self.last_received = now self.last_sent = now + self._in_use_ids = set() async def __aenter__(self): self._in_use += 1 @@ -648,6 +649,9 @@ async def send(self, payload: dict) -> int: id: the internal ID of the request (incremented int) """ original_id = get_next_id() + while original_id in self._in_use_ids: + original_id = get_next_id() + self._in_use_ids.add(original_id) # self._open_subscriptions += 1 await self.max_subscriptions.acquire() try: @@ -670,6 +674,7 @@ async def retrieve(self, item_id: int) -> Optional[dict]: """ try: item = self._received.pop(item_id) + self._in_use_ids.remove(item_id) self.max_subscriptions.release() return item except KeyError: From 5b97c8871c91b29fbb274ffdaafa62c3c41ea8db Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Thu, 5 Jun 2025 22:57:45 +0200 Subject: [PATCH 06/14] Change where `_in_use_ids` gets updated. --- async_substrate_interface/async_substrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/async_substrate_interface/async_substrate.py b/async_substrate_interface/async_substrate.py index 61f05bb..acdbade 100644 --- a/async_substrate_interface/async_substrate.py +++ b/async_substrate_interface/async_substrate.py @@ -619,6 +619,7 @@ async def _recv(self) -> None: self._open_subscriptions -= 1 if "id" in response: self._received[response["id"]] = response + self._in_use_ids.remove(response["id"]) elif "params" in response: self._received[response["params"]["subscription"]] = response else: @@ -674,7 +675,6 @@ async def retrieve(self, item_id: int) -> Optional[dict]: """ try: item = self._received.pop(item_id) - self._in_use_ids.remove(item_id) self.max_subscriptions.release() return item except KeyError: From 95585267f059563dab817a8b06c98dbf1b5f49a9 Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Mon, 9 Jun 2025 19:45:25 +0200 Subject: [PATCH 07/14] Added magic number comment. --- async_substrate_interface/async_substrate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/async_substrate_interface/async_substrate.py b/async_substrate_interface/async_substrate.py index acdbade..0059c47 100644 --- a/async_substrate_interface/async_substrate.py +++ b/async_substrate_interface/async_substrate.py @@ -538,6 +538,9 @@ def __init__( "You are instantiating the AsyncSubstrateInterface Websocket outside of an event loop. " "Verify this is intended." ) + # default value for in case there's no running asyncio loop + # this really doesn't matter in most cases, as it's only used for comparison on the first call to + # see how long it's been since the last call now = 0.0 self.last_received = now self.last_sent = now From fab28fc1f1d064f99709e0fdcd6fef1fd6c91bce Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Mon, 9 Jun 2025 20:06:02 +0200 Subject: [PATCH 08/14] Added unit tests for cache --- tests/unit_tests/test_cache.py | 83 ++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/unit_tests/test_cache.py diff --git a/tests/unit_tests/test_cache.py b/tests/unit_tests/test_cache.py new file mode 100644 index 0000000..86709b4 --- /dev/null +++ b/tests/unit_tests/test_cache.py @@ -0,0 +1,83 @@ +import asyncio +import pytest +from unittest import mock + +from async_substrate_interface.utils.cache import CachedFetcher + + +@pytest.mark.asyncio +async def test_cached_fetcher_fetches_and_caches(): + """Tests that CachedFetcher correctly fetches and caches results.""" + # Setup + mock_method = mock.AsyncMock(side_effect=lambda x: f"result_{x}") + fetcher = CachedFetcher(max_size=2, method=mock_method) + + # First call should trigger the method + result1 = await fetcher.execute("key1") + assert result1 == "result_key1" + mock_method.assert_awaited_once_with("key1") + + # Second call with the same key should use the cache + result2 = await fetcher.execute("key1") + assert result2 == "result_key1" + # Ensure the method was NOT called again + assert mock_method.await_count == 1 + + # Third call with a new key triggers a method call + result3 = await fetcher.execute("key2") + assert result3 == "result_key2" + assert mock_method.await_count == 2 + +@pytest.mark.asyncio +async def test_cached_fetcher_handles_inflight_requests(): + """Tests that CachedFetcher waits for in-flight results instead of re-fetching.""" + # Create an event to control when the mock returns + event = asyncio.Event() + + async def slow_method(x): + await event.wait() + return f"slow_result_{x}" + + fetcher = CachedFetcher(max_size=2, method=slow_method) + + # Start first request + task1 = asyncio.create_task(fetcher.execute("key1")) + await asyncio.sleep(0.1) # Let the task start and be inflight + + # Second request for the same key while the first is in-flight + task2 = asyncio.create_task(fetcher.execute("key1")) + await asyncio.sleep(0.1) + + # Release the inflight request + event.set() + result1, result2 = await asyncio.gather(task1, task2) + assert result1 == result2 == "slow_result_key1" + +@pytest.mark.asyncio +async def test_cached_fetcher_propagates_errors(): + """Tests that CachedFetcher correctly propagates errors.""" + async def error_method(x): + raise ValueError("Boom!") + + fetcher = CachedFetcher(max_size=2, method=error_method) + + with pytest.raises(ValueError, match="Boom!"): + await fetcher.execute("key1") + +@pytest.mark.asyncio +async def test_cached_fetcher_eviction(): + """Tests that LRU eviction works in CachedFetcher.""" + mock_method = mock.AsyncMock(side_effect=lambda x: f"val_{x}") + fetcher = CachedFetcher(max_size=2, method=mock_method) + + # Fill cache + await fetcher.execute("key1") + await fetcher.execute("key2") + assert list(fetcher._cache.cache.keys()) == list(fetcher._cache.cache.keys()) + + # Insert a new key to trigger eviction + await fetcher.execute("key3") + # key1 should be evicted + assert "key1" not in fetcher._cache.cache + assert "key2" in fetcher._cache.cache + assert "key3" in fetcher._cache.cache \ No newline at end of file From a52f356b9e6f7084010239c71ff6f72db4a6edd0 Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Mon, 9 Jun 2025 20:06:29 +0200 Subject: [PATCH 09/14] Renamed `tests/unittests/asyncio` to `asyncio_` to avoid name conflicts. --- async_substrate_interface/async_substrate.py | 1 + .../{asyncio => asyncio_}/test_substrate_interface.py | 6 +----- 2 files changed, 2 insertions(+), 5 deletions(-) rename tests/unit_tests/{asyncio => asyncio_}/test_substrate_interface.py (91%) diff --git a/async_substrate_interface/async_substrate.py b/async_substrate_interface/async_substrate.py index 0059c47..e546d96 100644 --- a/async_substrate_interface/async_substrate.py +++ b/async_substrate_interface/async_substrate.py @@ -732,6 +732,7 @@ def __init__( ) else: self.ws = AsyncMock(spec=Websocket) + self._lock = asyncio.Lock() self.config = { "use_remote_preset": use_remote_preset, diff --git a/tests/unit_tests/asyncio/test_substrate_interface.py b/tests/unit_tests/asyncio_/test_substrate_interface.py similarity index 91% rename from tests/unit_tests/asyncio/test_substrate_interface.py rename to tests/unit_tests/asyncio_/test_substrate_interface.py index b1ee98b..7dabbda 100644 --- a/tests/unit_tests/asyncio/test_substrate_interface.py +++ b/tests/unit_tests/asyncio_/test_substrate_interface.py @@ -23,11 +23,7 @@ async def test_invalid_url_raises_exception(): @pytest.mark.asyncio async def test_runtime_call(monkeypatch): - monkeypatch.setattr( - "async_substrate_interface.async_substrate.Websocket", unittest.mock.Mock() - ) - - substrate = AsyncSubstrateInterface("ws://localhost") + substrate = AsyncSubstrateInterface("ws://localhost", _mock=True) substrate._metadata = unittest.mock.Mock() substrate.metadata_v15 = unittest.mock.Mock( **{ From 831c1c0b6404c3a05f8d82e574265f67cf76d5bb Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Mon, 9 Jun 2025 20:23:50 +0200 Subject: [PATCH 10/14] Fix unit tests + ruff --- .../asyncio_/test_substrate_interface.py | 78 +++++++++++++------ tests/unit_tests/test_cache.py | 6 +- 2 files changed, 58 insertions(+), 26 deletions(-) diff --git a/tests/unit_tests/asyncio_/test_substrate_interface.py b/tests/unit_tests/asyncio_/test_substrate_interface.py index 7dabbda..ea76595 100644 --- a/tests/unit_tests/asyncio_/test_substrate_interface.py +++ b/tests/unit_tests/asyncio_/test_substrate_interface.py @@ -1,4 +1,4 @@ -import unittest.mock +from unittest.mock import AsyncMock, MagicMock import pytest from websockets.exceptions import InvalidURI @@ -24,42 +24,70 @@ async def test_invalid_url_raises_exception(): @pytest.mark.asyncio async def test_runtime_call(monkeypatch): substrate = AsyncSubstrateInterface("ws://localhost", _mock=True) - substrate._metadata = unittest.mock.Mock() - substrate.metadata_v15 = unittest.mock.Mock( - **{ - "value.return_value": { - "apis": [ + + fake_runtime = MagicMock() + fake_metadata_v15 = MagicMock() + fake_metadata_v15.value.return_value = { + "apis": [ + { + "name": "SubstrateApi", + "methods": [ { - "name": "SubstrateApi", - "methods": [ - { - "name": "SubstrateMethod", - "inputs": [], - "output": "1", - }, - ], + "name": "SubstrateMethod", + "inputs": [], + "output": "1", }, ], }, - } - ) - substrate.rpc_request = unittest.mock.AsyncMock( - return_value={ - "result": "0x00", + ], + "types": { + "types": [ + { + "id": "1", + "type": { + "path": ["Vec"], + "def": {"sequence": {"type": "4"}}, + }, + }, + ] }, + } + fake_runtime.metadata_v15 = fake_metadata_v15 + substrate.init_runtime = AsyncMock(return_value=fake_runtime) + + # Patch encode_scale (should not be called in this test since no inputs) + substrate.encode_scale = AsyncMock() + + # Patch decode_scale to produce a dummy value + substrate.decode_scale = AsyncMock(return_value="decoded_result") + + # Patch RPC request with correct behavior + substrate.rpc_request = AsyncMock( + side_effect=lambda method, params: { + "result": "0x00" if method == "state_call" else {"parentHash": "0xDEADBEEF"} + } ) - substrate.decode_scale = unittest.mock.AsyncMock() + # Patch get_block_runtime_info + substrate.get_block_runtime_info = AsyncMock(return_value={"specVersion": "1"}) + + # Run the call result = await substrate.runtime_call( "SubstrateApi", "SubstrateMethod", ) + # Validate the result is wrapped in ScaleObj assert isinstance(result, ScaleObj) - assert result.value is substrate.decode_scale.return_value + assert result.value == "decoded_result" - substrate.rpc_request.assert_called_once_with( - "state_call", - ["SubstrateApi_SubstrateMethod", "", None], - ) + # Check decode_scale called correctly substrate.decode_scale.assert_called_once_with("scale_info::1", b"\x00") + + # encode_scale should not be called since no inputs + substrate.encode_scale.assert_not_called() + + # Check RPC request called for the state_call + substrate.rpc_request.assert_any_call( + "state_call", ["SubstrateApi_SubstrateMethod", "", None] + ) diff --git a/tests/unit_tests/test_cache.py b/tests/unit_tests/test_cache.py index 86709b4..7844202 100644 --- a/tests/unit_tests/test_cache.py +++ b/tests/unit_tests/test_cache.py @@ -28,6 +28,7 @@ async def test_cached_fetcher_fetches_and_caches(): assert result3 == "result_key2" assert mock_method.await_count == 2 + @pytest.mark.asyncio async def test_cached_fetcher_handles_inflight_requests(): """Tests that CachedFetcher waits for in-flight results instead of re-fetching.""" @@ -53,9 +54,11 @@ async def slow_method(x): result1, result2 = await asyncio.gather(task1, task2) assert result1 == result2 == "slow_result_key1" + @pytest.mark.asyncio async def test_cached_fetcher_propagates_errors(): """Tests that CachedFetcher correctly propagates errors.""" + async def error_method(x): raise ValueError("Boom!") @@ -64,6 +67,7 @@ async def error_method(x): with pytest.raises(ValueError, match="Boom!"): await fetcher.execute("key1") + @pytest.mark.asyncio async def test_cached_fetcher_eviction(): """Tests that LRU eviction works in CachedFetcher.""" @@ -80,4 +84,4 @@ async def test_cached_fetcher_eviction(): # key1 should be evicted assert "key1" not in fetcher._cache.cache assert "key2" in fetcher._cache.cache - assert "key3" in fetcher._cache.cache \ No newline at end of file + assert "key3" in fetcher._cache.cache From f9776bc78170490fbdf24e7bed99203d7ad030af Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Mon, 9 Jun 2025 21:14:36 +0200 Subject: [PATCH 11/14] Ensure runtime_call is the same in both async and sync versions. Fix sync test --- async_substrate_interface/sync_substrate.py | 4 +- .../sync/test_substrate_interface.py | 86 ++++++++++++------- 2 files changed, 55 insertions(+), 35 deletions(-) diff --git a/async_substrate_interface/sync_substrate.py b/async_substrate_interface/sync_substrate.py index dc8d178..f51c3b6 100644 --- a/async_substrate_interface/sync_substrate.py +++ b/async_substrate_interface/sync_substrate.py @@ -2497,13 +2497,13 @@ def runtime_call( Returns: ScaleType from the runtime call """ - self.init_runtime(block_hash=block_hash) + runtime = self.init_runtime(block_hash=block_hash) if params is None: params = {} try: - metadata_v15_value = self.runtime.metadata_v15.value() + metadata_v15_value = runtime.metadata_v15.value() apis = {entry["name"]: entry for entry in metadata_v15_value["apis"]} api_entry = apis[api] diff --git a/tests/unit_tests/sync/test_substrate_interface.py b/tests/unit_tests/sync/test_substrate_interface.py index 18e85ea..6d9c471 100644 --- a/tests/unit_tests/sync/test_substrate_interface.py +++ b/tests/unit_tests/sync/test_substrate_interface.py @@ -1,54 +1,74 @@ -import unittest.mock +from unittest.mock import MagicMock from async_substrate_interface.sync_substrate import SubstrateInterface from async_substrate_interface.types import ScaleObj def test_runtime_call(monkeypatch): - monkeypatch.setattr( - "async_substrate_interface.sync_substrate.connect", unittest.mock.MagicMock() - ) - - substrate = SubstrateInterface( - "ws://localhost", - _mock=True, - ) - substrate._metadata = unittest.mock.Mock() - substrate.metadata_v15 = unittest.mock.Mock( - **{ - "value.return_value": { - "apis": [ + substrate = SubstrateInterface("ws://localhost", _mock=True) + fake_runtime = MagicMock() + fake_metadata_v15 = MagicMock() + fake_metadata_v15.value.return_value = { + "apis": [ + { + "name": "SubstrateApi", + "methods": [ { - "name": "SubstrateApi", - "methods": [ - { - "name": "SubstrateMethod", - "inputs": [], - "output": "1", - }, - ], + "name": "SubstrateMethod", + "inputs": [], + "output": "1", }, ], }, - } - ) - substrate.rpc_request = unittest.mock.Mock( - return_value={ - "result": "0x00", + ], + "types": { + "types": [ + { + "id": "1", + "type": { + "path": ["Vec"], + "def": {"sequence": {"type": "4"}}, + }, + }, + ] }, + } + fake_runtime.metadata_v15 = fake_metadata_v15 + substrate.init_runtime = MagicMock(return_value=fake_runtime) + + # Patch encode_scale (should not be called in this test since no inputs) + substrate.encode_scale = MagicMock() + + # Patch decode_scale to produce a dummy value + substrate.decode_scale = MagicMock(return_value="decoded_result") + + # Patch RPC request with correct behavior + substrate.rpc_request = MagicMock( + side_effect=lambda method, params: { + "result": "0x00" if method == "state_call" else {"parentHash": "0xDEADBEEF"} + } ) - substrate.decode_scale = unittest.mock.Mock() + # Patch get_block_runtime_info + substrate.get_block_runtime_info = MagicMock(return_value={"specVersion": "1"}) + + # Run the call result = substrate.runtime_call( "SubstrateApi", "SubstrateMethod", ) + # Validate the result is wrapped in ScaleObj assert isinstance(result, ScaleObj) - assert result.value is substrate.decode_scale.return_value + assert result.value == "decoded_result" - substrate.rpc_request.assert_called_once_with( - "state_call", - ["SubstrateApi_SubstrateMethod", "", None], - ) + # Check decode_scale called correctly substrate.decode_scale.assert_called_once_with("scale_info::1", b"\x00") + + # encode_scale should not be called since no inputs + substrate.encode_scale.assert_not_called() + + # Check RPC request called for the state_call + substrate.rpc_request.assert_any_call( + "state_call", ["SubstrateApi_SubstrateMethod", "", None] + ) From f62e0eb966ff99967ed870126f9ce38cc98e6e19 Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Mon, 9 Jun 2025 21:19:47 +0200 Subject: [PATCH 12/14] Add test workflow --- .../run-async-substrate-interface-tests.yml | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/run-async-substrate-interface-tests.yml diff --git a/.github/workflows/run-async-substrate-interface-tests.yml b/.github/workflows/run-async-substrate-interface-tests.yml new file mode 100644 index 0000000..01e4ce8 --- /dev/null +++ b/.github/workflows/run-async-substrate-interface-tests.yml @@ -0,0 +1,34 @@ +name: Run Unit Tests + +on: + push: + branches: + - main + - staging + pull_request: + branches: + - main + - staging + +jobs: + run-tests: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + python-version: 3.13 + + - name: install dependencies + run: | + uv venv .venv + source .venv/bin/activate + uv pip install .[dev] + + - name: Run pytest + run: | + source .venv/bin/activate + pytest tests From 7d03ece898ee6c78a5394ac6fb625ef9c5030ce7 Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Mon, 9 Jun 2025 21:30:08 +0200 Subject: [PATCH 13/14] Idk --- .../run-async-substrate-interface-tests.yml | 71 +++++++++++++++---- 1 file changed, 59 insertions(+), 12 deletions(-) diff --git a/.github/workflows/run-async-substrate-interface-tests.yml b/.github/workflows/run-async-substrate-interface-tests.yml index 01e4ce8..9890192 100644 --- a/.github/workflows/run-async-substrate-interface-tests.yml +++ b/.github/workflows/run-async-substrate-interface-tests.yml @@ -1,34 +1,81 @@ -name: Run Unit Tests +name: Run Tests on: push: - branches: - - main - - staging + branches: [main, staging] pull_request: - branches: - - main - - staging + branches: [main, staging] + workflow_dispatch: jobs: - run-tests: + find-tests: runs-on: ubuntu-latest steps: - - name: Check out repository + - name: Check-out repository + uses: actions/checkout@v4 + + - name: Find test files + id: get-tests + run: | + test_files=$(find tests -name "test*.py" | jq -R -s -c 'split("\n") | map(select(. != ""))') + echo "::set-output name=test-files::$test_files" + + pull-docker-image: + runs-on: ubuntu-latest + steps: + - name: Log in to GitHub Container Registry + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $GITHUB_ACTOR --password-stdin + + - name: Pull Docker Image + run: docker pull ghcr.io/opentensor/subtensor-localnet:devnet-ready + + - name: Save Docker Image to Cache + run: docker save -o subtensor-localnet.tar ghcr.io/opentensor/subtensor-localnet:devnet-ready + + - name: Upload Docker Image as Artifact + uses: actions/upload-artifact@v4 + with: + name: subtensor-localnet + path: subtensor-localnet.tar + + run-unit-tests: + name: ${{ matrix.test-file }} / Python ${{ matrix.python-version }} + needs: + - find-tests + - pull-docker-image + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + max-parallel: 32 + matrix: + test-file: ${{ fromJson(needs.find-tests.outputs.test-files) }} + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + + steps: + - name: Check-out repository uses: actions/checkout@v4 - name: Install uv uses: astral-sh/setup-uv@v4 with: - python-version: 3.13 + python-version: ${{ matrix.python-version }} - - name: install dependencies + - name: Install dependencies run: | uv venv .venv source .venv/bin/activate uv pip install .[dev] + - name: Download Docker Image + uses: actions/download-artifact@v4 + with: + name: subtensor-localnet + + - name: Load Docker Image + run: docker load -i subtensor-localnet.tar + - name: Run pytest run: | source .venv/bin/activate - pytest tests + uv run pytest ${{ matrix.test-file }} -v -s \ No newline at end of file From a46e1b8354ae9868ffb4e2fe953c170331854309 Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Mon, 9 Jun 2025 21:57:59 +0200 Subject: [PATCH 14/14] Trigger no-op