diff --git a/.github/workflows/iconsdk-publish-test-pypi.yml b/.github/workflows/iconsdk-publish-test-pypi.yml index a521493..85c9d00 100644 --- a/.github/workflows/iconsdk-publish-test-pypi.yml +++ b/.github/workflows/iconsdk-publish-test-pypi.yml @@ -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 }} @@ -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: | diff --git a/.github/workflows/iconsdk-workflow.yml b/.github/workflows/iconsdk-workflow.yml index 1ae01f7..30a80dc 100644 --- a/.github/workflows/iconsdk-workflow.yml +++ b/.github/workflows/iconsdk-workflow.yml @@ -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 }} @@ -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: | diff --git a/iconsdk/async_service.py b/iconsdk/async_service.py new file mode 100644 index 0000000..f0efc2b --- /dev/null +++ b/iconsdk/async_service.py @@ -0,0 +1,381 @@ +from typing import Any, Dict, List, Optional, Union + +# Import necessary components from iconsdk +from iconsdk.builder.call_builder import Call +from iconsdk.builder.transaction_builder import Transaction +from iconsdk.exception import AddressException, DataTypeException +from iconsdk.icon_service import IconService +from iconsdk.providers.async_provider import AsyncMonitor, AsyncProvider +from iconsdk.providers.provider import MonitorSpec +from iconsdk.signed_transaction import SignedTransaction +from iconsdk.utils import get_timestamp +from iconsdk.utils.convert_type import convert_int_to_hex_str +from iconsdk.utils.validation import (is_block_height, is_hex_block_hash, + is_predefined_block_value, + is_score_address, is_T_HASH, + is_wallet_address) + + +class AsyncIconService: + """ + Async version of IconService using aiohttp for network requests. + + Handles asynchronous communication with an ICON node via JSON-RPC. + """ + DEFAULT_BLOCK_VERSION = IconService.DEFAULT_BLOCK_VERSION # Use IconService's default + + def __init__(self, provider: AsyncProvider): + self.__provider = provider + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() + + async def close(self): + await self.__provider.close() + + # --- Async API Methods --- + + async def call(self, call_obj: Call) -> Any: + """Async version of IconService.call""" + # Validation based on IconService.call + if not isinstance(call_obj, Call): + raise DataTypeException("Call object is unrecognized.") + if not is_score_address(call_obj.to): + raise AddressException(f"SCORE address is invalid: {call_obj.to}") + if call_obj.from_ is not None and not is_wallet_address(call_obj.from_): + raise AddressException(f"'from' address is invalid: {call_obj.from_}") + + req_params = { + "to": call_obj.to, + "dataType": "call", + "data": { + "method": call_obj.method, + "params": call_obj.params or {} # Use empty dict if params is None + } + } + if call_obj.from_ is not None: + req_params["from"] = call_obj.from_ + if call_obj.height is not None: + # IconService uses the height directly, assuming it's already hex or handled by provider + # Here, we'll convert to hex for consistency with other methods + req_params["height"] = hex(call_obj.height) if isinstance(call_obj.height, int) else call_obj.height + + result = await self.__provider.make_request('icx_call', req_params) + # IconService doesn't convert the result for call, so we return raw + return result # Return raw result, conversion handled by caller if needed + + async def get_balance(self, address: str, height: Optional[int] = None) -> int: + """Async version of IconService.get_balance. Returns balance as integer.""" + # Validation from IconService.get_balance + if not (is_score_address(address) or is_wallet_address(address)): + raise AddressException(f"Address is invalid: {address}") + + # The result is typically hex string, convert it if needed by the caller + # or adjust the return type hint if conversion happens here. + # For now, returning the raw result from the node. + req_params = {"address": address} + if height is not None: + req_params["height"] = hex(height) # Add height param if provided + result = await self.__provider.make_request('icx_getBalance', req_params) + # Mimic IconService conversion + try: + return int(result, 0) + except (ValueError, TypeError): + # Handle cases where result might not be a valid hex string + raise DataTypeException(f"Failed to convert balance to integer: {result}") + + async def get_block(self, value: Union[str, int], block_version: str = DEFAULT_BLOCK_VERSION) -> Any: + """Async version of IconService.get_block. Returns raw block data.""" + # Validation and logic adapted from IconService.get_block + params: Optional[Dict[str, str]] = None + prev_method: str = "" + + if is_block_height(value): + params = {'height': hex(value)} # IconService uses hex here + prev_method = 'icx_getBlockByHeight' + elif is_hex_block_hash(value): + params = {'hash': value} + prev_method = 'icx_getBlockByHash' + elif is_predefined_block_value(value): # Checks for "latest" + params = None # No params for latest + prev_method = 'icx_getLastBlock' + else: + raise DataTypeException(f"Invalid block reference: {value!r}. Use integer height, 'latest', or 0x-prefixed hash.") + + # Determine actual method based on block_version (mimicking IconService logic) + method: str + if block_version == self.DEFAULT_BLOCK_VERSION: + method = prev_method + else: + # For newer block versions, IconService uses 'icx_getBlock' + # and includes the identifier (height/hash) within the params. + method = 'icx_getBlock' + # If getting 'latest', params remain None for icx_getBlock + # If getting by height/hash, the existing params dict is correct for icx_getBlock + + # Make the request + result = await self.__provider.make_request(method, params) # Pass params dict directly + + # IconService does conversion if full_response is False. We skip async conversion for now. + # Caller can handle conversion if needed. + return result + + + async def get_score_api(self, address: str, height: Optional[int] = None) -> Any: + """Async version of IconService.get_score_api. Returns raw API list.""" + # Validation from IconService.get_score_api + if not is_score_address(address): + raise AddressException(f"SCORE Address is invalid: {address}") + + req_params = {"address": address} + if height is not None: + req_params["height"] = hex(height) # Add height param if provided + result = await self.__provider.make_request('icx_getScoreApi', req_params) + # IconService returns raw result here + return result + + async def get_transaction(self, tx_hash: str) -> Any: + """Async version of IconService.get_transaction. Returns raw transaction data.""" + # Validation from IconService.get_transaction + if not is_T_HASH(tx_hash): + raise DataTypeException(f"Transaction hash is invalid: {tx_hash}") + + # IconService.get_transaction uses 'icx_getTransactionByHash' + result = await self.__provider.make_request('icx_getTransactionByHash', {"txHash": tx_hash}) + # IconService converts if full_response is False, skip async conversion + return result + + async def get_transaction_result(self, tx_hash: str) -> Any: + """Async version of IconService.get_transaction_result. Returns raw transaction result.""" + # Validation from IconService.get_transaction_result + if not is_T_HASH(tx_hash): + raise DataTypeException(f"Transaction hash is invalid: {tx_hash}") + + result = await self.__provider.make_request('icx_getTransactionResult', {"txHash": tx_hash}) + # IconService converts if full_response is False, skip async conversion + return result + + # Add missing async version of get_score_status + async def get_score_status(self, address: str, height: Optional[int] = None) -> Any: + """Async version of IconService.get_score_status. Returns raw SCORE status.""" + # Validation from IconService.get_score_status + if not is_score_address(address): + raise AddressException(f"SCORE Address is invalid: {address}") + + req_params = {"address": address} + if height is not None: + req_params["height"] = hex(height) # Add height param if provided + result = await self.__provider.make_request('icx_getScoreStatus', req_params) + # IconService returns raw result here + return result + + # Add missing async version of wait_transaction_result + async def wait_transaction_result(self, tx_hash: str) -> Any: + """Async version of IconService.wait_transaction_result. Returns raw transaction result.""" + # Validation from IconService.wait_transaction_result + if not is_T_HASH(tx_hash): + raise DataTypeException(f"Transaction hash is invalid: {tx_hash}") + + result = await self.__provider.make_request('icx_waitTransactionResult', {"txHash": tx_hash}) + # IconService converts if full_response is False, skip async conversion + return result + + async def send_transaction(self, signed_transaction: SignedTransaction) -> Any: + """Async version of IconService.send_transaction. Returns transaction hash.""" + if not isinstance(signed_transaction, SignedTransaction): + raise DataTypeException("SignedTransaction object is unrecognized.") + + params = signed_transaction.signed_transaction_dict + result = await self.__provider.make_request('icx_sendTransaction', params) + # IconService returns raw tx_hash here + return result + + # Add missing async version of send_transaction_and_wait + async def send_transaction_and_wait(self, signed_transaction: SignedTransaction) -> Any: + """Async version of IconService.send_transaction_and_wait. Returns raw transaction result.""" + if not isinstance(signed_transaction, SignedTransaction): + raise DataTypeException("SignedTransaction object is unrecognized.") + + params = signed_transaction.signed_transaction_dict + result = await self.__provider.make_request('icx_sendTransactionAndWait', params) + # IconService converts if full_response is False, skip async conversion + return result + + async def get_total_supply(self, height: Optional[int] = None) -> int: + """Async version of IconService.get_total_supply. Returns total supply as integer.""" + req_params = {} + if height is not None: + req_params["height"] = hex(height) # IconService uses hex here + # Pass params dict directly, or None if empty + result = await self.__provider.make_request('icx_getTotalSupply', req_params if req_params else None) + # Mimic IconService conversion + try: + return int(result, 16) + except (ValueError, TypeError): + raise DataTypeException(f"Failed to convert total supply to integer: {result}") + + # Add missing async version of estimate_step + async def estimate_step(self, transaction: Transaction) -> int: + """Async version of IconService.estimate_step. Returns step estimate as integer.""" + if not isinstance(transaction, Transaction): + raise DataTypeException("Transaction object is unrecognized.") + + # Build params similar to IconService.estimate_step + params = { + "version": convert_int_to_hex_str(transaction.version) if transaction.version else "0x3", + "from": transaction.from_, + "to": transaction.to, + "timestamp": convert_int_to_hex_str(transaction.timestamp) if transaction.timestamp else get_timestamp(), + "nid": convert_int_to_hex_str(transaction.nid) if transaction.nid else "0x1" + } + + if transaction.value is not None: + params["value"] = convert_int_to_hex_str(transaction.value) + if transaction.nonce is not None: + params["nonce"] = convert_int_to_hex_str(transaction.nonce) + if transaction.data_type is not None: + params["dataType"] = transaction.data_type + if transaction.data is not None: + params["data"] = transaction.data + + result = await self.__provider.make_request('debug_estimateStep', params) + # Mimic IconService conversion + try: + return int(result, 16) + except (ValueError, TypeError): + raise DataTypeException(f"Failed to convert step estimate to integer: {result}") + + + # Add missing async version of get_trace + async def get_trace(self, tx_hash: str) -> Any: + """Async version of IconService.get_trace. Returns raw trace data.""" + # Validation from IconService.get_trace + if not is_T_HASH(tx_hash): + raise DataTypeException(f"Transaction hash is invalid: {tx_hash}") + + result = await self.__provider.make_request('debug_getTrace', {"txHash": tx_hash}) + # IconService returns raw result here + return result + + # Add missing BTP/Data methods from IconService + async def get_data_by_hash(self, _hash: str) -> Any: + # IconService has no specific validation for _hash here + """Async version of IconService.get_data_by_hash""" + return await self.__provider.make_request('icx_getDataByHash', {'hash': _hash}) + + async def get_block_header_by_height(self, height: int) -> Any: + """Async version of IconService.get_block_header_by_height""" + # Basic type check (IconService doesn't explicitly validate height here) + if not isinstance(height, int) or height < 0: + raise DataTypeException(f"Block height must be a non-negative integer: {height}") + return await self.__provider.make_request('icx_getBlockHeaderByHeight', {'height': hex(height)}) + + async def get_votes_by_height(self, height: int) -> Any: + """Async version of IconService.get_votes_by_height""" + # Basic type check (IconService doesn't explicitly validate height here) + if not isinstance(height, int) or height < 0: + raise DataTypeException(f"Block height must be a non-negative integer: {height}") + return await self.__provider.make_request('icx_getVotesByHeight', {'height': hex(height)}) + + async def get_proof_for_result(self, _hash: str, index: int) -> Any: + """Async version of IconService.get_proof_for_result""" + # Basic validation (IconService doesn't explicitly validate here) + if not is_T_HASH(_hash): # Assuming block hash format + raise DataTypeException(f"Hash is invalid: {_hash}") + if not isinstance(index, int) or index < 0: + raise DataTypeException(f"Index must be a non-negative integer: {index}") + + params = {'hash': _hash, 'index': hex(index)} + return await self.__provider.make_request('icx_getProofForResult', params) + + async def get_proof_for_events(self, _hash: str, index: int, events: List[str] = []) -> Any: + """Async version of IconService.get_proof_for_events""" + # Basic validation (IconService doesn't explicitly validate here) + if not is_T_HASH(_hash): # Assuming block hash format + raise DataTypeException(f"Hash is invalid: {_hash}") + if not isinstance(index, int) or index < 0: + raise DataTypeException(f"Index must be a non-negative integer: {index}") + # IconService expects list of hex strings for events, but accepts empty list. + # No strict validation on event format here, assuming caller provides correct format. + if not isinstance(events, list): # Basic type check + raise DataTypeException(f"Events must be a list: {events}") + + # Pass events directly as IconService expects List[str] (likely hex strings) + params = {'hash': _hash, 'index': hex(index), 'events': events} + return await self.__provider.make_request('icx_getProofForEvents', params) + + async def get_btp_network_info(self, id: int, height: Optional[int] = None) -> Any: + """Async version of IconService.get_btp_network_info""" + # Basic type check (IconService doesn't explicitly validate here) + if not isinstance(id, int): + raise DataTypeException(f"BTP Network ID must be an integer: {id}") + if height is not None and (not isinstance(height, int) or height < 0): + raise DataTypeException(f"Block height must be a non-negative integer: {height}") + + params = {'id': hex(id)} + if height is not None: + params['height'] = hex(height) + return await self.__provider.make_request('btp_getNetworkInfo', params) + + async def get_btp_network_type_info(self, id: int, height: Optional[int] = None) -> Any: + """Async version of IconService.get_btp_network_type_info""" + # Basic type check (IconService doesn't explicitly validate here) + if not isinstance(id, int): + raise DataTypeException(f"BTP Network Type ID must be an integer: {id}") + if height is not None and (not isinstance(height, int) or height < 0): + raise DataTypeException(f"Block height must be a non-negative integer: {height}") + + params = {'id': hex(id)} + if height is not None: + params['height'] = hex(height) + return await self.__provider.make_request('btp_getNetworkTypeInfo', params) + + async def get_btp_messages(self, height: int, network_id: int) -> Any: + """Async version of IconService.get_btp_messages""" + # Basic type check (IconService doesn't explicitly validate here) + if not isinstance(height, int) or height < 0: + raise DataTypeException(f"Block height must be a non-negative integer: {height}") + if not isinstance(network_id, int): + raise DataTypeException(f"BTP Network ID must be an integer: {network_id}") + + params = {'height': hex(height), 'networkID': hex(network_id)} + return await self.__provider.make_request('btp_getMessages', params) + + async def get_btp_header(self, height: int, network_id: int) -> Any: + """Async version of IconService.get_btp_header""" + # Basic type check (IconService doesn't explicitly validate here) + if not isinstance(height, int) or height < 0: + raise DataTypeException(f"Block height must be a non-negative integer: {height}") + if not isinstance(network_id, int): + raise DataTypeException(f"BTP Network ID must be an integer: {network_id}") + + params = {'height': hex(height), 'networkID': hex(network_id)} + return await self.__provider.make_request('btp_getHeader', params) + + async def get_btp_proof(self, height: int, network_id: int) -> Any: + """Async version of IconService.get_btp_proof""" + # Basic type check (IconService doesn't explicitly validate here) + if not isinstance(height, int) or height < 0: + raise DataTypeException(f"Block height must be a non-negative integer: {height}") + if not isinstance(network_id, int): + raise DataTypeException(f"BTP Network ID must be an integer: {network_id}") + + params = {'height': hex(height), 'networkID': hex(network_id)} + return await self.__provider.make_request('btp_getProof', params) + + async def get_btp_source_information(self) -> Any: + """Async version of IconService.get_btp_source_information""" + return await self.__provider.make_request('btp_getSourceInformation') # No params + + async def get_network_info(self) -> Any: + """Async version of IconService.get_network_info""" + return await self.__provider.make_request('icx_getNetworkInfo') + + + async def monitor(self, spec: MonitorSpec, keep_alive: Optional[float] = None) -> AsyncMonitor: + return await self.__provider.make_monitor(spec, keep_alive) + +__all__ = ['AsyncIconService'] \ No newline at end of file diff --git a/iconsdk/exception.py b/iconsdk/exception.py index 3fe81f5..cc8f2a0 100644 --- a/iconsdk/exception.py +++ b/iconsdk/exception.py @@ -14,7 +14,7 @@ # limitations under the License. from enum import IntEnum, unique -from typing import Optional +from typing import Optional, Any @unique @@ -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 @@ -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""" @@ -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})' diff --git a/iconsdk/icon_service.py b/iconsdk/icon_service.py index dc37d3d..5df8abb 100644 --- a/iconsdk/icon_service.py +++ b/iconsdk/icon_service.py @@ -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. diff --git a/iconsdk/providers/aiohttp_provider.py b/iconsdk/providers/aiohttp_provider.py new file mode 100644 index 0000000..98ccece --- /dev/null +++ b/iconsdk/providers/aiohttp_provider.py @@ -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) diff --git a/iconsdk/providers/async_provider.py b/iconsdk/providers/async_provider.py new file mode 100644 index 0000000..70eb815 --- /dev/null +++ b/iconsdk/providers/async_provider.py @@ -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() \ No newline at end of file diff --git a/iconsdk/providers/http_provider.py b/iconsdk/providers/http_provider.py index 472d63d..b589c86 100644 --- a/iconsdk/providers/http_provider.py +++ b/iconsdk/providers/http_provider.py @@ -13,18 +13,17 @@ # limitations under the License. import json -import re from json.decoder import JSONDecodeError from time import time, monotonic from typing import Union, Optional -from urllib.parse import urlparse, urlunparse import requests from multimethod import multimethod from websocket import WebSocket, WebSocketTimeoutException -from iconsdk.exception import JSONRPCException, URLException +from iconsdk.exception import JSONRPCException, HTTPError from iconsdk.providers.provider import Provider, MonitorSpec, Monitor, MonitorTimeoutException +from iconsdk.providers.url_map import URLMap from iconsdk.utils import to_dict @@ -43,14 +42,8 @@ def __init__(self, base_domain_url: str, version: int, request_kwargs: dict = No :param version: version for RPC server :param request_kwargs: kwargs for setting to head of request """ - uri = urlparse(base_domain_url) - if uri.path != '': - raise URLException('Path is not allowed') - self._serverUri = f'{uri.scheme}://{uri.netloc}' - self._channel = '' - self._version = version + self._url = URLMap(base_domain_url, version, None) self._request_kwargs = request_kwargs or {} - self._generate_url_map() @multimethod def __init__(self, full_path_url: str, request_kwargs: dict = None): @@ -61,55 +54,11 @@ def __init__(self, full_path_url: str, request_kwargs: dict = None): :param full_path_url: full path URL as like ://:/api/v3 :param request_kwargs: kwargs for setting to head of request """ - uri = urlparse(full_path_url) - self._serverUri = f'{uri.scheme}://{uri.netloc}' - self._channel = self._get_channel(uri.path) - self._version = 3 + self._url = URLMap(full_path_url) self._request_kwargs = request_kwargs or {} - self._generate_url_map() - - def _generate_url_map(self): - def _add_channel_path(url: str): - if self._channel: - return f"{url}/{self._channel}" - return url - - self._URL_MAP = { - 'icx': _add_channel_path(f"{self._serverUri}/api/v{self._version}"), - 'btp': _add_channel_path(f"{self._serverUri}/api/v{self._version}"), - 'debug': _add_channel_path(f"{self._serverUri}/api/v{self._version}d"), - } - - def _make_ws_url(url: str, name: str) -> str: - url = urlparse(url) - if url.scheme == 'http': - scheme = 'ws' - elif url.scheme == 'https': - scheme = 'wss' - else: - raise URLException('unknown scheme') - return urlunparse((scheme, url.netloc, f'{url.path}/{name}', '', '', '')) - - if self._channel: - self._WS_MAP = { - 'block': _make_ws_url(self._URL_MAP['icx'], 'block'), - 'event': _make_ws_url(self._URL_MAP['icx'], 'event'), - 'btp': _make_ws_url(self._URL_MAP['btp'], 'btp'), - } - else: - self._WS_MAP = None - - @staticmethod - def _get_channel(path: str): - tokens = re.split("/(?=[^/]+$)", path.rstrip('/')) - if tokens[0] == '/api/v3': - return tokens[1] - elif tokens == ['/api', 'v3']: - return '' - raise URLException('Invalid URI path') def __str__(self): - return "RPC connection to {0}".format(self._serverUri) + return "RPC connection to {0}".format(self._url.serverUri) @to_dict def _get_request_kwargs(self) -> dict: @@ -137,14 +86,13 @@ def make_request(self, method: str, params=None, full_response: bool = False) -> if params: rpc_dict['params'] = params - req_key = method.split('_')[0] - request_url = self._URL_MAP.get(req_key) + request_url = self._url.for_rpc(method.split('_')[0]) response = self._make_post_request(request_url, rpc_dict, **self._get_request_kwargs()) try: return self._return_custom_response(response, full_response) except JSONDecodeError: raw_response = response.content.decode() - raise JSONRPCException(f'Unknown response: {raw_response}') + raise HTTPError(raw_response, response.status_code) @staticmethod def _return_custom_response(response: requests.Response, full_response: bool = False) -> Union[str, list, dict]: @@ -153,16 +101,16 @@ def _return_custom_response(response: requests.Response, full_response: bool = F return content if response.ok: return content['result'] - raise JSONRPCException(content["error"]) + raise JSONRPCException( + content["error"]["message"], + content["error"]["code"], + content['error'].get("data", None), + ) def make_monitor(self, spec: MonitorSpec, keep_alive: Optional[float] = None) -> Monitor: - if self._WS_MAP is None: - raise Exception(f'Channel must be set for socket') - path = spec.get_path() + ws_url = self._url.for_ws(spec.get_path()) params = spec.get_request() - if path not in self._WS_MAP: - raise Exception(f'No available socket for {path}') - return WebSocketMonitor(self._WS_MAP[path], params, keep_alive=keep_alive) + return WebSocketMonitor(ws_url, params, keep_alive=keep_alive) class WebSocketMonitor(Monitor): diff --git a/iconsdk/providers/url_map.py b/iconsdk/providers/url_map.py new file mode 100644 index 0000000..e8af650 --- /dev/null +++ b/iconsdk/providers/url_map.py @@ -0,0 +1,59 @@ +from ..exception import URLException + +import re +from urllib.parse import urlparse, urlunparse + +URL_PATH_FORMAT = r'/api/v(?P\d+)d?(/(?P[^/]+))?' + +class URLMap: + def __init__(self, base_url=None, version=None, channel=None): + # If base_url has full path to specific channel, then we utilize it. + uri = urlparse(base_url) + + if version is None: + mo: re.Match = re.compile(URL_PATH_FORMAT).match(uri.path) + if not mo: + raise URLException(f'Invalid URL: {base_url}') + version, channel = mo.group('version', 'channel') + elif uri.path != '': + raise URLException('Path is not allowed') + + self.serverUri = f'{uri.scheme}://{uri.netloc}' + + def _add_channel_path(url: str): + if channel: + return f"{url}/{channel}" + return url + + self.rpc = { + "icx": _add_channel_path(f"{self.serverUri}/api/v{version}"), + "btp": _add_channel_path(f"{self.serverUri}/api/v{version}"), + "debug": _add_channel_path(f"{self.serverUri}/api/v{version}d"), + } + + def _make_ws_url(url: str, name: str) -> str: + url = urlparse(url) + if url.scheme == 'http': + scheme = 'ws' + elif url.scheme == 'https': + scheme = 'wss' + else: + raise URLException('unknown scheme') + return urlunparse((scheme, url.netloc, f'{url.path}/{name}', '', '', '')) + + if channel: + self.ws = { + 'block': _make_ws_url(self.rpc['icx'], 'block'), + 'event': _make_ws_url(self.rpc['icx'], 'event'), + 'btp': _make_ws_url(self.rpc['btp'], 'btp'), + } + else: + self.ws = None + + def for_rpc(self, name): + return self.rpc[name] + + def for_ws(self, name): + if not self.ws: + raise Exception("Websocket is not available (missing channel)") + return self.ws[name] \ No newline at end of file diff --git a/iconsdk/version.py b/iconsdk/version.py index 667b52f..e977471 100644 --- a/iconsdk/version.py +++ b/iconsdk/version.py @@ -1 +1 @@ -__version__ = "2.5.2" +__version__ = "2.6.0rc1" diff --git a/requirements.txt b/requirements.txt index fe5d203..6fc90d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ multimethod~=1.9.1 requests~=2.32.0 requests-mock~=1.10.0 websocket-client~=1.5.1 +aiohttp~=3.12.2 diff --git a/setup.py b/setup.py index 3dc40a1..169a883 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ packages=find_packages(exclude=['tests*']), install_requires=requires, extras_require=extras_requires, - python_requires='>=3.8', + python_requires='>=3.9', license='Apache License 2.0', classifiers=[ 'Development Status :: 5 - Production/Stable', @@ -34,7 +34,6 @@ 'License :: OSI Approved :: Apache Software License', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11',