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 ADDED
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: solinpy
3
+ Version: 0.1.3
4
+ Summary: High-performance Python SDK for Solana
5
+ Author: Equipe Carcaras
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: solders>=0.27.1
8
+ Requires-Dist: mnemonic>=0.21
9
+ Requires-Dist: requests>=2.31.0
@@ -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
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
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)