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 +108 -0
- erpc_sdk/client.py +213 -0
- erpc_sdk/config.py +114 -0
- erpc_sdk/errors.py +110 -0
- erpc_sdk/py.typed +1 -0
- erpc_sdk/rest.py +486 -0
- erpc_sdk/rpc.py +291 -0
- erpc_sdk/subscriptions.py +351 -0
- erpc_sdk/transport.py +316 -0
- erpc_sdk/types.py +180 -0
- erpc_sdk-0.3.0.dist-info/METADATA +152 -0
- erpc_sdk-0.3.0.dist-info/RECORD +14 -0
- erpc_sdk-0.3.0.dist-info/WHEEL +4 -0
- erpc_sdk-0.3.0.dist-info/licenses/LICENSE +21 -0
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
|
+
|