erpc-sdk 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
erpc_sdk/__init__.py ADDED
@@ -0,0 +1,108 @@
1
+ """Async Python SDK for ERPC."""
2
+
3
+ from .client import (
4
+ ErpcClient,
5
+ ErpcCloudClient,
6
+ EthereumClient,
7
+ SolanaClient,
8
+ create_erpc_client,
9
+ create_erpc_cloud_client,
10
+ )
11
+ from .config import (
12
+ DEFAULT_ACCOUNT_ENDPOINT,
13
+ DEFAULT_ENDPOINT,
14
+ DEFAULT_TIMEOUT,
15
+ DEFAULT_USER_ENDPOINT,
16
+ ErpcClientConfig,
17
+ ErpcCloudClientConfig,
18
+ )
19
+ from .errors import (
20
+ ErpcAbortedError,
21
+ ErpcBatchPolicyError,
22
+ ErpcConfigError,
23
+ ErpcError,
24
+ ErpcErrorCode,
25
+ ErpcHttpError,
26
+ ErpcInvalidResponseError,
27
+ ErpcJsonRpcError,
28
+ ErpcTimeoutError,
29
+ ErpcTransportError,
30
+ )
31
+ from .rest import (
32
+ AccountClient,
33
+ CloudCatalogClient,
34
+ CloudCreditClient,
35
+ CloudResourcesClient,
36
+ PriceClient,
37
+ UsageClient,
38
+ )
39
+ from .rpc import (
40
+ ETHEREUM_RPC_METHODS,
41
+ ETHEREUM_SUBSCRIPTION_METHODS,
42
+ SOLANA_ANALYTICS_METHODS,
43
+ SOLANA_DAS_METHODS,
44
+ SOLANA_ENHANCED_SUBSCRIPTION_METHODS,
45
+ SOLANA_HISTORY_METHODS,
46
+ SOLANA_LEADER_METHODS,
47
+ SOLANA_RPC_METHODS,
48
+ PendingRpcBatchRequest,
49
+ PendingRpcRequest,
50
+ RpcNamespace,
51
+ )
52
+ from .subscriptions import (
53
+ EthereumSubscriptions,
54
+ RpcSubscription,
55
+ SolanaSubscriptions,
56
+ WebSocketJsonRpcTransport,
57
+ )
58
+ from .transport import HttpJsonRpcTransport
59
+ from .types import * # noqa: F403
60
+
61
+ __version__ = "0.3.0"
62
+
63
+ __all__ = [
64
+ "DEFAULT_ACCOUNT_ENDPOINT",
65
+ "DEFAULT_ENDPOINT",
66
+ "DEFAULT_TIMEOUT",
67
+ "DEFAULT_USER_ENDPOINT",
68
+ "ETHEREUM_RPC_METHODS",
69
+ "ETHEREUM_SUBSCRIPTION_METHODS",
70
+ "SOLANA_ANALYTICS_METHODS",
71
+ "SOLANA_DAS_METHODS",
72
+ "SOLANA_ENHANCED_SUBSCRIPTION_METHODS",
73
+ "SOLANA_HISTORY_METHODS",
74
+ "SOLANA_LEADER_METHODS",
75
+ "SOLANA_RPC_METHODS",
76
+ "AccountClient",
77
+ "CloudCatalogClient",
78
+ "CloudCreditClient",
79
+ "CloudResourcesClient",
80
+ "EthereumClient",
81
+ "EthereumSubscriptions",
82
+ "ErpcAbortedError",
83
+ "ErpcBatchPolicyError",
84
+ "ErpcClient",
85
+ "ErpcClientConfig",
86
+ "ErpcCloudClient",
87
+ "ErpcCloudClientConfig",
88
+ "ErpcConfigError",
89
+ "ErpcError",
90
+ "ErpcErrorCode",
91
+ "ErpcHttpError",
92
+ "ErpcInvalidResponseError",
93
+ "ErpcJsonRpcError",
94
+ "ErpcTimeoutError",
95
+ "ErpcTransportError",
96
+ "HttpJsonRpcTransport",
97
+ "PendingRpcBatchRequest",
98
+ "PendingRpcRequest",
99
+ "PriceClient",
100
+ "RpcNamespace",
101
+ "RpcSubscription",
102
+ "SolanaClient",
103
+ "SolanaSubscriptions",
104
+ "UsageClient",
105
+ "WebSocketJsonRpcTransport",
106
+ "create_erpc_client",
107
+ "create_erpc_cloud_client",
108
+ ]
erpc_sdk/client.py ADDED
@@ -0,0 +1,213 @@
1
+ """Top-level ERPC and Cloud client composition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import httpx
8
+
9
+ from .config import (
10
+ ErpcClientConfig,
11
+ ErpcCloudClientConfig,
12
+ endpoint_with_path,
13
+ websocket_url,
14
+ )
15
+ from .rest import (
16
+ AccountClient,
17
+ CloudCatalogClient,
18
+ CloudCreditClient,
19
+ CloudResourcesClient,
20
+ PriceClient,
21
+ UsageClient,
22
+ )
23
+ from .rpc import (
24
+ ETHEREUM_RPC_METHODS,
25
+ SOLANA_ANALYTICS_METHODS,
26
+ SOLANA_DAS_METHODS,
27
+ SOLANA_HISTORY_METHODS,
28
+ SOLANA_LEADER_METHODS,
29
+ SOLANA_RPC_METHODS,
30
+ RpcNamespace,
31
+ )
32
+ from .subscriptions import (
33
+ EthereumSubscriptions,
34
+ SolanaSubscriptions,
35
+ WebSocketJsonRpcTransport,
36
+ )
37
+ from .transport import HttpJsonRpcTransport, RestTransport
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class SolanaClient:
42
+ rpc: RpcNamespace
43
+ das: RpcNamespace
44
+ history: RpcNamespace
45
+ leaders: RpcNamespace
46
+ analytics: RpcNamespace
47
+ subscriptions: SolanaSubscriptions
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class EthereumClient:
52
+ rpc: RpcNamespace
53
+ subscriptions: EthereumSubscriptions
54
+
55
+
56
+ class ErpcClient:
57
+ """Async-first client for JSON-RPC, REST, streams, and subscriptions."""
58
+
59
+ def __init__(
60
+ self,
61
+ config: ErpcClientConfig,
62
+ *,
63
+ http_client: httpx.AsyncClient | None = None,
64
+ ) -> None:
65
+ client = http_client or httpx.AsyncClient(timeout=None, follow_redirects=False)
66
+ self._http_client = client
67
+ self._owns_http_client = http_client is None
68
+ solana_transport = HttpJsonRpcTransport(
69
+ api_key=config.api_key,
70
+ endpoint=config.endpoint,
71
+ headers=config.headers,
72
+ timeout=config.timeout,
73
+ client=client,
74
+ )
75
+ ethereum_transport = HttpJsonRpcTransport(
76
+ api_key=config.api_key,
77
+ endpoint=endpoint_with_path(config.endpoint, "/eth"),
78
+ headers=config.headers,
79
+ timeout=config.timeout,
80
+ client=client,
81
+ )
82
+ solana_ws = WebSocketJsonRpcTransport(
83
+ websocket_url(config.endpoint, config.api_key),
84
+ config.api_key,
85
+ config.timeout,
86
+ )
87
+ ethereum_ws = WebSocketJsonRpcTransport(
88
+ websocket_url(config.endpoint, config.api_key, "/eth"),
89
+ config.api_key,
90
+ config.timeout,
91
+ )
92
+ self.solana = SolanaClient(
93
+ rpc=RpcNamespace(
94
+ solana_transport,
95
+ SOLANA_RPC_METHODS,
96
+ parameter_mode="positional",
97
+ batch_policy="solana-standard",
98
+ ),
99
+ das=RpcNamespace(solana_transport, SOLANA_DAS_METHODS, parameter_mode="named"),
100
+ history=RpcNamespace(
101
+ solana_transport, SOLANA_HISTORY_METHODS, parameter_mode="positional"
102
+ ),
103
+ leaders=RpcNamespace(
104
+ solana_transport,
105
+ SOLANA_LEADER_METHODS,
106
+ parameter_mode="positional",
107
+ batch_policy="unsupported",
108
+ ),
109
+ analytics=RpcNamespace(
110
+ solana_transport,
111
+ SOLANA_ANALYTICS_METHODS,
112
+ parameter_mode="positional",
113
+ ),
114
+ subscriptions=SolanaSubscriptions(solana_ws),
115
+ )
116
+ self.ethereum = EthereumClient(
117
+ rpc=RpcNamespace(ethereum_transport, ETHEREUM_RPC_METHODS, parameter_mode="positional"),
118
+ subscriptions=EthereumSubscriptions(ethereum_ws),
119
+ )
120
+ self.price = PriceClient(
121
+ RestTransport(
122
+ credential=config.api_key,
123
+ endpoint=config.endpoint,
124
+ headers=config.headers,
125
+ timeout=config.timeout,
126
+ client=client,
127
+ )
128
+ )
129
+ self.account = AccountClient(
130
+ RestTransport(
131
+ credential=config.api_key,
132
+ endpoint=config.account_endpoint,
133
+ headers=config.headers,
134
+ timeout=config.timeout,
135
+ client=client,
136
+ )
137
+ )
138
+ self.usage = UsageClient(
139
+ RestTransport(
140
+ credential=config.api_key,
141
+ endpoint=config.user_endpoint,
142
+ headers=config.headers,
143
+ timeout=config.timeout,
144
+ client=client,
145
+ )
146
+ )
147
+ self._closed = False
148
+
149
+ async def close(self) -> None:
150
+ if self._closed:
151
+ return
152
+ self._closed = True
153
+ await self.solana.subscriptions.close()
154
+ await self.ethereum.subscriptions.close()
155
+ if self._owns_http_client:
156
+ await self._http_client.aclose()
157
+
158
+ async def __aenter__(self) -> ErpcClient:
159
+ return self
160
+
161
+ async def __aexit__(self, *_: object) -> None:
162
+ await self.close()
163
+
164
+
165
+ class ErpcCloudClient:
166
+ """Scoped Cloud catalog, credit, resource, and usage reads."""
167
+
168
+ def __init__(
169
+ self,
170
+ config: ErpcCloudClientConfig,
171
+ *,
172
+ http_client: httpx.AsyncClient | None = None,
173
+ ) -> None:
174
+ client = http_client or httpx.AsyncClient(timeout=None, follow_redirects=False)
175
+ self._http_client = client
176
+ self._owns_http_client = http_client is None
177
+ transport = RestTransport(
178
+ credential=config.access_token,
179
+ endpoint=config.endpoint,
180
+ headers=config.headers,
181
+ timeout=config.timeout,
182
+ client=client,
183
+ )
184
+ self.catalog = CloudCatalogClient(transport)
185
+ self.credit = CloudCreditClient(transport)
186
+ self.resources = CloudResourcesClient(transport)
187
+ self.usage = UsageClient(transport)
188
+ self._closed = False
189
+
190
+ async def close(self) -> None:
191
+ if self._closed:
192
+ return
193
+ self._closed = True
194
+ if self._owns_http_client:
195
+ await self._http_client.aclose()
196
+
197
+ async def __aenter__(self) -> ErpcCloudClient:
198
+ return self
199
+
200
+ async def __aexit__(self, *_: object) -> None:
201
+ await self.close()
202
+
203
+
204
+ def create_erpc_client(
205
+ config: ErpcClientConfig, *, http_client: httpx.AsyncClient | None = None
206
+ ) -> ErpcClient:
207
+ return ErpcClient(config, http_client=http_client)
208
+
209
+
210
+ def create_erpc_cloud_client(
211
+ config: ErpcCloudClientConfig, *, http_client: httpx.AsyncClient | None = None
212
+ ) -> ErpcCloudClient:
213
+ return ErpcCloudClient(config, http_client=http_client)
erpc_sdk/config.py ADDED
@@ -0,0 +1,114 @@
1
+ """Configuration and URL handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from urllib.parse import quote, urlencode, urlsplit, urlunsplit
8
+
9
+ from .errors import ErpcConfigError
10
+
11
+ DEFAULT_ENDPOINT = "https://edge.erpc.global"
12
+ DEFAULT_ACCOUNT_ENDPOINT = "https://solana-rpc.erpc.global"
13
+ DEFAULT_USER_ENDPOINT = "https://user-api.erpc.global"
14
+ DEFAULT_TIMEOUT = 30.0
15
+
16
+
17
+ def normalize_endpoint(value: str, *, local_http_only: bool = False) -> str:
18
+ try:
19
+ parsed = urlsplit(value)
20
+ _ = parsed.port
21
+ except ValueError as error:
22
+ raise ErpcConfigError("endpoint must be an absolute HTTP(S) URL") from error
23
+ if not parsed.scheme or not parsed.netloc:
24
+ raise ErpcConfigError("endpoint must be an absolute HTTP(S) URL")
25
+ local = parsed.hostname in {"127.0.0.1", "::1", "localhost"}
26
+ allowed = parsed.scheme == "https" or (
27
+ parsed.scheme == "http" and (not local_http_only or local)
28
+ )
29
+ if not allowed:
30
+ message = (
31
+ "endpoint must use HTTPS except on localhost"
32
+ if local_http_only
33
+ else "endpoint must use HTTP or HTTPS"
34
+ )
35
+ raise ErpcConfigError(message)
36
+ path = parsed.path.rstrip("/") or "/"
37
+ return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
38
+
39
+
40
+ def endpoint_with_path(endpoint: str, path: str) -> str:
41
+ parsed = urlsplit(endpoint)
42
+ base = parsed.path.rstrip("/")
43
+ joined = f"{base}/{path.lstrip('/')}"
44
+ return urlunsplit((parsed.scheme, parsed.netloc, joined, "", ""))
45
+
46
+
47
+ def websocket_url(endpoint: str, api_key: str, path: str = "") -> str:
48
+ parsed = urlsplit(endpoint_with_path(endpoint, path))
49
+ scheme = "wss" if parsed.scheme == "https" else "ws"
50
+ query = urlencode({"api-key": api_key}, quote_via=quote)
51
+ return urlunsplit((scheme, parsed.netloc, parsed.path, query, ""))
52
+
53
+
54
+ @dataclass(frozen=True, repr=False, slots=True)
55
+ class ErpcClientConfig:
56
+ """Configuration for :class:`ErpcClient`; credentials are hidden in repr."""
57
+
58
+ api_key: str
59
+ endpoint: str = DEFAULT_ENDPOINT
60
+ account_endpoint: str = DEFAULT_ACCOUNT_ENDPOINT
61
+ user_endpoint: str = DEFAULT_USER_ENDPOINT
62
+ headers: Mapping[str, str] = field(default_factory=dict)
63
+ timeout: float = DEFAULT_TIMEOUT
64
+
65
+ def __post_init__(self) -> None:
66
+ key = self.api_key.strip()
67
+ if not key:
68
+ raise ErpcConfigError("api_key must not be empty")
69
+ if not isinstance(self.timeout, (int, float)) or self.timeout <= 0:
70
+ raise ErpcConfigError("timeout must be positive")
71
+ object.__setattr__(self, "api_key", key)
72
+ object.__setattr__(self, "endpoint", normalize_endpoint(self.endpoint))
73
+ object.__setattr__(self, "account_endpoint", normalize_endpoint(self.account_endpoint))
74
+ object.__setattr__(self, "user_endpoint", normalize_endpoint(self.user_endpoint))
75
+ object.__setattr__(self, "headers", dict(self.headers))
76
+ object.__setattr__(self, "timeout", float(self.timeout))
77
+
78
+ def __repr__(self) -> str:
79
+ return (
80
+ "ErpcClientConfig(api_key='[REDACTED]', "
81
+ f"endpoint={self.endpoint!r}, account_endpoint={self.account_endpoint!r}, "
82
+ f"user_endpoint={self.user_endpoint!r}, "
83
+ f"header_names={list(self.headers)!r}, timeout={self.timeout!r})"
84
+ )
85
+
86
+
87
+ @dataclass(frozen=True, repr=False, slots=True)
88
+ class ErpcCloudClientConfig:
89
+ """Configuration for scoped Cloud reads."""
90
+
91
+ access_token: str
92
+ endpoint: str = DEFAULT_USER_ENDPOINT
93
+ headers: Mapping[str, str] = field(default_factory=dict)
94
+ timeout: float = DEFAULT_TIMEOUT
95
+
96
+ def __post_init__(self) -> None:
97
+ token = self.access_token.strip()
98
+ if not token:
99
+ raise ErpcConfigError("access_token must not be empty")
100
+ if not isinstance(self.timeout, (int, float)) or self.timeout <= 0:
101
+ raise ErpcConfigError("timeout must be positive")
102
+ object.__setattr__(self, "access_token", token)
103
+ object.__setattr__(
104
+ self, "endpoint", normalize_endpoint(self.endpoint, local_http_only=True)
105
+ )
106
+ object.__setattr__(self, "headers", dict(self.headers))
107
+ object.__setattr__(self, "timeout", float(self.timeout))
108
+
109
+ def __repr__(self) -> str:
110
+ return (
111
+ "ErpcCloudClientConfig(access_token='[REDACTED]', "
112
+ f"endpoint={self.endpoint!r}, header_names={list(self.headers)!r}, "
113
+ f"timeout={self.timeout!r})"
114
+ )
erpc_sdk/errors.py ADDED
@@ -0,0 +1,110 @@
1
+ """Stable, credential-safe error types for the ERPC Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+ from urllib.parse import quote, quote_plus
7
+
8
+
9
+ class ErpcErrorCode(StrEnum):
10
+ """Stable machine-readable SDK error categories."""
11
+
12
+ ABORTED = "ERPC_ABORTED"
13
+ BATCH_POLICY = "ERPC_BATCH_POLICY"
14
+ CONFIG = "ERPC_CONFIG"
15
+ HTTP = "ERPC_HTTP"
16
+ INVALID_RESPONSE = "ERPC_INVALID_RESPONSE"
17
+ RPC = "ERPC_RPC"
18
+ TIMEOUT = "ERPC_TIMEOUT"
19
+ TRANSPORT = "ERPC_TRANSPORT"
20
+
21
+
22
+ class ErpcError(Exception):
23
+ """Base class for all SDK errors."""
24
+
25
+ code: ErpcErrorCode
26
+
27
+ def __init__(self, code: ErpcErrorCode, message: str) -> None:
28
+ super().__init__(message)
29
+ self.code = code
30
+
31
+
32
+ class ErpcConfigError(ErpcError):
33
+ def __init__(self, message: str) -> None:
34
+ super().__init__(ErpcErrorCode.CONFIG, message)
35
+
36
+
37
+ class ErpcTransportError(ErpcError):
38
+ def __init__(self, message: str = "Unable to reach ERPC") -> None:
39
+ super().__init__(ErpcErrorCode.TRANSPORT, message)
40
+
41
+
42
+ class ErpcHttpError(ErpcError):
43
+ status: int
44
+
45
+ def __init__(self, status: int) -> None:
46
+ self.status = status
47
+ super().__init__(ErpcErrorCode.HTTP, f"ERPC request failed with HTTP {status}")
48
+
49
+
50
+ class ErpcTimeoutError(ErpcError):
51
+ timeout: float
52
+
53
+ def __init__(self, timeout: float) -> None:
54
+ self.timeout = timeout
55
+ super().__init__(
56
+ ErpcErrorCode.TIMEOUT,
57
+ f"ERPC request timed out after {round(timeout * 1000)}ms",
58
+ )
59
+
60
+
61
+ class ErpcAbortedError(ErpcError):
62
+ def __init__(self) -> None:
63
+ super().__init__(ErpcErrorCode.ABORTED, "ERPC request was aborted")
64
+
65
+
66
+ class ErpcInvalidResponseError(ErpcError):
67
+ def __init__(self, message: str = "ERPC returned an invalid response") -> None:
68
+ super().__init__(ErpcErrorCode.INVALID_RESPONSE, message)
69
+
70
+
71
+ class ErpcBatchPolicyError(ErpcError):
72
+ def __init__(self, message: str) -> None:
73
+ super().__init__(ErpcErrorCode.BATCH_POLICY, message)
74
+
75
+
76
+ class ErpcJsonRpcError(ErpcError):
77
+ """A JSON-RPC error with credential-redacted message and data."""
78
+
79
+ rpc_code: int
80
+ data: object | None
81
+
82
+ def __init__(self, rpc_code: int, message: str, data: object | None = None) -> None:
83
+ self.rpc_code = rpc_code
84
+ self.data = data
85
+ super().__init__(ErpcErrorCode.RPC, message)
86
+
87
+
88
+ def credential_variants(credential: str) -> tuple[str, ...]:
89
+ values = {credential, quote(credential, safe="~()*!.'"), quote_plus(credential)}
90
+ return tuple(sorted((value for value in values if value), key=len, reverse=True))
91
+
92
+
93
+ def redact_text(value: str, credential: str) -> str:
94
+ for variant in credential_variants(credential):
95
+ value = value.replace(variant, "[REDACTED]")
96
+ return value
97
+
98
+
99
+ def redact_value(value: object, credential: str, depth: int = 0) -> object:
100
+ if depth >= 32:
101
+ return "[REDACTED]"
102
+ if isinstance(value, str):
103
+ return redact_text(value, credential)
104
+ if isinstance(value, list):
105
+ return [redact_value(item, credential, depth + 1) for item in value]
106
+ if isinstance(value, tuple):
107
+ return tuple(redact_value(item, credential, depth + 1) for item in value)
108
+ if isinstance(value, dict):
109
+ return {key: redact_value(item, credential, depth + 1) for key, item in value.items()}
110
+ return value
erpc_sdk/py.typed ADDED
@@ -0,0 +1 @@
1
+