Skip to content

Commit 8d6e041

Browse files
author
Matthias Zimmermann
committed
feat: add get_block_timing and to_blocks to (Async)Arkiv
1 parent 2ddb15f commit 8d6e041

File tree

8 files changed

+1729
-1498
lines changed

8 files changed

+1729
-1498
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ The snippet below demonstrates the creation of various nodes to connect to using
126126
```python
127127
from arkiv import Arkiv
128128
from arkiv.account import NamedAccount
129-
from arkiv.provider import ProviderBuilder## Advanced Features
129+
from arkiv.provider import ProviderBuilder
130130

131131
### Provider Builder
132132

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ lint = [
5757
]
5858
dev = [
5959
"ipython>=8.37.0",
60+
"testcontainers>=4.13.1",
61+
"websockets>=13.0",
6062
]
6163

6264
[build-system]

src/arkiv/contract.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,4 +191,12 @@
191191
json_rpc_method=RPCEndpoint("arkiv_query"),
192192
mungers=[default_root_munger],
193193
),
194+
"get_block_timing": Method(
195+
json_rpc_method=RPCEndpoint("arkiv_getBlockTiming"),
196+
mungers=[default_root_munger],
197+
),
198+
"estimate_costs": Method(
199+
json_rpc_method=RPCEndpoint("arkiv_estimateStorageCosts"),
200+
mungers=[default_root_munger],
201+
),
194202
}

src/arkiv/module.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
import logging
6-
from typing import TYPE_CHECKING
6+
from typing import TYPE_CHECKING, Any
77

88
from eth_typing import ChecksumAddress, HexStr
99
from web3 import Web3
@@ -397,6 +397,39 @@ def cleanup_filters(self) -> None:
397397
self._active_filters.clear()
398398
logger.info("All event filters cleaned up")
399399

400+
def get_block_timing(self) -> Any:
401+
block_timing_response = self.client.eth.get_block_timing()
402+
logger.info(f"Block timing response: {block_timing_response}")
403+
404+
return block_timing_response
405+
406+
def to_blocks(
407+
self, seconds: int = 0, minutes: int = 0, hours: int = 0, days: int = 0
408+
) -> int:
409+
"""
410+
Convert a time duration to number of blocks.
411+
412+
Useful for calculating blocks-to-live (BTL) parameters based on
413+
desired entity lifetime.
414+
415+
Args:
416+
seconds: Number of seconds
417+
minutes: Number of minutes
418+
hours: Number of hours
419+
days: Number of days
420+
421+
Returns:
422+
Number of blocks corresponding to the time duration
423+
"""
424+
total_seconds = seconds + minutes * 60 + hours * 3600 + days * 86400
425+
426+
if not hasattr(self, "_block_duration"):
427+
block_timing = self.get_block_timing()
428+
self._block_duration = block_timing["duration"]
429+
430+
block_time = self._block_duration
431+
return total_seconds // block_time if block_time else 0
432+
400433
@property
401434
def active_filters(self) -> list[EventFilter]:
402435
"""Get a copy of currently active event filters."""

src/arkiv/module_async.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
import logging
6-
from typing import TYPE_CHECKING
6+
from typing import TYPE_CHECKING, Any
77

88
from eth_typing import ChecksumAddress, HexStr
99
from web3.types import TxParams, TxReceipt
@@ -360,6 +360,39 @@ async def cleanup_filters(self) -> None:
360360
self._active_filters.clear()
361361
logger.info("All async event filters cleaned up")
362362

363+
async def get_block_timing(self) -> Any:
364+
block_timing_response = await self.client.eth.get_block_timing()
365+
logger.info(f"Block timing response: {block_timing_response}")
366+
367+
return block_timing_response
368+
369+
async def to_blocks(
370+
self, seconds: int = 0, minutes: int = 0, hours: int = 0, days: int = 0
371+
) -> int:
372+
"""
373+
Convert a time duration to number of blocks.
374+
375+
Useful for calculating blocks-to-live (BTL) parameters based on
376+
desired entity lifetime.
377+
378+
Args:
379+
seconds: Number of seconds
380+
minutes: Number of minutes
381+
hours: Number of hours
382+
days: Number of days
383+
384+
Returns:
385+
Number of blocks corresponding to the time duration
386+
"""
387+
total_seconds = seconds + minutes * 60 + hours * 3600 + days * 86400
388+
389+
if not hasattr(self, "_block_duration"):
390+
block_timing = await self.get_block_timing()
391+
self._block_duration = block_timing["duration"]
392+
393+
block_time = self._block_duration
394+
return total_seconds // block_time if block_time else 0
395+
363396
@property
364397
def active_filters(self) -> list[AsyncEventFilter]:
365398
"""Get a copy of currently active async event filters."""

src/arkiv/utils.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -344,9 +344,12 @@ def to_entity(fields: int, response_item: dict[str, Any]) -> Entity:
344344

345345
# Extract created_at if present
346346
if fields & CREATED_AT != 0:
347-
if not hasattr(response_item, "createdAtBlock"):
348-
raise ValueError("RPC query response item missing 'createdAtBlock' field")
349-
created_at_block = int(response_item.createdAtBlock)
347+
if hasattr(response_item, "createdAtBlock"):
348+
created_at_block = int(response_item.createdAtBlock)
349+
else:
350+
# TODO revert to raise pattern once available
351+
# raise ValueError("RPC query response item missing 'createdAtBlock' field")
352+
logger.info("RPC query response item missing 'createdAtBlock' field")
350353

351354
# Extract last_modified_at if present
352355
if fields & LAST_MODIFIED_AT != 0:

tests/test_module_utils.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""Tests for ArkivModule utility methods."""
2+
3+
import logging
4+
from unittest.mock import MagicMock
5+
6+
import pytest
7+
8+
from arkiv.module import ArkivModule
9+
from arkiv.module_async import AsyncArkivModule
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
class TestToBlocks:
15+
"""Test cases for to_blocks time conversion method."""
16+
17+
@pytest.fixture
18+
def mock_module(self):
19+
"""Create a mock ArkivModule for testing."""
20+
module = MagicMock(spec=ArkivModule)
21+
# Mock the get_block_timing response
22+
module.get_block_timing.return_value = {"duration": 2}
23+
# Bind the actual to_blocks method to our mock
24+
module.to_blocks = ArkivModule.to_blocks.__get__(module)
25+
return module
26+
27+
def test_to_blocks_from_seconds(self, mock_module):
28+
"""Test conversion from seconds to blocks."""
29+
# With 2 second block time, 120 seconds = 60 blocks
30+
result = mock_module.to_blocks(seconds=120)
31+
assert result == 60
32+
mock_module.get_block_timing.assert_called_once()
33+
34+
def test_to_blocks_from_minutes(self, mock_module):
35+
"""Test conversion from minutes to blocks."""
36+
# 2 minutes = 120 seconds = 60 blocks
37+
result = mock_module.to_blocks(minutes=2)
38+
assert result == 60
39+
40+
def test_to_blocks_from_hours(self, mock_module):
41+
"""Test conversion from hours to blocks."""
42+
# 1 hour = 3600 seconds = 1800 blocks
43+
result = mock_module.to_blocks(hours=1)
44+
assert result == 1800
45+
46+
def test_to_blocks_from_days(self, mock_module):
47+
"""Test conversion from days to blocks."""
48+
# 1 day = 86400 seconds = 43200 blocks
49+
result = mock_module.to_blocks(days=1)
50+
assert result == 43200
51+
52+
def test_to_blocks_mixed_units(self, mock_module):
53+
"""Test conversion with mixed time units."""
54+
# 1 day + 2 hours + 30 minutes + 60 seconds
55+
# = 86400 + 7200 + 1800 + 60 = 95460 seconds = 47730 blocks
56+
result = mock_module.to_blocks(days=1, hours=2, minutes=30, seconds=60)
57+
assert result == 47730
58+
59+
def test_to_blocks_caches_block_duration(self, mock_module):
60+
"""Test that block duration is cached after first call."""
61+
mock_module.to_blocks(seconds=2)
62+
mock_module.to_blocks(seconds=4)
63+
# Should only call get_block_timing once
64+
assert mock_module.get_block_timing.call_count == 1
65+
66+
def test_to_blocks_default_values(self, mock_module):
67+
"""Test with all default values (should return 0)."""
68+
result = mock_module.to_blocks()
69+
assert result == 0
70+
71+
def test_to_blocks_rounding_down(self, mock_module):
72+
"""Test that partial blocks are rounded down."""
73+
# 125 seconds with 2 second blocks = 62.5 blocks, should round to 62
74+
result = mock_module.to_blocks(seconds=125)
75+
assert result == 62
76+
77+
78+
class TestToBlocksAsync:
79+
"""Test cases for async to_blocks time conversion method."""
80+
81+
@pytest.fixture
82+
async def mock_module_async(self):
83+
"""Create a mock AsyncArkivModule for testing."""
84+
module = MagicMock(spec=AsyncArkivModule)
85+
# Mock the async get_block_timing response
86+
async def mock_get_block_timing():
87+
return {"duration": 2}
88+
89+
module.get_block_timing = mock_get_block_timing
90+
# Bind the actual to_blocks method to our mock
91+
module.to_blocks = AsyncArkivModule.to_blocks.__get__(module)
92+
return module
93+
94+
@pytest.mark.asyncio
95+
async def test_async_to_blocks_from_seconds(self, mock_module_async):
96+
"""Test async conversion from seconds to blocks."""
97+
# With 2 second block time, 120 seconds = 60 blocks
98+
result = await mock_module_async.to_blocks(seconds=120)
99+
assert result == 60
100+
101+
@pytest.mark.asyncio
102+
async def test_async_to_blocks_from_minutes(self, mock_module_async):
103+
"""Test async conversion from minutes to blocks."""
104+
# 2 minutes = 120 seconds = 60 blocks
105+
result = await mock_module_async.to_blocks(minutes=2)
106+
assert result == 60
107+
108+
@pytest.mark.asyncio
109+
async def test_async_to_blocks_from_hours(self, mock_module_async):
110+
"""Test async conversion from hours to blocks."""
111+
# 1 hour = 3600 seconds = 1800 blocks
112+
result = await mock_module_async.to_blocks(hours=1)
113+
assert result == 1800
114+
115+
@pytest.mark.asyncio
116+
async def test_async_to_blocks_from_days(self, mock_module_async):
117+
"""Test async conversion from days to blocks."""
118+
# 1 day = 86400 seconds = 43200 blocks
119+
result = await mock_module_async.to_blocks(days=1)
120+
assert result == 43200
121+
122+
@pytest.mark.asyncio
123+
async def test_async_to_blocks_mixed_units(self, mock_module_async):
124+
"""Test async conversion with mixed time units."""
125+
# 1 day + 2 hours + 30 minutes + 60 seconds
126+
# = 86400 + 7200 + 1800 + 60 = 95460 seconds = 47730 blocks
127+
result = await mock_module_async.to_blocks(
128+
days=1, hours=2, minutes=30, seconds=60
129+
)
130+
assert result == 47730
131+
132+
@pytest.mark.asyncio
133+
async def test_async_to_blocks_default_values(self, mock_module_async):
134+
"""Test async with all default values (should return 0)."""
135+
result = await mock_module_async.to_blocks()
136+
assert result == 0
137+
138+
@pytest.mark.asyncio
139+
async def test_async_to_blocks_rounding_down(self, mock_module_async):
140+
"""Test that partial blocks are rounded down in async version."""
141+
# 125 seconds with 2 second blocks = 62.5 blocks, should round to 62
142+
result = await mock_module_async.to_blocks(seconds=125)
143+
assert result == 62

0 commit comments

Comments
 (0)