Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/iconsdk-publish-test-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11"]
python-version: ["3.9", "3.10", "3.11"]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
Expand Down Expand Up @@ -44,7 +44,7 @@ jobs:
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: "3.8"
python-version: "3.9"
cache: pip
- name: Install dependency
run: |
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/iconsdk-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11"]
python-version: ["3.9", "3.10", "3.11"]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
Expand Down Expand Up @@ -39,7 +39,7 @@ jobs:
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: "3.8"
python-version: "3.9"
cache: pip
- name: Install dependency
run: |
Expand Down
381 changes: 381 additions & 0 deletions iconsdk/async_service.py

Large diffs are not rendered by default.

63 changes: 59 additions & 4 deletions iconsdk/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# limitations under the License.

from enum import IntEnum, unique
from typing import Optional
from typing import Optional, Any


@unique
Expand All @@ -35,7 +35,7 @@ def __str__(self) -> str:

class IconServiceBaseException(BaseException):

def __init__(self, message: Optional[str], code: IconServiceExceptionCode = IconServiceExceptionCode.OK):
def __init__(self, message: Optional[str],code: IconServiceExceptionCode = IconServiceExceptionCode.OK):
if message is None:
message = str(code)
self.__message = message
Expand Down Expand Up @@ -83,10 +83,48 @@ def __init__(self, message: Optional[str]):

class JSONRPCException(IconServiceBaseException):
"""Error when get JSON-RPC Error Response."""

def __init__(self, message: Optional[str]):
def __init__(self,
message: Optional[str],
code: Optional[int] = None,
data: Any = None,
):
super().__init__(message, IconServiceExceptionCode.JSON_RPC_ERROR)
self.__code = code
self.__data = data

JSON_PARSE_ERROR = -32700
RPC_INVALID_REQUEST = -32600
RPC_METHOD_NOT_FOUND = -32601
RPC_INVALID_PARAMS = -32602
RPC_INTERNAL_ERROR = -32603

SYSTEM_ERROR = -31000
SYSTEM_POOL_OVERFLOW = -31001
SYSTEM_TX_PENDING = -31002
SYSTEM_TX_EXECUTING = -31003
SYSTEM_TX_NOT_FOUND = -31004
SYSTEM_LACK_OF_RESOURCE = -31005
SYSTEM_REQUEST_TIMEOUT = -31006
SYSTEM_HARD_TIMEOUT = -31007

@property
def rpc_code(self) -> Optional[int]:
return self.__code

@property
def rpc_data(self) -> Any:
return self.__data

def __repr__(self):
return f"JSONRPCException(message={self.message!r},code={self.rpc_code},data={self.rpc_data})"

@staticmethod
def score_error(code):
if code is None:
return 0
if -30000 > code > -31000:
return -30000 - code
return 0

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

def __init__(self, message: Optional[str]):
super().__init__(message, IconServiceExceptionCode.URL_ERROR)

class HTTPError(IconServiceBaseException):
""""Error regarding HTTP Error"""
def __init__(self, message: str, status: int):
super().__init__(message, IconServiceExceptionCode.JSON_RPC_ERROR)
self.__status = status

@property
def status(self):
return self.__status

@property
def ok(self):
return 0 <= self.__status < 300

def __repr__(self):
return f'HTTPError(message={self.message!r}, status={self.status!r})'
2 changes: 1 addition & 1 deletion iconsdk/icon_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ def get_transaction(self, tx_hash: str, full_response: bool = False) -> dict:

return result

def call(self, call: object, full_response: bool = False) -> Union[dict, str]:
def call(self, call: Call, full_response: bool = False) -> Union[dict, str]:
"""
Calls SCORE's external function which is read-only without creating a transaction.
Delegates to icx_call RPC method.
Expand Down
175 changes: 175 additions & 0 deletions iconsdk/providers/aiohttp_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# -*- coding: utf-8 -*-
# Copyright 2024 ICON Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import asyncio
import json
from json import JSONDecodeError
from time import monotonic
from typing import Any, Dict, Optional

import aiohttp
from ..exception import JSONRPCException, HTTPError

from .async_provider import AsyncMonitor, AsyncProvider

from .provider import (MonitorSpec,
MonitorTimeoutException)
from .url_map import URLMap


class AIOHTTPProvider(AsyncProvider):
"""
Async Provider implementation using the aiohttp library for HTTP requests.
Connects to a standard ICON JSON-RPC endpoint.
"""

def __init__(self, full_path_url: str,
request_kwargs: Optional[Dict[str, Any]] = None,
):
"""
Initializes AIOHTTPProvider.

:param full_path_url: The URL of the ICON node's JSON-RPC endpoint (e.g., "https://ctz.solidwallet.io/api/v3/icon_dex").
It should include channel name if you want to use socket.
:param session: An optional existing aiohttp ClientSession. If None, a new session is created.
Using an external session is recommended for better resource management.
:param request_kwargs: Optional dictionary of keyword arguments to pass to aiohttp session requests
(e.g., {'timeout': 10}).
"""
self._url = URLMap(full_path_url)
self._request_kwargs = request_kwargs or {}
if 'headers' not in self._request_kwargs:
self._request_kwargs['headers'] = {'Content-Type': 'application/json'}
self._request_id = 0 # Simple counter for JSON-RPC request IDs


async def make_request(self, method: str, params: Optional[Dict[str, Any]] = None, full_response: bool = False) -> Any:
"""
Makes an asynchronous JSON-RPC request to the ICON node.

:param method: The JSON-RPC method name (e.g., 'icx_getLastBlock').
:param params: A dictionary of parameters for the JSON-RPC method.
:param full_response: If True, returns the entire JSON-RPC response object.
If False (default), returns only the 'result' field.
:return: The JSON-RPC response 'result' or the full response dictionary.
:raise aiohttp.ClientError: If there's an issue with the HTTP request/response.
:raise JsonRpcError: If the JSON-RPC response contains an error object.
:raise ValueError: If the response is not valid JSON or missing expected fields.
"""
self._request_id += 1

payload: dict = {
"jsonrpc": "2.0",
"method": method,
"id": self._request_id,
}
if params is not None:
payload["params"] = params

request_url = self._url.for_rpc(method.split('_')[0])
try:
async with aiohttp.ClientSession() as session:
response = await session.post(request_url, json=payload, **self._request_kwargs)
# Raise exception for non-2xx HTTP status codes
resp_json = await response.json()
if full_response:
return resp_json

if response.ok:
return resp_json['result']
raise JSONRPCException(
resp_json['error']['message'],
resp_json['error']['code'],
resp_json['error'].get("data", None),
)
except JSONDecodeError:
raw_response = await response.text()
raise HTTPError(raw_response, response.status)

async def make_monitor(self, spec: MonitorSpec, keep_alive: Optional[float] = None) -> AsyncMonitor:
"""
Creates a monitor for receiving real-time events via WebSocket (Not Implemented).

:param spec: Monitoring specification defining the events to subscribe to.
:param keep_alive: Keep-alive message interval in seconds.
:return: A Monitor object for reading events.
:raise NotImplementedError: This provider does not currently support monitoring.
"""
ws_url = self._url.for_ws(spec.get_path())
params = spec.get_request()
monitor = AIOWebSocketMonitor(aiohttp.ClientSession(), ws_url, params, keep_alive=keep_alive)
await monitor._connect()
return monitor

class AIOWebSocketMonitor(AsyncMonitor):
def __init__(self, session: aiohttp.ClientSession, url: str, params: dict, keep_alive: Optional[float] = None):
self.__session = session
self.__url = url
self.__params = params
self.__keep_alive = keep_alive or 30
self.__ws = None

async def __aenter__(self):
if self.__ws is None:
raise Exception("WebSocket is not connected")
return self

async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.close()
return self

async def _connect(self):
if self.__ws is not None:
raise Exception("WebSocket is already connected")
self.__ws = await self.__session.ws_connect(self.__url)
await self.__ws.send_json(self.__params)
result = await self.__read_json(None)
if 'code' not in result:
raise Exception(f'invalid response={json.dumps(result)}')
if result['code'] != 0:
raise Exception(f'fail to monitor err={result["message"]}')

async def close(self):
if self.__ws:
ws = self.__ws
self.__ws = None
await ws.close()

async def __read_json(self, timeout: Optional[float] = None) -> any:
now = monotonic()
limit = None
if timeout is not None:
limit = now + timeout

while True:
try:
if limit is not None:
timeout_left = min(limit - now, self.__keep_alive)
else:
timeout_left = self.__keep_alive
msg = await self.__ws.receive_json(timeout=timeout_left)
return msg
except asyncio.TimeoutError as e:
now = monotonic()
if limit is None or now < limit:
await self.__ws.send_json({"keepalive": "0x1"})
continue
else:
raise MonitorTimeoutException()
except Exception as e:
raise e

async def read(self, timeout: Optional[float] = None) -> any:
return await self.__read_json(timeout=timeout)
50 changes: 50 additions & 0 deletions iconsdk/providers/async_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from abc import ABCMeta, abstractmethod

from .provider import MonitorSpec
from typing import Any, Dict, Optional


class AsyncMonitor(metaclass=ABCMeta):
@abstractmethod
async def read(self, timeout: Optional[float] = None) -> any:
"""
Read the notification

:param timeout: Timeout to wait for the message in fraction of seconds
:except MonitorTimeoutException: if it passes the timeout
"""
pass

@abstractmethod
async def close(self):
"""
Close the monitor

It releases related resources.
"""
pass

@abstractmethod
async def __aenter__(self):
pass

@abstractmethod
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass


class AsyncProvider(metaclass=ABCMeta):
"""The provider defines how the IconService connects to RPC server."""

@abstractmethod
async def make_request(self, method: str, params: Optional[Dict[str, Any]] = None, full_response: bool = False):
raise NotImplementedError("Providers must implement this method")

@abstractmethod
async def make_monitor(self, spec: MonitorSpec, keep_alive: Optional[float] = None) -> AsyncMonitor:
"""
Make monitor for the spec
:param spec: Monitoring spec
:param keep_alive: Keep-alive message interval in fraction of seconds
"""
raise NotImplementedError()
Loading