diff --git a/chainbench/profile/ethereum/subscriptions.py b/chainbench/profile/ethereum/subscriptions.py new file mode 100644 index 0000000..079c1ab --- /dev/null +++ b/chainbench/profile/ethereum/subscriptions.py @@ -0,0 +1,16 @@ +from chainbench.user import WssJrpcUser +from chainbench.user.protocol.ethereum import EthSubscribe + + +class EthSubscriptions(WssJrpcUser): + subscriptions = [ + EthSubscribe(["newHeads"]), + # logs subscription for approve method signature + EthSubscribe(["logs", {"topics": ["0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925"]}]), + EthSubscribe(["newPendingTransactions"]), + ] + + def get_notification_name(self, parsed_response: dict): + return self.get_subscription( + subscription_id=parsed_response["params"]["subscription"] + ).subscribe_rpc_call.params[0] diff --git a/chainbench/profile/solana/get_program_accounts_shark.py b/chainbench/profile/solana/get_program_accounts_shark.py index 62370e6..e44c450 100644 --- a/chainbench/profile/solana/get_program_accounts_shark.py +++ b/chainbench/profile/solana/get_program_accounts_shark.py @@ -1,6 +1,6 @@ from locust import task -from chainbench.user.http import RpcCall +from chainbench.user.jsonrpc import RpcCall from chainbench.user.protocol.solana import SolanaUser diff --git a/chainbench/profile/solana/get_program_accounts_stake.py b/chainbench/profile/solana/get_program_accounts_stake.py index a618084..10cefea 100644 --- a/chainbench/profile/solana/get_program_accounts_stake.py +++ b/chainbench/profile/solana/get_program_accounts_stake.py @@ -1,6 +1,6 @@ from locust import task -from chainbench.user.http import RpcCall +from chainbench.user.jsonrpc import RpcCall from chainbench.user.protocol.solana import SolanaUser diff --git a/chainbench/test_data/blockchain.py b/chainbench/test_data/blockchain.py index cd76266..054735f 100644 --- a/chainbench/test_data/blockchain.py +++ b/chainbench/test_data/blockchain.py @@ -1,10 +1,11 @@ -import json import logging import typing as t from argparse import Namespace from dataclasses import dataclass +import orjson as json from gevent.lock import Semaphore as GeventSemaphore +from orjson.orjson import OPT_SORT_KEYS from tenacity import retry, stop_after_attempt from chainbench.util.http import HttpClient @@ -46,7 +47,7 @@ class Block: block_number: BlockNumber def to_json(self) -> str: - return json.dumps(self.__dict__) + return json.dumps(self.__dict__).decode("utf-8") @dataclass @@ -91,7 +92,7 @@ def __init__(self, size: Size, start: BlockNumber = 0, end: BlockNumber = 0): self.block_numbers: list[BlockNumber] = [] def to_json(self) -> str: - return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True) + return json.dumps(self, default=lambda o: o.__dict__, option=OPT_SORT_KEYS).decode("utf-8") def push_block(self, block: B) -> None: if block.block_number in self.block_numbers: diff --git a/chainbench/test_data/ethereum.py b/chainbench/test_data/ethereum.py index 34bc10d..81df057 100644 --- a/chainbench/test_data/ethereum.py +++ b/chainbench/test_data/ethereum.py @@ -1,9 +1,9 @@ -import json import logging import typing as t from argparse import Namespace from dataclasses import dataclass +import orjson as json from tenacity import retry, stop_after_attempt, wait_fixed from chainbench.test_data.blockchain import ( diff --git a/chainbench/test_data/evm.py b/chainbench/test_data/evm.py index 2948d36..3f443c8 100644 --- a/chainbench/test_data/evm.py +++ b/chainbench/test_data/evm.py @@ -1,9 +1,10 @@ -import json import logging import typing as t from argparse import Namespace from dataclasses import dataclass +import orjson as json + from chainbench.util.rng import RNG, get_rng from .blockchain import ( diff --git a/chainbench/test_data/solana.py b/chainbench/test_data/solana.py index f054247..eb71845 100644 --- a/chainbench/test_data/solana.py +++ b/chainbench/test_data/solana.py @@ -1,9 +1,9 @@ -import json import logging import typing as t from argparse import Namespace from dataclasses import dataclass +import orjson as json from tenacity import retry, stop_after_attempt from chainbench.util.rng import RNG, get_rng diff --git a/chainbench/test_data/starknet.py b/chainbench/test_data/starknet.py index ad91596..1dbfcf7 100644 --- a/chainbench/test_data/starknet.py +++ b/chainbench/test_data/starknet.py @@ -1,7 +1,8 @@ -import json import logging import typing as t +import orjson as json + from .blockchain import ( Account, BlockHash, diff --git a/chainbench/tools/discovery/rpc.py b/chainbench/tools/discovery/rpc.py index b26910e..27d6cf6 100644 --- a/chainbench/tools/discovery/rpc.py +++ b/chainbench/tools/discovery/rpc.py @@ -1,9 +1,9 @@ -import json import os from dataclasses import dataclass from pathlib import Path from typing import Iterator +import orjson as json from tenacity import retry, retry_if_exception_type, wait_exponential from chainbench.util.http import HttpClient, HttpErrorLevel diff --git a/chainbench/user/__init__.py b/chainbench/user/__init__.py index 4e971b5..36208d5 100644 --- a/chainbench/user/__init__.py +++ b/chainbench/user/__init__.py @@ -2,7 +2,9 @@ from chainbench.util.event import setup_event_listeners from .common import get_subclass_tasks -from .http import HttpUser, JsonRpcUser +from .http import HttpUser +from .jsonrpc import JrpcHttpUser +from .wss import WssJrpcUser # importing plugins here as all profiles depend on it import locust_plugins # isort: skip # noqa @@ -13,8 +15,9 @@ "EthBeaconUser", "EvmUser", "HttpUser", - "JsonRpcUser", + "JrpcHttpUser", "SolanaUser", "StarkNetUser", + "WssJrpcUser", "get_subclass_tasks", ] diff --git a/chainbench/user/http.py b/chainbench/user/http.py index 0a308d4..277b786 100644 --- a/chainbench/user/http.py +++ b/chainbench/user/http.py @@ -1,45 +1,11 @@ -import json import logging -import random import typing as t -from locust import FastHttpUser, TaskSet, tag, task +from locust import FastHttpUser, TaskSet from locust.contrib.fasthttp import ResponseContextManager from chainbench.test_data import TestData -from chainbench.util.rng import RNGManager - - -class RpcCall: - def __init__(self, method: str, params: list[t.Any] | dict | None = None) -> None: - self.method = method - self.params = params - - -def expand_rpc_calls(rpc_calls_weighted: dict[t.Callable[[], RpcCall], int]) -> list[RpcCall]: - rpc_call_methods_weighted: dict[RpcCall, int] = {} - for rpc_call_method, weight in rpc_calls_weighted.items(): - rpc_call_methods_weighted[rpc_call_method()] = weight - - expanded_rpc_calls: list[RpcCall] = expand_to_list(rpc_call_methods_weighted) - return expanded_rpc_calls - - -def expand_to_list(items_weighted: dict[t.Any, int] | list[t.Any | tuple[t.Any, int]]) -> list[t.Any]: - expanded_items_list: list[t.Any] = [] - if isinstance(items_weighted, dict): - items_weighted = list(items_weighted.items()) - - if isinstance(items_weighted, list): - for rpc_call in items_weighted: - if isinstance(rpc_call, tuple): - rpc_call, count = rpc_call - for _ in range(count): - expanded_items_list.append(rpc_call) - else: - expanded_items_list.append(rpc_call) - - return expanded_items_list +from chainbench.util.jsonrpc import expand_to_list class HttpUser(FastHttpUser): @@ -48,7 +14,6 @@ class HttpUser(FastHttpUser): abstract = True test_data: TestData = TestData() logger = logging.getLogger(__name__) - rng = RNGManager() connection_timeout = 120 network_timeout = 360 @@ -119,164 +84,3 @@ def get(self, name: str, params: t.Optional[dict] = None, path: str = "") -> Res with self.client.request("GET", path, params=params, name=name, catch_response=True) as response: self.check_http_error(response) return response - - -class JsonRpcUser(HttpUser): - """Extension of HttpUser to provide JsonRPC support.""" - - abstract = True - rpc_path = "" - rpc_error_code_exclusions: list[int] = [] - rpc_calls: dict[t.Callable, int] = {} # To be populated in the subclass load profile - calls_per_batch = 10 # default requests to include in a batch request - - def __init__(self, environment: t.Any): - self.calls_per_batch = environment.parsed_options.batch_size - super().__init__(environment) - - @tag("single") - @task - def rpc_call_task(self) -> None: - self.method_to_task_function(self.environment.parsed_options.method)(self) - - @tag("batch") - @task - def batch_rpc_call_task(self) -> None: - rpc_calls = {getattr(self, method.__name__): weight for method, weight in self.rpc_calls.items()} - self.make_random_batch_rpc_call( - rpc_calls, - calls_per_batch=self.calls_per_batch, - ) - - @tag("batch_single") - @task - def batch_single_rpc_call_task(self) -> None: - rpc_call: RpcCall = self.method_to_rpc_call(self.environment.parsed_options.method)(self) - rpc_calls = [rpc_call for _ in range(self.calls_per_batch)] - self.make_batch_rpc_call( - rpc_calls, - ) - - @classmethod - def method_to_rpc_call(cls, method: str) -> t.Callable: - method_name = cls.method_to_function_name(method) - return getattr(cls, method_name) - - def check_json_rpc_response(self, response: ResponseContextManager, name: str) -> None: - CHUNK_SIZE = 1024 - if response.text is None: - self.logger.error(f"Response for {name} is empty") - response.failure(f"Response for {name} is empty") - return - data = response.text[:CHUNK_SIZE] - if "jsonrpc" not in data: - self.logger.error(f"Response for {name} is not a JSON-RPC: {response.text}") - response.failure(f"Response for {name} is not a JSON-RPC") - return - - if "error" in data: - response_js: list | dict = response.json() - if isinstance(response_js, dict): - response_js = [response_js] - if isinstance(response_js, list): - for response_js_item in response_js: - if "error" in response_js_item: - if "code" in response_js_item["error"]: - self.logger.error(f"Response for {name} has a JSON-RPC error: {response.text}") - if response_js_item["error"]["code"] not in self.rpc_error_code_exclusions: - response.failure( - f"Response for {name} has a JSON-RPC error {response_js_item['error']['code']} - " - f"{response_js_item['error']['message']}" - ) - return - response.failure("Unspecified JSON-RPC error") - self.logger.error(f"Unspecified JSON-RPC error: {response.text}") - return - # TODO: handle multiple errors in batch response properly - - if "result" not in data: - response.failure(f"Response for {name} call has no result") - self.logger.error(f"Response for {name} call has no result: {response.text}") - - def make_rpc_call( - self, - rpc_call: RpcCall | None = None, - method: str | None = None, - params: list[t.Any] | dict | None = None, - name: str = "", - path: str = "", - ) -> None: - """Make a JSON-RPC call.""" - if rpc_call is not None: - method = rpc_call.method - params = rpc_call.params - - if name == "" and method is not None: - name = method - - with self.client.request( - "POST", self.rpc_path + path, json=generate_request_body(method, params), name=name, catch_response=True - ) as response: - self.check_http_error(response) - self.check_json_rpc_response(response, name=name) - - def make_batch_rpc_call(self, rpc_calls: list[RpcCall], name: str = "", path: str = "") -> None: - """Make a Batch JSON-RPC call.""" - - if name == "": - name = f"Batch RPC ({len(rpc_calls)})" - - headers = {"Content-Type": "application/json", "accept": "application/json"} - - with self.client.request( - "POST", - self.rpc_path + path, - data=generate_batch_request_body(rpc_calls), - name=name, - catch_response=True, - headers=headers, - ) as response: - self.check_http_error(response) - self.check_json_rpc_response(response, name=name) - - def make_random_batch_rpc_call( - self, - weighted_rpc_calls: dict[t.Callable[[], RpcCall], int], - calls_per_batch: int, - name: str = "", - path: str = "", - ) -> None: - """Make a Batch JSON-RPC call.""" - rpc_calls: list[RpcCall] = expand_rpc_calls(weighted_rpc_calls) - random_rpc_calls: list[RpcCall] = random.choices(rpc_calls, k=calls_per_batch) - - self.make_batch_rpc_call(random_rpc_calls, name=name, path=path) - - -def generate_request_body( - method: str | None = None, params: list | dict | None = None, request_id: int | None = None, version: str = "2.0" -) -> dict: - """Generate a JSON-RPC request body.""" - - if params is None: - params = [] - - if request_id is None: - request_id = random.randint(1, 100000000) - - return { - "jsonrpc": version, - "method": method, - "params": params, - "id": request_id, - } - - -def generate_batch_request_body(rpc_calls: list[RpcCall], version: str = "2.0") -> str: - """Generate a batch JSON-RPC request body.""" - return json.dumps( - [ - generate_request_body(rpc_calls[i].method, rpc_calls[i].params, request_id=i, version=version) - for i in range(1, len(rpc_calls)) - ] - ) diff --git a/chainbench/user/jsonrpc.py b/chainbench/user/jsonrpc.py new file mode 100644 index 0000000..cc110c0 --- /dev/null +++ b/chainbench/user/jsonrpc.py @@ -0,0 +1,146 @@ +import random +import typing as t + +from locust import tag, task +from locust.contrib.fasthttp import ResponseContextManager + +from chainbench.user.http import HttpUser +from chainbench.util.jsonrpc import ( + RpcCall, + expand_rpc_calls, + generate_batch_request_body, +) + + +class JrpcHttpUser(HttpUser): + """Extension of HttpUser to provide JsonRPC support.""" + + abstract = True + rpc_path = "" + rpc_error_code_exclusions: list[int] = [] + rpc_calls: dict[t.Callable, int] = {} # To be populated in the subclass load profile + calls_per_batch = 10 # default requests to include in a batch request + + def __init__(self, environment: t.Any): + self.calls_per_batch = environment.parsed_options.batch_size + super().__init__(environment) + + @tag("single") + @task + def rpc_call_task(self) -> None: + self.method_to_task_function(self.environment.parsed_options.method)(self) + + @tag("batch") + @task + def batch_rpc_call_task(self) -> None: + rpc_calls = {getattr(self, method.__name__): weight for method, weight in self.rpc_calls.items()} + self.make_random_batch_rpc_call( + rpc_calls, + calls_per_batch=self.calls_per_batch, + ) + + @tag("batch_single") + @task + def batch_single_rpc_call_task(self) -> None: + rpc_call: RpcCall = self.method_to_rpc_call(self.environment.parsed_options.method)(self) + rpc_calls = [rpc_call for _ in range(self.calls_per_batch)] + self.make_batch_rpc_call( + rpc_calls, + ) + + @classmethod + def method_to_rpc_call(cls, method: str) -> t.Callable: + method_name = cls.method_to_function_name(method) + return getattr(cls, method_name) + + def check_json_rpc_response(self, response: ResponseContextManager, name: str) -> None: + CHUNK_SIZE = 1024 + if response.text is None: + self.logger.error(f"Response for {name} is empty") + response.failure(f"Response for {name} is empty") + return + data = response.text[:CHUNK_SIZE] + if "jsonrpc" not in data: + self.logger.error(f"Response for {name} is not a JSON-RPC: {response.text}") + response.failure(f"Response for {name} is not a JSON-RPC") + return + + if "error" in data: + response_js: list | dict = response.json() + if isinstance(response_js, dict): + response_js = [response_js] + if isinstance(response_js, list): + for response_js_item in response_js: + if "error" in response_js_item: + if "code" in response_js_item["error"]: + self.logger.error(f"Response for {name} has a JSON-RPC error: {response.text}") + if response_js_item["error"]["code"] not in self.rpc_error_code_exclusions: + response.failure( + f"Response for {name} has a JSON-RPC error {response_js_item['error']['code']} - " + f"{response_js_item['error']['message']}" + ) + return + response.failure("Unspecified JSON-RPC error") + self.logger.error(f"Unspecified JSON-RPC error: {response.text}") + return + # TODO: handle multiple errors in batch response properly + + if "result" not in data: + response.failure(f"Response for {name} call has no result") + self.logger.error(f"Response for {name} call has no result: {response.text}") + + def make_rpc_call( + self, + rpc_call: RpcCall | None = None, + method: str | None = None, + params: list[t.Any] | dict | None = None, + name: str = "", + path: str = "", + ) -> None: + """Make a JSON-RPC call.""" + if rpc_call is None: + if method is None: + raise ValueError("Either rpc_call or method must be provided") + else: + rpc_call = RpcCall(method, params) + name = method + else: + name = rpc_call.method + + with self.client.request( + "POST", self.rpc_path + path, json=rpc_call.request_body(), name=name, catch_response=True + ) as response: + self.check_http_error(response) + self.check_json_rpc_response(response, name=name) + + def make_batch_rpc_call(self, rpc_calls: list[RpcCall], name: str = "", path: str = "") -> None: + """Make a Batch JSON-RPC call.""" + + if name == "": + name = f"Batch RPC ({len(rpc_calls)})" + + headers = {"Content-Type": "application/json", "accept": "application/json"} + + with self.client.request( + "POST", + self.rpc_path + path, + data=generate_batch_request_body(rpc_calls), + name=name, + catch_response=True, + headers=headers, + ) as response: + self.check_http_error(response) + self.check_json_rpc_response(response, name=name) + + def make_random_batch_rpc_call( + self, + weighted_rpc_calls: dict[t.Callable[[], RpcCall], int], + calls_per_batch: int, + name: str = "", + path: str = "", + ) -> None: + """Make a Batch JSON-RPC call.""" + rpc_calls: list[RpcCall] = expand_rpc_calls(weighted_rpc_calls) + random_rpc_calls: list[RpcCall] = random.choices(rpc_calls, k=calls_per_batch) + + self.make_batch_rpc_call(random_rpc_calls, name=name, path=path) diff --git a/chainbench/user/protocol/ethereum.py b/chainbench/user/protocol/ethereum.py index b4736b7..da59d98 100644 --- a/chainbench/user/protocol/ethereum.py +++ b/chainbench/user/protocol/ethereum.py @@ -5,6 +5,7 @@ from chainbench.test_data.ethereum import EthBeaconTestData from chainbench.user.http import HttpUser +from chainbench.user.wss import WSSubscription logger = logging.getLogger(__name__) @@ -329,3 +330,8 @@ class TestEthMethod(EthBeaconUser): @task def run_task(self) -> None: self.method_to_task_function(self.environment.parsed_options.method)() + + +class EthSubscribe(WSSubscription): + def __init__(self, params: dict | list): + super().__init__("eth_subscribe", params, "eth_unsubscribe") diff --git a/chainbench/user/protocol/evm.py b/chainbench/user/protocol/evm.py index fd746ca..e310465 100644 --- a/chainbench/user/protocol/evm.py +++ b/chainbench/user/protocol/evm.py @@ -8,14 +8,16 @@ Tx, TxHash, ) -from chainbench.user.http import JsonRpcUser, RpcCall +from chainbench.user.jsonrpc import JrpcHttpUser from chainbench.user.tag import tag -from chainbench.util.rng import RNG +from chainbench.util.jsonrpc import RpcCall +from chainbench.util.rng import RNG, RNGManager -class EvmBaseUser(JsonRpcUser): +class EvmBaseUser(JrpcHttpUser): abstract = True - test_data = EvmTestData() + test_data: EvmTestData = EvmTestData() + rng = RNGManager() _default_trace_timeout = "120s" diff --git a/chainbench/user/protocol/solana.py b/chainbench/user/protocol/solana.py index d2fc63e..4659f8f 100644 --- a/chainbench/user/protocol/solana.py +++ b/chainbench/user/protocol/solana.py @@ -4,13 +4,15 @@ from solders.message import Message from chainbench.test_data import Account, BlockNumber, SolanaTestData, TxHash -from chainbench.user.http import JsonRpcUser, RpcCall -from chainbench.util.rng import RNG +from chainbench.user.jsonrpc import JrpcHttpUser +from chainbench.util.jsonrpc import RpcCall +from chainbench.util.rng import RNG, RNGManager -class SolanaBaseUser(JsonRpcUser): +class SolanaBaseUser(JrpcHttpUser): abstract = True test_data = SolanaTestData() + rng = RNGManager() rpc_error_code_exclusions = [-32007] def _create_random_transaction_message(self, rng: RNG) -> Message: diff --git a/chainbench/user/protocol/starknet.py b/chainbench/user/protocol/starknet.py index 710a808..774162b 100644 --- a/chainbench/user/protocol/starknet.py +++ b/chainbench/user/protocol/starknet.py @@ -2,11 +2,11 @@ from chainbench.test_data import StarkNetTestData from chainbench.test_data.blockchain import Account, TxHash -from chainbench.user.http import JsonRpcUser +from chainbench.user.jsonrpc import JrpcHttpUser from chainbench.util.rng import RNG -class StarkNetUser(JsonRpcUser): +class StarkNetUser(JrpcHttpUser): abstract = True test_data = StarkNetTestData() diff --git a/chainbench/user/wss.py b/chainbench/user/wss.py new file mode 100644 index 0000000..bf2bf8f --- /dev/null +++ b/chainbench/user/wss.py @@ -0,0 +1,215 @@ +import logging +import time + +import gevent +import orjson as json +from gevent import Greenlet, Timeout +from locust import User, task +from locust.env import Environment +from orjson import JSONDecodeError +from websocket import WebSocket, WebSocketConnectionClosedException, create_connection + +from chainbench.util.jsonrpc import RpcCall + + +class WSSubscription: + def __init__(self, subscribe_method: str, subscribe_params: dict | list, unsubscribe_method: str): + self.subscribe_rpc_call: RpcCall = RpcCall(subscribe_method, subscribe_params) + self.unsubscribe_method: str = unsubscribe_method + self.subscribed: bool = False + self._subscription_id: int | str | None = None + + @property + def subscription_id(self): + return self._subscription_id + + @subscription_id.setter + def subscription_id(self, value: int | str): + self._subscription_id = value + self.subscribed = True + + @subscription_id.deleter + def subscription_id(self): + self._subscription_id = None + self.subscribed = False + + +class WSRequest: + def __init__(self, rpc_call: RpcCall, start_time: int, subscription_index: int | None = None): + self.rpc_call = rpc_call + self.start_time = start_time + self.subscription_index = subscription_index + + +class WssJrpcUser(User): + abstract = True + logger = logging.getLogger(__name__) + + # To be populated by subclass + subscriptions: list[WSSubscription] = [] + subscription_ids_to_index: dict[str | int, int] = {} + + def __init__(self, environment: Environment): + super().__init__(environment) + self._ws: WebSocket | None = None + self._ws_greenlet: Greenlet | None = None + self._requests: dict[int, WSRequest] = {} + self._running: bool = False + + def get_subscription(self, subscription_id: str | int): + return self.subscriptions[self.subscription_ids_to_index[subscription_id]] + + @task + def dummy_task(self): + gevent.sleep(3600) + + def on_start(self) -> None: + self._running = True + host: str = self.environment.parsed_options.host + if host.startswith("ws") or host.startswith("wss"): + self.connect(host) + else: + raise ValueError("Invalid host provided. Expected ws or wss protocol") + self.subscribe_all() + + def on_stop(self) -> None: + self.unsubscribe_all() + timeout = Timeout(30) + timeout.start() + try: + while self._requests: + gevent.sleep(1) + except Timeout: + self.logger.error("Timeout 30s - Failed to unsubscribe from all subscriptions") + timeout.close() + self._running = False + self.logger.debug("Unsubscribed from all subscriptions") + + def connect(self, host: str): + self._ws = create_connection(host, skip_utf8_validation=True) + self._ws_greenlet = gevent.spawn(self.receive_loop) + + def subscribe_all(self): + for i in range(len(self.subscriptions)): + self.subscribe(self.subscriptions[i], i) + + def subscribe(self, subscription: WSSubscription, index: int): + self.send(rpc_call=subscription.subscribe_rpc_call, subscription_index=index) + + def unsubscribe_all(self): + for i in range(len(self.subscriptions)): + self.unsubscribe(self.subscriptions[i], i) + + def unsubscribe(self, subscription: WSSubscription, index: int): + params = [subscription.subscription_id] + self.send(method=subscription.unsubscribe_method, params=params, subscription_index=index) + + def get_notification_name(self, parsed_response: dict): + # Override this method to return the name of the notification if this is not correct + return parsed_response["method"] + + def on_message(self, message: str | bytes): + try: + parsed_json: dict = json.loads(message) + if "error" in parsed_json: + self.environment.events.request.fire( + request_type="WSJrpcErr", + name=f"JsonRPC Error {parsed_json['error']['code']}", + response_time=None, + response_length=len(message), + exception=None, + response=message, + ) + return + if "id" not in parsed_json: + self.environment.events.request.fire( + request_type="WSNotif", + name=self.get_notification_name(parsed_json), + response_time=None, + response_length=len(message), + exception=None, + ) + return + if request := self.get_request(parsed_json): + if request.subscription_index is not None: + self.subscriptions[request.subscription_index].subscription_id = parsed_json["result"] + self.subscriptions[request.subscription_index].subscribed = "subscribed" + self.subscription_ids_to_index.update({parsed_json["result"]: request.subscription_index}) + self.environment.events.request.fire( + request_type="WSJrpc", + name=request.rpc_call.method, + response_time=((time.time_ns() - request.start_time) / 1_000_000).__round__(), + response_length=len(message), + exception=None, + ) + else: + self.logger.error("Received message with unknown id") + except JSONDecodeError: + self.environment.events.request.fire( + request_type="WSErr", + name="JSONDecodeError", + response_time=None, + response_length=len(message), + exception=JSONDecodeError, + response=message, + ) + + def get_request(self, json_response: dict): + if json_response["id"] not in self._requests: + self.logger.error("Received message with unknown id") + self.logger.error(json_response) + return None + return self._requests.pop(json_response["id"]) + + def receive_loop(self): + try: + while self._running: + message = self._ws.recv() + self.logger.debug(f"WSResp: {message.strip()}") + self.on_message(message) + else: + self._ws.close() + except WebSocketConnectionClosedException: + self.environment.events.request.fire( + request_type="WSerr", + name="WebSocket Connection", + response_time=None, + response_length=0, + exception=WebSocketConnectionClosedException, + ) + self._running = False + self.logger.error("Connection closed by server, trying to reconnect...") + self.on_start() + + def send( + self, + rpc_call: RpcCall | None = None, + method: str | None = None, + params: dict | list | None = None, + subscription_index: int | None = None, + ): + def _get_args(): + if rpc_call: + return rpc_call + elif method: + return RpcCall(method, params) + else: + raise ValueError("Either rpc_call or method must be provided") + + rpc_call = _get_args() + self.logger.debug(f"Sending: {rpc_call or method}") + + if rpc_call is None: + raise ValueError("Either rpc_call or method must be provided") + + if rpc_call is None and (method is not None): + rpc_call = RpcCall(method, params) + elif rpc_call is None and (method is None): + raise ValueError("Either rpc_call or method must be provided") + self._requests.update( + {rpc_call.request_id: WSRequest(rpc_call, start_time=time.time_ns(), subscription_index=subscription_index)} + ) + json_body = json.dumps(rpc_call.request_body()) + self.logger.debug(f"WSReq: {json_body.decode('utf-8')}") + if self._ws: + self._ws.send(json_body) diff --git a/chainbench/util/cli.py b/chainbench/util/cli.py index a8f4016..b1edc85 100644 --- a/chainbench/util/cli.py +++ b/chainbench/util/cli.py @@ -156,7 +156,7 @@ def get_worker_command(self, worker_id: int = 0) -> str: """Generate worker command.""" command = ( f"locust -f {self.profile_path} --worker --master-host {self.host} --master-port {self.port} " - f"--logfile {self.results_path}/worker_{worker_id}.log --loglevel {self.log_level}" + f"--logfile {self.results_path}/worker_{worker_id}.log --loglevel {self.log_level} --stop-timeout 30" ) return self.get_extra_options(command) diff --git a/chainbench/util/event.py b/chainbench/util/event.py index e751049..cdbfbcb 100644 --- a/chainbench/util/event.py +++ b/chainbench/util/event.py @@ -179,59 +179,66 @@ def on_init(environment: Environment, **_kwargs): test_data: dict[str, t.Any] = {} for user in environment.runner.user_classes: if not hasattr(user, "test_data"): - raise AttributeError(f"{user} class does not have 'test_data' attribute") - user_test_data: TestData = getattr(user, "test_data") - test_data_class_name: str = type(user_test_data).__name__ - if test_data_class_name in test_data: - continue - logger.info(f"Initializing test data for {test_data_class_name}") - print(f"Initializing test data for {test_data_class_name}") - if environment.host: - user_test_data.init_http_client(environment.host) - if isinstance(user_test_data, EvmTestData): - chain_id: ChainId = user_test_data.fetch_chain_id() - user_test_data.init_network(chain_id) - logger.info(f"Target endpoint network is {user_test_data.network.name}") - print(f"Target endpoint network is {user_test_data.network.name}") - test_data["chain_id"] = {test_data_class_name: chain_id} - if environment.parsed_options: - user_test_data.init_data(environment.parsed_options) - test_data[test_data_class_name] = user_test_data.data.to_json() - send_msg_to_workers(environment.runner, "test_data", test_data) - print("Fetching blocks...") - if environment.parsed_options.use_latest_blocks: - print(f"Using latest {user_test_data.data.size.blocks_len} blocks as test data") - logger.info(f"Using latest {user_test_data.data.size.blocks_len} blocks as test data") - for block_number in range( - user_test_data.data.block_range.start, user_test_data.data.block_range.end + 1 - ): - try: - block = user_test_data.fetch_block(block_number) - except (BlockNotFoundError, InvalidBlockError): - block = user_test_data.fetch_latest_block() - user_test_data.data.push_block(block) - block_data = {test_data_class_name: block.to_json()} - send_msg_to_workers(environment.runner, "block_data", block_data) - print(user_test_data.data.stats(), end="\r") - else: - print(user_test_data.data.stats(), end="\r") - print("\n") # new line after progress display upon exiting loop + logger.warning(f"{user} class does not have 'test_data' attribute") else: - while user_test_data.data.size.blocks_len > len(user_test_data.data.blocks): - try: - block = user_test_data.fetch_random_block(user_test_data.data.block_numbers) - except (BlockNotFoundError, InvalidBlockError): - continue - user_test_data.data.push_block(block) - block_data = {test_data_class_name: block.to_json()} - send_msg_to_workers(environment.runner, "block_data", block_data) - print(user_test_data.data.stats(), end="\r") + user_test_data: TestData = getattr(user, "test_data") + test_data_class_name: str = type(user_test_data).__name__ + if test_data_class_name in test_data: + continue + logger.info(f"Initializing test data for {test_data_class_name}") + print(f"Initializing test data for {test_data_class_name}") + if environment.host: + user_test_data.init_http_client(environment.host) + if isinstance(user_test_data, EvmTestData): + chain_id: ChainId = user_test_data.fetch_chain_id() + user_test_data.init_network(chain_id) + logger.info(f"Target endpoint network is {user_test_data.network.name}") + print(f"Target endpoint network is {user_test_data.network.name}") + test_data["chain_id"] = {test_data_class_name: chain_id} + if environment.parsed_options: + user_test_data.init_data(environment.parsed_options) + test_data[test_data_class_name] = user_test_data.data.to_json() + send_msg_to_workers(environment.runner, "test_data", test_data) + print("Fetching blocks...") + if environment.parsed_options.use_latest_blocks: + print(f"Using latest {user_test_data.data.size.blocks_len} blocks as test data") + logger.info(f"Using latest {user_test_data.data.size.blocks_len} blocks as test data") + for block_number in range( + user_test_data.data.block_range.start, user_test_data.data.block_range.end + 1 + ): + block = None + try: + block = user_test_data.fetch_block(block_number) + except (BlockNotFoundError, InvalidBlockError): + pass + while block is None: + try: + block = user_test_data.fetch_latest_block() + except (BlockNotFoundError, InvalidBlockError): + pass + user_test_data.data.push_block(block) + block_data = {test_data_class_name: block.to_json()} + send_msg_to_workers(environment.runner, "block_data", block_data) + print(user_test_data.data.stats(), end="\r") + else: + print(user_test_data.data.stats(), end="\r") + print("\n") # new line after progress display upon exiting loop else: - print(user_test_data.data.stats(), end="\r") - print("\n") # new line after progress display upon exiting loop - logger.info("Test data is ready") - send_msg_to_workers(environment.runner, "release_lock", {}) - user_test_data.release_lock() + while user_test_data.data.size.blocks_len > len(user_test_data.data.blocks): + try: + block = user_test_data.fetch_random_block(user_test_data.data.block_numbers) + except (BlockNotFoundError, InvalidBlockError): + continue + user_test_data.data.push_block(block) + block_data = {test_data_class_name: block.to_json()} + send_msg_to_workers(environment.runner, "block_data", block_data) + print(user_test_data.data.stats(), end="\r") + else: + print(user_test_data.data.stats(), end="\r") + print("\n") # new line after progress display upon exiting loop + logger.info("Test data is ready") + send_msg_to_workers(environment.runner, "release_lock", {}) + user_test_data.release_lock() except Exception as e: logger.error(f"Failed to init test data: {e.__class__.__name__}: {e}. Exiting...") print(f"Failed to init test data:\n {e.__class__.__name__}: {e}. Exiting...") diff --git a/chainbench/util/http.py b/chainbench/util/http.py index 1a7f95e..02e6b34 100644 --- a/chainbench/util/http.py +++ b/chainbench/util/http.py @@ -1,14 +1,14 @@ -import json import logging import typing as t from base64 import b64encode from enum import IntEnum from functools import cached_property -from json import JSONDecodeError from secrets import token_hex +import orjson as json from geventhttpclient import URL, HTTPClient from geventhttpclient.response import HTTPSocketPoolResponse +from orjson import JSONDecodeError logger = logging.getLogger(__name__) @@ -142,7 +142,7 @@ def post( headers.update({"Accept": "application/json"}) headers.update(self._general_headers) if isinstance(data, dict): - body = json.dumps(data).encode("utf-8") + body = json.dumps(data) elif isinstance(data, bytes): body = data else: diff --git a/chainbench/util/jsonrpc.py b/chainbench/util/jsonrpc.py new file mode 100644 index 0000000..0fab3be --- /dev/null +++ b/chainbench/util/jsonrpc.py @@ -0,0 +1,66 @@ +import random +import typing as t + +import orjson as json + + +class RpcCall: + def __init__(self, method: str, params: list[t.Any] | dict | None = None, request_id: int | None = None) -> None: + self._request_id = request_id + self.method = method + self.params = params + + @property + def request_id(self) -> int: + if self._request_id is None: + self._request_id = random.Random().randint(1, 100000000) + return self._request_id + + def request_body(self, request_id: int | None = None) -> dict: + """Generate a JSON-RPC request body.""" + if self.params is None: + self.params = [] + + if type(self.params) is dict: + self.params = [self.params] + + if request_id: + self._request_id = request_id + + return { + "jsonrpc": "2.0", + "method": self.method, + "params": self.params, + "id": self.request_id, + } + + +def generate_batch_request_body(rpc_calls: list[RpcCall]) -> str: + """Generate a batch JSON-RPC request body.""" + return json.dumps([rpc_calls[i].request_body(i) for i in range(1, len(rpc_calls))]).decode("utf-8") + + +def expand_rpc_calls(rpc_calls_weighted: dict[t.Callable[[], RpcCall], int]) -> list[RpcCall]: + rpc_call_methods_weighted: dict[RpcCall, int] = {} + for rpc_call_method, weight in rpc_calls_weighted.items(): + rpc_call_methods_weighted[rpc_call_method()] = weight + + expanded_rpc_calls: list[RpcCall] = expand_to_list(rpc_call_methods_weighted) + return expanded_rpc_calls + + +def expand_to_list(items_weighted: dict[t.Any, int] | list[t.Any | tuple[t.Any, int]]) -> list[t.Any]: + expanded_items_list: list[t.Any] = [] + if isinstance(items_weighted, dict): + items_weighted = list(items_weighted.items()) + + if isinstance(items_weighted, list): + for rpc_call in items_weighted: + if isinstance(rpc_call, tuple): + rpc_call, count = rpc_call + for _ in range(count): + expanded_items_list.append(rpc_call) + else: + expanded_items_list.append(rpc_call) + + return expanded_items_list diff --git a/chainbench/util/monitor.py b/chainbench/util/monitor.py index 4e4f0dd..4098435 100644 --- a/chainbench/util/monitor.py +++ b/chainbench/util/monitor.py @@ -1,11 +1,11 @@ import csv import logging from datetime import datetime, timedelta -from json import JSONDecodeError from pathlib import Path from time import sleep from locust.util.timespan import parse_timespan +from orjson import JSONDecodeError from .http import HttpClient diff --git a/poetry.lock b/poetry.lock index eeb20bb..20c4e29 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1071,6 +1071,66 @@ files = [ [package.dependencies] setuptools = "*" +[[package]] +name = "orjson" +version = "3.10.6" +description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" +optional = false +python-versions = ">=3.8" +files = [ + {file = "orjson-3.10.6-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:fb0ee33124db6eaa517d00890fc1a55c3bfe1cf78ba4a8899d71a06f2d6ff5c7"}, + {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c1c4b53b24a4c06547ce43e5fee6ec4e0d8fe2d597f4647fc033fd205707365"}, + {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eadc8fd310edb4bdbd333374f2c8fec6794bbbae99b592f448d8214a5e4050c0"}, + {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:61272a5aec2b2661f4fa2b37c907ce9701e821b2c1285d5c3ab0207ebd358d38"}, + {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57985ee7e91d6214c837936dc1608f40f330a6b88bb13f5a57ce5257807da143"}, + {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:633a3b31d9d7c9f02d49c4ab4d0a86065c4a6f6adc297d63d272e043472acab5"}, + {file = "orjson-3.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1c680b269d33ec444afe2bdc647c9eb73166fa47a16d9a75ee56a374f4a45f43"}, + {file = "orjson-3.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f759503a97a6ace19e55461395ab0d618b5a117e8d0fbb20e70cfd68a47327f2"}, + {file = "orjson-3.10.6-cp310-none-win32.whl", hash = "sha256:95a0cce17f969fb5391762e5719575217bd10ac5a189d1979442ee54456393f3"}, + {file = "orjson-3.10.6-cp310-none-win_amd64.whl", hash = "sha256:df25d9271270ba2133cc88ee83c318372bdc0f2cd6f32e7a450809a111efc45c"}, + {file = "orjson-3.10.6-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b1ec490e10d2a77c345def52599311849fc063ae0e67cf4f84528073152bb2ba"}, + {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d43d3feb8f19d07e9f01e5b9be4f28801cf7c60d0fa0d279951b18fae1932b"}, + {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac3045267e98fe749408eee1593a142e02357c5c99be0802185ef2170086a863"}, + {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c27bc6a28ae95923350ab382c57113abd38f3928af3c80be6f2ba7eb8d8db0b0"}, + {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d27456491ca79532d11e507cadca37fb8c9324a3976294f68fb1eff2dc6ced5a"}, + {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05ac3d3916023745aa3b3b388e91b9166be1ca02b7c7e41045da6d12985685f0"}, + {file = "orjson-3.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1335d4ef59ab85cab66fe73fd7a4e881c298ee7f63ede918b7faa1b27cbe5212"}, + {file = "orjson-3.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4bbc6d0af24c1575edc79994c20e1b29e6fb3c6a570371306db0993ecf144dc5"}, + {file = "orjson-3.10.6-cp311-none-win32.whl", hash = "sha256:450e39ab1f7694465060a0550b3f6d328d20297bf2e06aa947b97c21e5241fbd"}, + {file = "orjson-3.10.6-cp311-none-win_amd64.whl", hash = "sha256:227df19441372610b20e05bdb906e1742ec2ad7a66ac8350dcfd29a63014a83b"}, + {file = "orjson-3.10.6-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ea2977b21f8d5d9b758bb3f344a75e55ca78e3ff85595d248eee813ae23ecdfb"}, + {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6f3d167d13a16ed263b52dbfedff52c962bfd3d270b46b7518365bcc2121eed"}, + {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f710f346e4c44a4e8bdf23daa974faede58f83334289df80bc9cd12fe82573c7"}, + {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7275664f84e027dcb1ad5200b8b18373e9c669b2a9ec33d410c40f5ccf4b257e"}, + {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0943e4c701196b23c240b3d10ed8ecd674f03089198cf503105b474a4f77f21f"}, + {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:446dee5a491b5bc7d8f825d80d9637e7af43f86a331207b9c9610e2f93fee22a"}, + {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:64c81456d2a050d380786413786b057983892db105516639cb5d3ee3c7fd5148"}, + {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:960db0e31c4e52fa0fc3ecbaea5b2d3b58f379e32a95ae6b0ebeaa25b93dfd34"}, + {file = "orjson-3.10.6-cp312-none-win32.whl", hash = "sha256:a6ea7afb5b30b2317e0bee03c8d34c8181bc5a36f2afd4d0952f378972c4efd5"}, + {file = "orjson-3.10.6-cp312-none-win_amd64.whl", hash = "sha256:874ce88264b7e655dde4aeaacdc8fd772a7962faadfb41abe63e2a4861abc3dc"}, + {file = "orjson-3.10.6-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:66680eae4c4e7fc193d91cfc1353ad6d01b4801ae9b5314f17e11ba55e934183"}, + {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caff75b425db5ef8e8f23af93c80f072f97b4fb3afd4af44482905c9f588da28"}, + {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3722fddb821b6036fd2a3c814f6bd9b57a89dc6337b9924ecd614ebce3271394"}, + {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2c116072a8533f2fec435fde4d134610f806bdac20188c7bd2081f3e9e0133f"}, + {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6eeb13218c8cf34c61912e9df2de2853f1d009de0e46ea09ccdf3d757896af0a"}, + {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:965a916373382674e323c957d560b953d81d7a8603fbeee26f7b8248638bd48b"}, + {file = "orjson-3.10.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:03c95484d53ed8e479cade8628c9cea00fd9d67f5554764a1110e0d5aa2de96e"}, + {file = "orjson-3.10.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:e060748a04cccf1e0a6f2358dffea9c080b849a4a68c28b1b907f272b5127e9b"}, + {file = "orjson-3.10.6-cp38-none-win32.whl", hash = "sha256:738dbe3ef909c4b019d69afc19caf6b5ed0e2f1c786b5d6215fbb7539246e4c6"}, + {file = "orjson-3.10.6-cp38-none-win_amd64.whl", hash = "sha256:d40f839dddf6a7d77114fe6b8a70218556408c71d4d6e29413bb5f150a692ff7"}, + {file = "orjson-3.10.6-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:697a35a083c4f834807a6232b3e62c8b280f7a44ad0b759fd4dce748951e70db"}, + {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd502f96bf5ea9a61cbc0b2b5900d0dd68aa0da197179042bdd2be67e51a1e4b"}, + {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f215789fb1667cdc874c1b8af6a84dc939fd802bf293a8334fce185c79cd359b"}, + {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2debd8ddce948a8c0938c8c93ade191d2f4ba4649a54302a7da905a81f00b56"}, + {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5410111d7b6681d4b0d65e0f58a13be588d01b473822483f77f513c7f93bd3b2"}, + {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb1f28a137337fdc18384079fa5726810681055b32b92253fa15ae5656e1dddb"}, + {file = "orjson-3.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bf2fbbce5fe7cd1aa177ea3eab2b8e6a6bc6e8592e4279ed3db2d62e57c0e1b2"}, + {file = "orjson-3.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:79b9b9e33bd4c517445a62b90ca0cc279b0f1f3970655c3df9e608bc3f91741a"}, + {file = "orjson-3.10.6-cp39-none-win32.whl", hash = "sha256:30b0a09a2014e621b1adf66a4f705f0809358350a757508ee80209b2d8dae219"}, + {file = "orjson-3.10.6-cp39-none-win_amd64.whl", hash = "sha256:49e3bc615652617d463069f91b867a4458114c5b104e13b7ae6872e5f79d0844"}, + {file = "orjson-3.10.6.tar.gz", hash = "sha256:e54b63d0a7c6c54a5f5f726bc93a2078111ef060fec4ecbf34c5db800ca3b3a7"}, +] + [[package]] name = "packaging" version = "24.0" @@ -1603,6 +1663,22 @@ platformdirs = ">=3.9.1,<5" docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] +[[package]] +name = "websocket-client" +version = "1.8.0" +description = "WebSocket client for Python with low level API options" +optional = false +python-versions = ">=3.8" +files = [ + {file = "websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526"}, + {file = "websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da"}, +] + +[package.extras] +docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx-rtd-theme (>=1.1.0)"] +optional = ["python-socks", "wsaccel"] +test = ["websockets"] + [[package]] name = "werkzeug" version = "3.0.2" @@ -1620,6 +1696,71 @@ MarkupSafe = ">=2.1.1" [package.extras] watchdog = ["watchdog (>=2.3)"] +[[package]] +name = "wsaccel" +version = "0.6.6" +description = "Accelerator for ws4py and AutobahnPython" +optional = false +python-versions = "*" +files = [ + {file = "wsaccel-0.6.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:670857dd7a95a900bbb338d3a51b59d9cabc4797dbefa93ae56cbc249dedf8f3"}, + {file = "wsaccel-0.6.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d1377c8458ad4d21fc756dcffe9d768c68944bd09b278a1846758719c8e00288"}, + {file = "wsaccel-0.6.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c1a5937edaf38532597df61ce089deaef33417b4396484deff6f2b1fa22c62b"}, + {file = "wsaccel-0.6.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10a8eba84ef89b24e09bfdd94f74c90679810ea9508986178f2d858f55040029"}, + {file = "wsaccel-0.6.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:458b1576f8e3ff650aae35301cb717504c7fe7f1414565d8a4a19489d83df316"}, + {file = "wsaccel-0.6.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f069d60be03312ece51b75d0ea6839c41625930feaef8acd94716edaef74d053"}, + {file = "wsaccel-0.6.6-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62f3c7920167261cebf7b2ddd1d4a3e0e3b43f762dd9740ef71a1f2530081b3f"}, + {file = "wsaccel-0.6.6-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3aae728bdb5ec46442f7f4062bde6c62e2382a32ccd1fd8b3138b638021a2a36"}, + {file = "wsaccel-0.6.6-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:51ce50e3c7c7a1fc69f3b7920238d9cf9673d48d1402dc5ee71eb8426457ddc5"}, + {file = "wsaccel-0.6.6-cp310-cp310-win32.whl", hash = "sha256:d20505d3c0cbc7d34efcf9e7cc5b8f5f1dc4b055f0be09e15f528f387d72a633"}, + {file = "wsaccel-0.6.6-cp310-cp310-win_amd64.whl", hash = "sha256:273cd5cd584c02f5026b0609430d8ba9e7f5b6d01bd0a6309cab258fed1d5f8d"}, + {file = "wsaccel-0.6.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:70139bf54c26b8a7e5158bd7dc03cd0cc3af55facf6b63c55384610fc711c3ea"}, + {file = "wsaccel-0.6.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9c0ee2fc0e4cacb333e4ca00f7f55457c715d84244a8f034fdcb851abc3635ca"}, + {file = "wsaccel-0.6.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:414d9d802b59f5f41bbc2f001e2a9228957007c3684e33e64479e76b22909af3"}, + {file = "wsaccel-0.6.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f899f79d0881be740396efca9c01a776473c4f0eb59861a55c2c6e32b6781a74"}, + {file = "wsaccel-0.6.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d87200bbdf9759826dad33c2c322ccd2954b93431db707b83a80886f0b5910e5"}, + {file = "wsaccel-0.6.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a918d4e0b4042715a311706cd91aa5dcc1cf9bba5090cf7c2884769abb39c9cf"}, + {file = "wsaccel-0.6.6-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bcd6b7f5f8b6fc8378ad3d30f328b5091fbdc62b8b46bd952dacd5a64688707f"}, + {file = "wsaccel-0.6.6-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e5a758e1a9916df05053334978ed74c3241fa665ad789f8b962e977131e076ca"}, + {file = "wsaccel-0.6.6-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c2a1a929512a9f82504aa739d4790e3c78c800abd07c3b9bd1e046228c5a967e"}, + {file = "wsaccel-0.6.6-cp311-cp311-win32.whl", hash = "sha256:00445ea583128055896c359aa0fad1bcbc44bc85b7857db1e34a06301d232f20"}, + {file = "wsaccel-0.6.6-cp311-cp311-win_amd64.whl", hash = "sha256:ab1a1ee11161a9b882ebf6553bb1401713e86e72547a6f2d8f0bc467f672c911"}, + {file = "wsaccel-0.6.6-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4b3cafeb2f69e2b9606755da0098b2b0e70a6f37cb5f374d195b92d12d605f30"}, + {file = "wsaccel-0.6.6-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7d5c4535d153979c1196d7a23389a8e93129b738a4bb839fb1e77d9f2b08bb41"}, + {file = "wsaccel-0.6.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:59a77171798eac6ba372c4dc935a5ea98fe46461c5b70554de50bf500c735dd9"}, + {file = "wsaccel-0.6.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc51bae5016d2ef69cf8a71d5009f0f90dd043bcf1867f8d5b7cf7dab2d790c8"}, + {file = "wsaccel-0.6.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0440f6c8a0334336c76536c8f9985db0ac5f430afe5ca5827a4f2c4aaba11df"}, + {file = "wsaccel-0.6.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:610245b574804b4432c8aad906cc9c3c6c7315977290d8d323aee3f95b267651"}, + {file = "wsaccel-0.6.6-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b5a2a6e53bf9b184f70a63dfd595af7d534db29691e2a11324e720212ada7953"}, + {file = "wsaccel-0.6.6-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fd06a4c6539de32cf669da1629bd6837c6ac84a2454cc78a043441f18f8aeb5a"}, + {file = "wsaccel-0.6.6-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7bf2731f53907e89f4fd152149d3ce02c35f7c5c3755eedcad09d4f793ff75f8"}, + {file = "wsaccel-0.6.6-cp312-cp312-win32.whl", hash = "sha256:3e7b37678d316bb00126f97961bc1f4774fc7680dafa23bdea40a15beaf6a6bb"}, + {file = "wsaccel-0.6.6-cp312-cp312-win_amd64.whl", hash = "sha256:5b9b7cbee58ea12da45e817a01f20ba45ebd37d258aee4ab11106b91980961a3"}, + {file = "wsaccel-0.6.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:229c27d182591e76028d8fa2b4cc73286e18b7c882f34aa47d75a1c065b5514a"}, + {file = "wsaccel-0.6.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:587d3bbd9a9b8bf38fb8dcc32f59e23a60096da8900bbb1512fbc65fdf06c992"}, + {file = "wsaccel-0.6.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:09999ae0727897e47c958150311de25eba28dc52a0b5568a7d7a35b54e536e73"}, + {file = "wsaccel-0.6.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c21b7101b156a91df598e73dbc1b1a6c85224d609351f72991aa68f69aca873a"}, + {file = "wsaccel-0.6.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47b9c529dbabf0b647b091486f8f473abc10c927e897253a0038830b5aa57494"}, + {file = "wsaccel-0.6.6-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:60d7a1c6e3aa5f6cc144a789d5313a861f2fd860bf62e0a468eef2262bd805a0"}, + {file = "wsaccel-0.6.6-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:79a5cc1b3172601ac874ca048e9cf7ecb6209b61b6c3543822ac7640851c58a7"}, + {file = "wsaccel-0.6.6-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4f28a380eda05efbaae39701828d501539520938e1316ce4dc94bb0020cdfba1"}, + {file = "wsaccel-0.6.6-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d68c0638777d24ed1dfbef67b89894b887f0b24aac7e0106dad236337b6212ec"}, + {file = "wsaccel-0.6.6-cp38-cp38-win32.whl", hash = "sha256:fe774891fe89ce6e2e570501bf9af9cb8760b42991b3b588159ac38d8f058adf"}, + {file = "wsaccel-0.6.6-cp38-cp38-win_amd64.whl", hash = "sha256:4cd7f5f376efd07d93eeb4d0848c1b5b2805c93cee734cd79f2903cfd4d8a9a4"}, + {file = "wsaccel-0.6.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:56eae811dc5a13f4a0e7c1e8d567132846f7f320c4fa80964d5def7b98873eae"}, + {file = "wsaccel-0.6.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:73a5bb52cbbd19b19dd87232f2cead62c01fa7895b42f08ec266c847d4703561"}, + {file = "wsaccel-0.6.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0dd4a5e4c4b80c9d2b0b037b552a576193d3f6e9e812347763907217d92c2702"}, + {file = "wsaccel-0.6.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2638c5e69df9e86e12310a26eadc3bf6c536eb32304496e4a58914e735b303"}, + {file = "wsaccel-0.6.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0718cb37e17b258eb5e5804ae4d86dcc63073a6be5090c7c5e8993c0e0ec754c"}, + {file = "wsaccel-0.6.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:238298b9c5b9b821aa94703bbbf34e0e92a4ae67f08f2b82a28ecb293fbade73"}, + {file = "wsaccel-0.6.6-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1ba9afd0613b852c119a93d7dbd1ab02244b41f8ea619a117bdb0926ebfcc845"}, + {file = "wsaccel-0.6.6-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ca7ecf0a24ece61540c9b12488e60224c43de4b9ad21218074b5e998ea3219b2"}, + {file = "wsaccel-0.6.6-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8acad029668184e2a4dc3ebeefca4c0b71cb5d1081ebd933b674d6a0fc790f38"}, + {file = "wsaccel-0.6.6-cp39-cp39-win32.whl", hash = "sha256:90bb18a1b440230ef40f918449c4ca3219625500cba0423489a258a732e5e0b6"}, + {file = "wsaccel-0.6.6-cp39-cp39-win_amd64.whl", hash = "sha256:3798ba3af378753fa5166b68462dc11ad740ba47a321699d923d4eedaf50c20b"}, + {file = "wsaccel-0.6.6.tar.gz", hash = "sha256:18efec0a7182587ba97102b4cd8df7b4f665f45d7ca36f19783f5f081ea114ea"}, +] + [[package]] name = "zope-event" version = "5.0" @@ -1694,4 +1835,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "3544f0c3a01ea63a80fb62f87c5aaa2b4f5b4c60591229df1ffff9d002aee8b5" +content-hash = "11afa278640c190f98cb3334cfa24c630e83a52ceb5a505a62e8564563bbb8a3" diff --git a/pyproject.toml b/pyproject.toml index 9429e93..b13678b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,9 @@ locust-plugins = {extras = ["dashboards"], version = "^4.4.2"} tenacity = "^8.2.2" base58 = "^2.1.1" solders = "^0.21.0" +websocket-client = "^1.8.0" +orjson = "^3.10.6" +wsaccel = "^0.6.6" [tool.poetry.group.dev.dependencies] black = ">=23.1,<25.0"