Skip to content

Commit 9de7ac1

Browse files
authored
Merge pull request #87 from icon-project/feature-async
Feature async
2 parents 3002f82 + 6a57b2e commit 9de7ac1

File tree

12 files changed

+746
-78
lines changed

12 files changed

+746
-78
lines changed

.github/workflows/iconsdk-publish-test-pypi.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
runs-on: ubuntu-latest
1616
strategy:
1717
matrix:
18-
python-version: ["3.8", "3.9", "3.10", "3.11"]
18+
python-version: ["3.9", "3.10", "3.11"]
1919
steps:
2020
- uses: actions/checkout@v3
2121
- name: Set up Python ${{ matrix.python-version }}
@@ -44,7 +44,7 @@ jobs:
4444
- name: Set up Python ${{ matrix.python-version }}
4545
uses: actions/setup-python@v4
4646
with:
47-
python-version: "3.8"
47+
python-version: "3.9"
4848
cache: pip
4949
- name: Install dependency
5050
run: |

.github/workflows/iconsdk-workflow.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
runs-on: ubuntu-latest
1212
strategy:
1313
matrix:
14-
python-version: ["3.8", "3.9", "3.10", "3.11"]
14+
python-version: ["3.9", "3.10", "3.11"]
1515
steps:
1616
- uses: actions/checkout@v3
1717
- name: Set up Python ${{ matrix.python-version }}
@@ -39,7 +39,7 @@ jobs:
3939
- name: Set up Python ${{ matrix.python-version }}
4040
uses: actions/setup-python@v4
4141
with:
42-
python-version: "3.8"
42+
python-version: "3.9"
4343
cache: pip
4444
- name: Install dependency
4545
run: |

iconsdk/async_service.py

Lines changed: 381 additions & 0 deletions
Large diffs are not rendered by default.

iconsdk/exception.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
# limitations under the License.
1515

1616
from enum import IntEnum, unique
17-
from typing import Optional
17+
from typing import Optional, Any
1818

1919

2020
@unique
@@ -35,7 +35,7 @@ def __str__(self) -> str:
3535

3636
class IconServiceBaseException(BaseException):
3737

38-
def __init__(self, message: Optional[str], code: IconServiceExceptionCode = IconServiceExceptionCode.OK):
38+
def __init__(self, message: Optional[str],code: IconServiceExceptionCode = IconServiceExceptionCode.OK):
3939
if message is None:
4040
message = str(code)
4141
self.__message = message
@@ -83,10 +83,48 @@ def __init__(self, message: Optional[str]):
8383

8484
class JSONRPCException(IconServiceBaseException):
8585
"""Error when get JSON-RPC Error Response."""
86-
87-
def __init__(self, message: Optional[str]):
86+
def __init__(self,
87+
message: Optional[str],
88+
code: Optional[int] = None,
89+
data: Any = None,
90+
):
8891
super().__init__(message, IconServiceExceptionCode.JSON_RPC_ERROR)
92+
self.__code = code
93+
self.__data = data
94+
95+
JSON_PARSE_ERROR = -32700
96+
RPC_INVALID_REQUEST = -32600
97+
RPC_METHOD_NOT_FOUND = -32601
98+
RPC_INVALID_PARAMS = -32602
99+
RPC_INTERNAL_ERROR = -32603
100+
101+
SYSTEM_ERROR = -31000
102+
SYSTEM_POOL_OVERFLOW = -31001
103+
SYSTEM_TX_PENDING = -31002
104+
SYSTEM_TX_EXECUTING = -31003
105+
SYSTEM_TX_NOT_FOUND = -31004
106+
SYSTEM_LACK_OF_RESOURCE = -31005
107+
SYSTEM_REQUEST_TIMEOUT = -31006
108+
SYSTEM_HARD_TIMEOUT = -31007
109+
110+
@property
111+
def rpc_code(self) -> Optional[int]:
112+
return self.__code
113+
114+
@property
115+
def rpc_data(self) -> Any:
116+
return self.__data
89117

118+
def __repr__(self):
119+
return f"JSONRPCException(message={self.message!r},code={self.rpc_code},data={self.rpc_data})"
120+
121+
@staticmethod
122+
def score_error(code):
123+
if code is None:
124+
return 0
125+
if -30000 > code > -31000:
126+
return -30000 - code
127+
return 0
90128

91129
class ZipException(IconServiceBaseException):
92130
""""Error while write zip in memory"""
@@ -100,3 +138,20 @@ class URLException(IconServiceBaseException):
100138

101139
def __init__(self, message: Optional[str]):
102140
super().__init__(message, IconServiceExceptionCode.URL_ERROR)
141+
142+
class HTTPError(IconServiceBaseException):
143+
""""Error regarding HTTP Error"""
144+
def __init__(self, message: str, status: int):
145+
super().__init__(message, IconServiceExceptionCode.JSON_RPC_ERROR)
146+
self.__status = status
147+
148+
@property
149+
def status(self):
150+
return self.__status
151+
152+
@property
153+
def ok(self):
154+
return 0 <= self.__status < 300
155+
156+
def __repr__(self):
157+
return f'HTTPError(message={self.message!r}, status={self.status!r})'

iconsdk/icon_service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ def get_transaction(self, tx_hash: str, full_response: bool = False) -> dict:
257257

258258
return result
259259

260-
def call(self, call: object, full_response: bool = False) -> Union[dict, str]:
260+
def call(self, call: Call, full_response: bool = False) -> Union[dict, str]:
261261
"""
262262
Calls SCORE's external function which is read-only without creating a transaction.
263263
Delegates to icx_call RPC method.
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2024 ICON Foundation
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import asyncio
17+
import json
18+
from json import JSONDecodeError
19+
from time import monotonic
20+
from typing import Any, Dict, Optional
21+
22+
import aiohttp
23+
from ..exception import JSONRPCException, HTTPError
24+
25+
from .async_provider import AsyncMonitor, AsyncProvider
26+
27+
from .provider import (MonitorSpec,
28+
MonitorTimeoutException)
29+
from .url_map import URLMap
30+
31+
32+
class AIOHTTPProvider(AsyncProvider):
33+
"""
34+
Async Provider implementation using the aiohttp library for HTTP requests.
35+
Connects to a standard ICON JSON-RPC endpoint.
36+
"""
37+
38+
def __init__(self, full_path_url: str,
39+
request_kwargs: Optional[Dict[str, Any]] = None,
40+
):
41+
"""
42+
Initializes AIOHTTPProvider.
43+
44+
:param full_path_url: The URL of the ICON node's JSON-RPC endpoint (e.g., "https://ctz.solidwallet.io/api/v3/icon_dex").
45+
It should include channel name if you want to use socket.
46+
:param session: An optional existing aiohttp ClientSession. If None, a new session is created.
47+
Using an external session is recommended for better resource management.
48+
:param request_kwargs: Optional dictionary of keyword arguments to pass to aiohttp session requests
49+
(e.g., {'timeout': 10}).
50+
"""
51+
self._url = URLMap(full_path_url)
52+
self._request_kwargs = request_kwargs or {}
53+
if 'headers' not in self._request_kwargs:
54+
self._request_kwargs['headers'] = {'Content-Type': 'application/json'}
55+
self._request_id = 0 # Simple counter for JSON-RPC request IDs
56+
57+
58+
async def make_request(self, method: str, params: Optional[Dict[str, Any]] = None, full_response: bool = False) -> Any:
59+
"""
60+
Makes an asynchronous JSON-RPC request to the ICON node.
61+
62+
:param method: The JSON-RPC method name (e.g., 'icx_getLastBlock').
63+
:param params: A dictionary of parameters for the JSON-RPC method.
64+
:param full_response: If True, returns the entire JSON-RPC response object.
65+
If False (default), returns only the 'result' field.
66+
:return: The JSON-RPC response 'result' or the full response dictionary.
67+
:raise aiohttp.ClientError: If there's an issue with the HTTP request/response.
68+
:raise JsonRpcError: If the JSON-RPC response contains an error object.
69+
:raise ValueError: If the response is not valid JSON or missing expected fields.
70+
"""
71+
self._request_id += 1
72+
73+
payload: dict = {
74+
"jsonrpc": "2.0",
75+
"method": method,
76+
"id": self._request_id,
77+
}
78+
if params is not None:
79+
payload["params"] = params
80+
81+
request_url = self._url.for_rpc(method.split('_')[0])
82+
try:
83+
async with aiohttp.ClientSession() as session:
84+
response = await session.post(request_url, json=payload, **self._request_kwargs)
85+
# Raise exception for non-2xx HTTP status codes
86+
resp_json = await response.json()
87+
if full_response:
88+
return resp_json
89+
90+
if response.ok:
91+
return resp_json['result']
92+
raise JSONRPCException(
93+
resp_json['error']['message'],
94+
resp_json['error']['code'],
95+
resp_json['error'].get("data", None),
96+
)
97+
except JSONDecodeError:
98+
raw_response = await response.text()
99+
raise HTTPError(raw_response, response.status)
100+
101+
async def make_monitor(self, spec: MonitorSpec, keep_alive: Optional[float] = None) -> AsyncMonitor:
102+
"""
103+
Creates a monitor for receiving real-time events via WebSocket (Not Implemented).
104+
105+
:param spec: Monitoring specification defining the events to subscribe to.
106+
:param keep_alive: Keep-alive message interval in seconds.
107+
:return: A Monitor object for reading events.
108+
:raise NotImplementedError: This provider does not currently support monitoring.
109+
"""
110+
ws_url = self._url.for_ws(spec.get_path())
111+
params = spec.get_request()
112+
monitor = AIOWebSocketMonitor(aiohttp.ClientSession(), ws_url, params, keep_alive=keep_alive)
113+
await monitor._connect()
114+
return monitor
115+
116+
class AIOWebSocketMonitor(AsyncMonitor):
117+
def __init__(self, session: aiohttp.ClientSession, url: str, params: dict, keep_alive: Optional[float] = None):
118+
self.__session = session
119+
self.__url = url
120+
self.__params = params
121+
self.__keep_alive = keep_alive or 30
122+
self.__ws = None
123+
124+
async def __aenter__(self):
125+
if self.__ws is None:
126+
raise Exception("WebSocket is not connected")
127+
return self
128+
129+
async def __aexit__(self, exc_type, exc_val, exc_tb):
130+
await self.close()
131+
return self
132+
133+
async def _connect(self):
134+
if self.__ws is not None:
135+
raise Exception("WebSocket is already connected")
136+
self.__ws = await self.__session.ws_connect(self.__url)
137+
await self.__ws.send_json(self.__params)
138+
result = await self.__read_json(None)
139+
if 'code' not in result:
140+
raise Exception(f'invalid response={json.dumps(result)}')
141+
if result['code'] != 0:
142+
raise Exception(f'fail to monitor err={result["message"]}')
143+
144+
async def close(self):
145+
if self.__ws:
146+
ws = self.__ws
147+
self.__ws = None
148+
await ws.close()
149+
150+
async def __read_json(self, timeout: Optional[float] = None) -> any:
151+
now = monotonic()
152+
limit = None
153+
if timeout is not None:
154+
limit = now + timeout
155+
156+
while True:
157+
try:
158+
if limit is not None:
159+
timeout_left = min(limit - now, self.__keep_alive)
160+
else:
161+
timeout_left = self.__keep_alive
162+
msg = await self.__ws.receive_json(timeout=timeout_left)
163+
return msg
164+
except asyncio.TimeoutError as e:
165+
now = monotonic()
166+
if limit is None or now < limit:
167+
await self.__ws.send_json({"keepalive": "0x1"})
168+
continue
169+
else:
170+
raise MonitorTimeoutException()
171+
except Exception as e:
172+
raise e
173+
174+
async def read(self, timeout: Optional[float] = None) -> any:
175+
return await self.__read_json(timeout=timeout)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from abc import ABCMeta, abstractmethod
2+
3+
from .provider import MonitorSpec
4+
from typing import Any, Dict, Optional
5+
6+
7+
class AsyncMonitor(metaclass=ABCMeta):
8+
@abstractmethod
9+
async def read(self, timeout: Optional[float] = None) -> any:
10+
"""
11+
Read the notification
12+
13+
:param timeout: Timeout to wait for the message in fraction of seconds
14+
:except MonitorTimeoutException: if it passes the timeout
15+
"""
16+
pass
17+
18+
@abstractmethod
19+
async def close(self):
20+
"""
21+
Close the monitor
22+
23+
It releases related resources.
24+
"""
25+
pass
26+
27+
@abstractmethod
28+
async def __aenter__(self):
29+
pass
30+
31+
@abstractmethod
32+
async def __aexit__(self, exc_type, exc_val, exc_tb):
33+
pass
34+
35+
36+
class AsyncProvider(metaclass=ABCMeta):
37+
"""The provider defines how the IconService connects to RPC server."""
38+
39+
@abstractmethod
40+
async def make_request(self, method: str, params: Optional[Dict[str, Any]] = None, full_response: bool = False):
41+
raise NotImplementedError("Providers must implement this method")
42+
43+
@abstractmethod
44+
async def make_monitor(self, spec: MonitorSpec, keep_alive: Optional[float] = None) -> AsyncMonitor:
45+
"""
46+
Make monitor for the spec
47+
:param spec: Monitoring spec
48+
:param keep_alive: Keep-alive message interval in fraction of seconds
49+
"""
50+
raise NotImplementedError()

0 commit comments

Comments
 (0)