solinpy 0.1.3__tar.gz
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.
- solinpy-0.1.3/PKG-INFO +9 -0
- solinpy-0.1.3/README.md +144 -0
- solinpy-0.1.3/pyproject.toml +29 -0
- solinpy-0.1.3/setup.cfg +4 -0
- solinpy-0.1.3/solinpy/__init__.py +0 -0
- solinpy-0.1.3/solinpy/client/__init__.py +0 -0
- solinpy-0.1.3/solinpy/client/client.py +190 -0
- solinpy-0.1.3/solinpy/client/entities.py +37 -0
- solinpy-0.1.3/solinpy/client/execptions.py +130 -0
- solinpy-0.1.3/solinpy/client/test_client.py +164 -0
- solinpy-0.1.3/solinpy/tests/test_account_decoder.py +304 -0
- solinpy-0.1.3/solinpy/tests/test_airdrop.py +188 -0
- solinpy-0.1.3/solinpy/tests/test_devnet_integration.py +248 -0
- solinpy-0.1.3/solinpy/tests/test_wallet.py +35 -0
- solinpy-0.1.3/solinpy/transaction/__init__.py +0 -0
- solinpy-0.1.3/solinpy/transaction/token.py +87 -0
- solinpy-0.1.3/solinpy/utils/__init__.py +4 -0
- solinpy-0.1.3/solinpy/utils/account_decoder.py +396 -0
- solinpy-0.1.3/solinpy/utils/airdrop.py +107 -0
- solinpy-0.1.3/solinpy/wallet/__init__.py +0 -0
- solinpy-0.1.3/solinpy/wallet/manager.py +46 -0
- solinpy-0.1.3/solinpy/wallet/mnemonic.py +53 -0
- solinpy-0.1.3/solinpy.egg-info/PKG-INFO +9 -0
- solinpy-0.1.3/solinpy.egg-info/SOURCES.txt +25 -0
- solinpy-0.1.3/solinpy.egg-info/dependency_links.txt +1 -0
- solinpy-0.1.3/solinpy.egg-info/requires.txt +3 -0
- solinpy-0.1.3/solinpy.egg-info/top_level.txt +1 -0
solinpy-0.1.3/PKG-INFO
ADDED
solinpy-0.1.3/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# 🤝 Contributing Guide — SolInPy
|
|
2
|
+
|
|
3
|
+
Python Framework for Solana focused on developer experience and simplicity.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 📌 Features (MVP)
|
|
8
|
+
|
|
9
|
+
- RPC client
|
|
10
|
+
- Wallet management
|
|
11
|
+
- Transactions
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 📦 Development Setup
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
git clone https://github.com/your-org/solinpy.git
|
|
19
|
+
cd solinpy
|
|
20
|
+
|
|
21
|
+
python -m venv .venv
|
|
22
|
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
23
|
+
|
|
24
|
+
pip install -U pip
|
|
25
|
+
pip install -r requirements.txt
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 🧹 Code Quality Rules
|
|
31
|
+
|
|
32
|
+
This project enforces strict quality standards:
|
|
33
|
+
|
|
34
|
+
- Type checking: `mypy`
|
|
35
|
+
- Linting: `ruff`
|
|
36
|
+
- Testing: `pytest`
|
|
37
|
+
|
|
38
|
+
All contributions must pass CI before merge.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## 🚀 Running Checks Locally
|
|
43
|
+
|
|
44
|
+
### Lint
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
python -m ruff check .
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Format
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
python -m ruff format .
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Type Check
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
python -m mypy solinpy
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Tests
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pytest
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## 🔁 Recommended Workflow
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
git checkout -b feature/my-feature
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Make changes, then:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
git add .
|
|
80
|
+
git commit -m "feat: add new feature"
|
|
81
|
+
git push origin feature/my-feature
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Open a Pull Request.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## ⚠️ Pull Request Requirements
|
|
89
|
+
|
|
90
|
+
A PR will only be merged if:
|
|
91
|
+
|
|
92
|
+
- [ ] Ruff passes
|
|
93
|
+
- [ ] MyPy passes
|
|
94
|
+
- [ ] Tests pass
|
|
95
|
+
- [ ] Code is documented where necessary
|
|
96
|
+
- [ ] No untyped functions introduced
|
|
97
|
+
- [ ] No breaking changes without discussion
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## 🧪 Testing Philosophy
|
|
102
|
+
|
|
103
|
+
- Unit tests for core logic
|
|
104
|
+
- Integration tests for RPC interactions
|
|
105
|
+
- Avoid mainnet dependency in tests
|
|
106
|
+
- Use devnet only for integration tests
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## 📂 Project Structure
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
solinpy/
|
|
114
|
+
├── solinpy/
|
|
115
|
+
│ ├── client/
|
|
116
|
+
│ ├── wallet/
|
|
117
|
+
│ ├── transaction/
|
|
118
|
+
│ └── utils/
|
|
119
|
+
├── tests/
|
|
120
|
+
├── examples/
|
|
121
|
+
└── docs/
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## 🧠 Engineering Philosophy
|
|
127
|
+
|
|
128
|
+
SolInPy is built on:
|
|
129
|
+
|
|
130
|
+
- Simplicity over complexity
|
|
131
|
+
- Developer experience first
|
|
132
|
+
- Predictable APIs
|
|
133
|
+
- Strong typing
|
|
134
|
+
- Reliability by design
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## 🚀 Next Steps (Roadmap)
|
|
139
|
+
|
|
140
|
+
- RPC abstraction improvements
|
|
141
|
+
- Transaction builder expansion
|
|
142
|
+
- SPL token support
|
|
143
|
+
- Better error mapping
|
|
144
|
+
- CLI tooling (future)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "solinpy"
|
|
3
|
+
version = "0.1.3"
|
|
4
|
+
description = "High-performance Python SDK for Solana"
|
|
5
|
+
authors = [{ name = "Equipe Carcaras" }]
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"solders>=0.27.1",
|
|
9
|
+
"mnemonic>=0.21",
|
|
10
|
+
"requests>=2.31.0"
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[tool.setuptools]
|
|
14
|
+
packages = { find = { include = ["solinpy*"] } }
|
|
15
|
+
|
|
16
|
+
[tool.mypy]
|
|
17
|
+
ignore_missing_imports = true
|
|
18
|
+
check_untyped_defs = false
|
|
19
|
+
disallow_untyped_defs = false
|
|
20
|
+
warn_return_any = false
|
|
21
|
+
no_implicit_optional = true
|
|
22
|
+
|
|
23
|
+
[[tool.mypy.overrides]]
|
|
24
|
+
module = "solders.*"
|
|
25
|
+
ignore_missing_imports = true
|
|
26
|
+
|
|
27
|
+
[[tool.mypy.overrides]]
|
|
28
|
+
module = "mnemonic.*"
|
|
29
|
+
ignore_missing_imports = true
|
solinpy-0.1.3/setup.cfg
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import random
|
|
3
|
+
import time
|
|
4
|
+
import urllib.request
|
|
5
|
+
import urllib.error
|
|
6
|
+
from typing import Optional, Dict, Any
|
|
7
|
+
from solinpy.client.entities import RPCConfig
|
|
8
|
+
from solinpy.client.execptions import RPCError
|
|
9
|
+
|
|
10
|
+
class SolanaRPCClient:
|
|
11
|
+
def __init__(self, config: Optional[RPCConfig | str] = None):
|
|
12
|
+
if isinstance(config, str):
|
|
13
|
+
self.cfg = RPCConfig(custom_endpoint=config)
|
|
14
|
+
else:
|
|
15
|
+
self.cfg = config or RPCConfig()
|
|
16
|
+
|
|
17
|
+
self.endpoint = self.cfg.custom_endpoint or self._resolve_cluster_url()
|
|
18
|
+
self._request_id = 0
|
|
19
|
+
|
|
20
|
+
def _resolve_cluster_url(self) -> str:
|
|
21
|
+
urls = {
|
|
22
|
+
"devnet": "https://api.devnet.solana.com",
|
|
23
|
+
"testnet": "https://api.testnet.solana.com",
|
|
24
|
+
"mainnet": "https://api.mainnet-beta.solana.com",
|
|
25
|
+
}
|
|
26
|
+
return urls.get(self.cfg.cluster, urls["devnet"])
|
|
27
|
+
|
|
28
|
+
def _calc_backoff(self, attempt: int) -> float:
|
|
29
|
+
delay = min(self.cfg.base_delay * (2**attempt), self.cfg.max_delay)
|
|
30
|
+
jitter = random.uniform(0, delay * 0.5)
|
|
31
|
+
return delay + jitter
|
|
32
|
+
|
|
33
|
+
def _is_retryable(self, exc: Exception, rpc_error: Optional[dict] = None) -> bool:
|
|
34
|
+
if isinstance(exc, urllib.error.HTTPError):
|
|
35
|
+
return exc.code in self.cfg.retryable_http_codes
|
|
36
|
+
if isinstance(exc, (urllib.error.URLError, ConnectionError, OSError, TimeoutError)):
|
|
37
|
+
return True
|
|
38
|
+
if rpc_error and rpc_error.get("code") in self.cfg.retryable_rpc_codes:
|
|
39
|
+
return True
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
def _raise_rpc_error(self, method: str, rpc_error: dict, context: Optional[Dict[str, Any]] = None) -> None:
|
|
43
|
+
raise RPCError.from_rpc_error(method, rpc_error, context=context)
|
|
44
|
+
|
|
45
|
+
def _call(
|
|
46
|
+
self,
|
|
47
|
+
method: str,
|
|
48
|
+
params: Optional[list] = None,
|
|
49
|
+
context: Optional[Dict[str, Any]] = None,
|
|
50
|
+
) -> Dict[str, Any]:
|
|
51
|
+
self._request_id += 1
|
|
52
|
+
payload = {
|
|
53
|
+
"jsonrpc": "2.0",
|
|
54
|
+
"id": self._request_id,
|
|
55
|
+
"method": method,
|
|
56
|
+
"params": params or [],
|
|
57
|
+
}
|
|
58
|
+
data = json.dumps(payload).encode("utf-8")
|
|
59
|
+
|
|
60
|
+
req = urllib.request.Request(
|
|
61
|
+
self.endpoint, data=data, headers={"Content-Type": "application/json"}, method="POST"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
last_exc = None
|
|
65
|
+
for attempt in range(self.cfg.max_retries + 1):
|
|
66
|
+
try:
|
|
67
|
+
with urllib.request.urlopen(req, timeout=self.cfg.timeout) as resp:
|
|
68
|
+
body = json.loads(resp.read())
|
|
69
|
+
if "error" in body:
|
|
70
|
+
err = body["error"]
|
|
71
|
+
if self._is_retryable(None, rpc_error=err):
|
|
72
|
+
if attempt < self.cfg.max_retries:
|
|
73
|
+
time.sleep(self._calc_backoff(attempt))
|
|
74
|
+
continue
|
|
75
|
+
self._raise_rpc_error(method, err, context=context)
|
|
76
|
+
return body
|
|
77
|
+
|
|
78
|
+
except urllib.error.HTTPError as e:
|
|
79
|
+
last_exc = e
|
|
80
|
+
if self._is_retryable(e) and attempt < self.cfg.max_retries:
|
|
81
|
+
time.sleep(self._calc_backoff(attempt))
|
|
82
|
+
continue
|
|
83
|
+
raise RPCError.from_transport_error(
|
|
84
|
+
method,
|
|
85
|
+
e,
|
|
86
|
+
context={**(context or {}), "endpoint": self.endpoint},
|
|
87
|
+
message=f"Falha HTTP ao executar {method}. Verifique o endpoint configurado.",
|
|
88
|
+
) from e
|
|
89
|
+
except (urllib.error.URLError, ConnectionError, OSError, TimeoutError) as e:
|
|
90
|
+
last_exc = e
|
|
91
|
+
if self._is_retryable(e) and attempt < self.cfg.max_retries:
|
|
92
|
+
time.sleep(self._calc_backoff(attempt))
|
|
93
|
+
continue
|
|
94
|
+
break
|
|
95
|
+
except Exception as e:
|
|
96
|
+
last_exc = e
|
|
97
|
+
raise RPCError.from_transport_error(
|
|
98
|
+
method,
|
|
99
|
+
e,
|
|
100
|
+
context={**(context or {}), "endpoint": self.endpoint},
|
|
101
|
+
message=f"Falha inesperada ao executar {method}.",
|
|
102
|
+
) from e
|
|
103
|
+
raise RPCError(
|
|
104
|
+
f"Falha de comunicação ao executar {method} após {self.cfg.max_retries + 1} tentativas.",
|
|
105
|
+
method=method,
|
|
106
|
+
context={**(context or {}), "endpoint": self.endpoint},
|
|
107
|
+
cause=last_exc,
|
|
108
|
+
) from last_exc
|
|
109
|
+
|
|
110
|
+
def get_health(self) -> str:
|
|
111
|
+
return self._call("getHealth")["result"]
|
|
112
|
+
|
|
113
|
+
def get_latest_blockhash(self, commitment: str = "confirmed") -> str:
|
|
114
|
+
resp = self._call("getLatestBlockhash", [{"commitment": commitment}], {"commitment": commitment})
|
|
115
|
+
return resp["result"]["value"]["blockhash"]
|
|
116
|
+
|
|
117
|
+
def send_transaction(self, tx_base64: str, max_retries: int = 5) -> str:
|
|
118
|
+
resp = self._call(
|
|
119
|
+
"sendTransaction",
|
|
120
|
+
[tx_base64, {
|
|
121
|
+
"encoding": "base64",
|
|
122
|
+
"maxRetries": max_retries
|
|
123
|
+
}],
|
|
124
|
+
{"tx_size": len(tx_base64), "max_retries": max_retries},
|
|
125
|
+
)
|
|
126
|
+
return resp["result"]
|
|
127
|
+
|
|
128
|
+
def get_balance(self, address: str) -> int:
|
|
129
|
+
"""
|
|
130
|
+
Returns the account balance in Lamports.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
address: The base58-encoded public key of the account.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
int: The balance in Lamports.
|
|
137
|
+
"""
|
|
138
|
+
sanitized_address = address.strip()
|
|
139
|
+
resp = self._call("getBalance", [sanitized_address], {"address": sanitized_address})
|
|
140
|
+
return resp["result"]["value"]
|
|
141
|
+
|
|
142
|
+
def get_token_accounts_by_owner(self, address: str) -> list[Dict[str, Any]]:
|
|
143
|
+
"""
|
|
144
|
+
Returns the list of SPL token accounts associated with an address.
|
|
145
|
+
"""
|
|
146
|
+
TOKEN_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
|
|
147
|
+
|
|
148
|
+
sanitized_address = address.strip()
|
|
149
|
+
|
|
150
|
+
params = [
|
|
151
|
+
sanitized_address,
|
|
152
|
+
{"programId": TOKEN_PROGRAM_ID},
|
|
153
|
+
{"encoding": "jsonParsed", "commitment": "confirmed"} # Adicionado o commitment
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
resp = self._call("getTokenAccountsByOwner", params, {"address": sanitized_address})
|
|
157
|
+
return resp["result"]["value"]
|
|
158
|
+
|
|
159
|
+
def get_sol_balance(self, address: str) -> float:
|
|
160
|
+
"""
|
|
161
|
+
Returns the account balance converted to SOL (user-friendly unit).
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
address: The base58-encoded public key of the account.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
float: The balance in SOL.
|
|
168
|
+
"""
|
|
169
|
+
lamports = self.get_balance(address)
|
|
170
|
+
# 1 SOL is equivalent to 1,000,000,000 Lamports
|
|
171
|
+
return lamports / 1_000_000_000
|
|
172
|
+
|
|
173
|
+
def get_token_balances(self, address: str) -> list[dict]:
|
|
174
|
+
"""
|
|
175
|
+
Retrieves a simplified list of token balances for a given address.
|
|
176
|
+
"""
|
|
177
|
+
raw_accounts = self.get_token_accounts_by_owner(address)
|
|
178
|
+
balances = []
|
|
179
|
+
|
|
180
|
+
for account in raw_accounts:
|
|
181
|
+
info = account["account"]["data"]["parsed"]["info"]
|
|
182
|
+
token_amount = info["tokenAmount"]
|
|
183
|
+
|
|
184
|
+
balances.append({
|
|
185
|
+
"mint": info["mint"],
|
|
186
|
+
"amount": token_amount["uiAmount"],
|
|
187
|
+
"decimals": token_amount["decimals"]
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
return balances
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class RPCConfig:
|
|
7
|
+
"""Configuração para o cliente RPC Solana."""
|
|
8
|
+
cluster: str = "devnet"
|
|
9
|
+
custom_endpoint: Optional[str] = None
|
|
10
|
+
timeout: float = 10.0
|
|
11
|
+
max_retries: int = 3
|
|
12
|
+
base_delay: float = 1.0
|
|
13
|
+
max_delay: float = 30.0
|
|
14
|
+
retryable_http_codes: tuple[int, ...] = field(
|
|
15
|
+
default_factory=lambda: (429, 500, 502, 503, 504)
|
|
16
|
+
)
|
|
17
|
+
retryable_rpc_codes: tuple[int, ...] = field(default_factory=lambda: (-32004, -32005))
|
|
18
|
+
retries: Optional[int] = None
|
|
19
|
+
backoff_factor: Optional[float] = None
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
if self.retries is not None:
|
|
23
|
+
self.max_retries = self.retries
|
|
24
|
+
else:
|
|
25
|
+
self.retries = self.max_retries
|
|
26
|
+
|
|
27
|
+
if self.backoff_factor is not None:
|
|
28
|
+
self.base_delay = self.backoff_factor
|
|
29
|
+
else:
|
|
30
|
+
self.backoff_factor = self.base_delay
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class TokenBalance:
|
|
34
|
+
mint: str
|
|
35
|
+
amount: float
|
|
36
|
+
decimals: int
|
|
37
|
+
ui_amount: str
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _format_context(context: Mapping[str, Any] | None) -> str:
|
|
7
|
+
if not context:
|
|
8
|
+
return ""
|
|
9
|
+
|
|
10
|
+
parts = [f"{key}={value}" for key, value in context.items() if value is not None]
|
|
11
|
+
return ", ".join(parts)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _friendly_rpc_message(method: str, code: Optional[int], message: str) -> str:
|
|
15
|
+
normalized = message.lower()
|
|
16
|
+
|
|
17
|
+
if code == -32600 or "invalid request" in normalized:
|
|
18
|
+
return "Requisição RPC inválida. Verifique os parâmetros enviados."
|
|
19
|
+
|
|
20
|
+
if code == -32601 or "method not found" in normalized:
|
|
21
|
+
return f"Método RPC desconhecido em {method}. Verifique o nome da operação."
|
|
22
|
+
|
|
23
|
+
if code == -32602 or any(
|
|
24
|
+
term in normalized for term in ("invalid params", "invalid parameter", "invalid argument")
|
|
25
|
+
):
|
|
26
|
+
return f"Parâmetros inválidos ao executar {method}. Revise os valores enviados."
|
|
27
|
+
|
|
28
|
+
if any(term in normalized for term in ("insufficient funds", "insufficient balance")):
|
|
29
|
+
return (
|
|
30
|
+
f"Saldo insuficiente ao executar {method}. "
|
|
31
|
+
"Verifique se a conta tem SOL suficiente para taxas e valor da operação."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if any(
|
|
35
|
+
term in normalized
|
|
36
|
+
for term in (
|
|
37
|
+
"account not found",
|
|
38
|
+
"invalid account",
|
|
39
|
+
"could not find account",
|
|
40
|
+
"failed to get account info",
|
|
41
|
+
"account does not exist",
|
|
42
|
+
)
|
|
43
|
+
):
|
|
44
|
+
return f"Conta inválida ou inexistente ao executar {method}. Verifique o endereço informado."
|
|
45
|
+
|
|
46
|
+
if any(term in normalized for term in ("blockhash not found", "blockhash expired")):
|
|
47
|
+
return f"Blockhash expirado ou inválido ao executar {method}. Busque um blockhash novo e tente novamente."
|
|
48
|
+
|
|
49
|
+
if code in (-32004, -32005):
|
|
50
|
+
return f"Serviço RPC temporariamente indisponível ao executar {method}. Tente novamente em instantes."
|
|
51
|
+
|
|
52
|
+
if message:
|
|
53
|
+
return f"Erro RPC ao executar {method}: {message}"
|
|
54
|
+
|
|
55
|
+
return f"Erro RPC ao executar {method}."
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RPCError(Exception):
|
|
59
|
+
"""Erro de RPC com mensagem amigável e contexto estruturado."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
message: str,
|
|
64
|
+
*,
|
|
65
|
+
code: Optional[int] = None,
|
|
66
|
+
method: Optional[str] = None,
|
|
67
|
+
context: Optional[Mapping[str, Any]] = None,
|
|
68
|
+
cause: Optional[BaseException] = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
super().__init__(message)
|
|
71
|
+
self.message = message
|
|
72
|
+
self.code = code
|
|
73
|
+
self.method = method
|
|
74
|
+
self.context = dict(context or {})
|
|
75
|
+
self.cause = cause
|
|
76
|
+
|
|
77
|
+
def __str__(self) -> str:
|
|
78
|
+
details: list[str] = []
|
|
79
|
+
if self.code is not None:
|
|
80
|
+
details.append(f"código RPC={self.code}")
|
|
81
|
+
if self.method is not None:
|
|
82
|
+
details.append(f"método={self.method}")
|
|
83
|
+
|
|
84
|
+
context_text = _format_context(self.context)
|
|
85
|
+
if context_text:
|
|
86
|
+
details.append(f"contexto={context_text}")
|
|
87
|
+
|
|
88
|
+
if self.cause is not None:
|
|
89
|
+
details.append(f"causa={self.cause}")
|
|
90
|
+
|
|
91
|
+
if not details:
|
|
92
|
+
return self.message
|
|
93
|
+
|
|
94
|
+
return f"{self.message} [{' | '.join(details)}]"
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def from_rpc_error(
|
|
98
|
+
cls,
|
|
99
|
+
method: str,
|
|
100
|
+
rpc_error: Mapping[str, Any],
|
|
101
|
+
*,
|
|
102
|
+
context: Optional[Mapping[str, Any]] = None,
|
|
103
|
+
) -> "RPCError":
|
|
104
|
+
code = rpc_error.get("code")
|
|
105
|
+
raw_message = str(rpc_error.get("message") or "")
|
|
106
|
+
friendly_message = _friendly_rpc_message(method, code if isinstance(code, int) else None, raw_message)
|
|
107
|
+
|
|
108
|
+
extra_details = rpc_error.get("data")
|
|
109
|
+
merged_context: dict[str, Any] = dict(context or {})
|
|
110
|
+
if extra_details is not None:
|
|
111
|
+
merged_context["rpc_data"] = extra_details
|
|
112
|
+
|
|
113
|
+
return cls(
|
|
114
|
+
friendly_message,
|
|
115
|
+
code=code if isinstance(code, int) else None,
|
|
116
|
+
method=method,
|
|
117
|
+
context=merged_context,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def from_transport_error(
|
|
122
|
+
cls,
|
|
123
|
+
method: str,
|
|
124
|
+
error: BaseException,
|
|
125
|
+
*,
|
|
126
|
+
context: Optional[Mapping[str, Any]] = None,
|
|
127
|
+
message: Optional[str] = None,
|
|
128
|
+
) -> "RPCError":
|
|
129
|
+
friendly_message = message or f"Falha de comunicação ao executar {method}. Verifique o endpoint e tente novamente."
|
|
130
|
+
return cls(friendly_message, method=method, context=context, cause=error)
|