healchain-sdk 0.1.0__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.
- healchain_sdk-0.1.0/LICENSE (1) +21 -0
- healchain_sdk-0.1.0/LICENSE (1):Zone.Identifier +0 -0
- healchain_sdk-0.1.0/PKG-INFO +29 -0
- healchain_sdk-0.1.0/healchain/__init__.py +18 -0
- healchain_sdk-0.1.0/healchain/client.py +354 -0
- healchain_sdk-0.1.0/healchain_sdk.egg-info/PKG-INFO +29 -0
- healchain_sdk-0.1.0/healchain_sdk.egg-info/SOURCES.txt +11 -0
- healchain_sdk-0.1.0/healchain_sdk.egg-info/dependency_links.txt +1 -0
- healchain_sdk-0.1.0/healchain_sdk.egg-info/requires.txt +4 -0
- healchain_sdk-0.1.0/healchain_sdk.egg-info/top_level.txt +1 -0
- healchain_sdk-0.1.0/pyproject.toml +42 -0
- healchain_sdk-0.1.0/setup.cfg +4 -0
- healchain_sdk-0.1.0/tests/test_client.py +219 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 HealChain
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
Binary file
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: healchain-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for HealChain — self-healing decentralized storage via Reed-Solomon erasure coding
|
|
5
|
+
Author-email: HealChain Team <info@healchain.org>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://healchain.org
|
|
8
|
+
Project-URL: Repository, https://github.com/karmaxul/ci-quantum-storage
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/karmaxul/ci-quantum-storage/issues
|
|
10
|
+
Keywords: healchain,blockchain,storage,reed-solomon,erasure-coding,ethereum,arbitrum,decentralized
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Internet
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.8
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE (1)
|
|
25
|
+
License-File: LICENSE (1):Zone.Identifier
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-mock; extra == "dev"
|
|
29
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HealChain Python SDK
|
|
3
|
+
Self-healing decentralized storage via Reed-Solomon erasure coding.
|
|
4
|
+
|
|
5
|
+
Quick start::
|
|
6
|
+
|
|
7
|
+
from healchain import HealChain
|
|
8
|
+
|
|
9
|
+
hc = HealChain(api_url="https://api.healchain.org")
|
|
10
|
+
result = hc.store("Hello World", label="my record")
|
|
11
|
+
record = hc.retrieve(result["record_id"])
|
|
12
|
+
print(record["text"]) # Hello World
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .client import HealChain, HealChainError
|
|
16
|
+
|
|
17
|
+
__all__ = ["HealChain", "HealChainError"]
|
|
18
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HealChain Python SDK v0.1.0
|
|
3
|
+
REST API client for HealChain — self-healing decentralized storage.
|
|
4
|
+
|
|
5
|
+
Works with Python 3.8+ using only the standard library (urllib).
|
|
6
|
+
Optional: install `requests` for a more ergonomic HTTP experience.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
import urllib.request
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.parse
|
|
16
|
+
from typing import Any, Callable, Optional, Union
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ── Constants ─────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
DEFAULT_API_URL = "https://api.healchain.org"
|
|
22
|
+
DEFAULT_POLL_INTERVAL = 4.0 # seconds between fulfillment polls
|
|
23
|
+
DEFAULT_POLL_TIMEOUT = 120.0 # seconds before giving up
|
|
24
|
+
DEFAULT_DATA_SHARDS = 10
|
|
25
|
+
DEFAULT_PARITY_SHARDS = 4
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── Exceptions ────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
class HealChainError(Exception):
|
|
31
|
+
"""Raised on any HealChain API or network error."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
message: str,
|
|
36
|
+
*,
|
|
37
|
+
status: Optional[int] = None,
|
|
38
|
+
code: Optional[str] = None,
|
|
39
|
+
response: Any = None,
|
|
40
|
+
):
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
self.status = status
|
|
43
|
+
self.code = code
|
|
44
|
+
self.response = response
|
|
45
|
+
|
|
46
|
+
def __repr__(self) -> str:
|
|
47
|
+
return (
|
|
48
|
+
f"HealChainError({str(self)!r}, "
|
|
49
|
+
f"status={self.status}, code={self.code!r})"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
def _to_hex(data: Union[str, bytes, bytearray]) -> str:
|
|
56
|
+
"""Convert string or bytes to 0x-prefixed hex."""
|
|
57
|
+
if isinstance(data, str):
|
|
58
|
+
if data.startswith("0x"):
|
|
59
|
+
return data
|
|
60
|
+
return "0x" + data.encode("utf-8").hex()
|
|
61
|
+
if isinstance(data, (bytes, bytearray)):
|
|
62
|
+
return "0x" + data.hex()
|
|
63
|
+
raise HealChainError(
|
|
64
|
+
f"store() data must be str or bytes, got {type(data).__name__}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ── Main client ───────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
class HealChain:
|
|
71
|
+
"""
|
|
72
|
+
HealChain API client.
|
|
73
|
+
|
|
74
|
+
Example::
|
|
75
|
+
|
|
76
|
+
from healchain import HealChain
|
|
77
|
+
|
|
78
|
+
hc = HealChain(api_url="https://api.healchain.org")
|
|
79
|
+
|
|
80
|
+
# Store data
|
|
81
|
+
result = hc.store("Hello World", label="my record")
|
|
82
|
+
print(f"Stored as record #{result['record_id']} on chain {result['chain_id']}")
|
|
83
|
+
|
|
84
|
+
# Retrieve — auto-discovers chain from GlobalRegistry
|
|
85
|
+
record = hc.retrieve(result["record_id"])
|
|
86
|
+
print(record["text"])
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
api_url: str = DEFAULT_API_URL,
|
|
92
|
+
*,
|
|
93
|
+
api_key: Optional[str] = None,
|
|
94
|
+
network: str = "sepolia",
|
|
95
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
96
|
+
poll_timeout: float = DEFAULT_POLL_TIMEOUT,
|
|
97
|
+
data_shards: int = DEFAULT_DATA_SHARDS,
|
|
98
|
+
parity_shards: int = DEFAULT_PARITY_SHARDS,
|
|
99
|
+
timeout: float = 30.0,
|
|
100
|
+
):
|
|
101
|
+
"""
|
|
102
|
+
Args:
|
|
103
|
+
api_url: API base URL (default: https://api.healchain.org)
|
|
104
|
+
api_key: Optional API key
|
|
105
|
+
network: Default network: 'sepolia' or 'arbitrum'
|
|
106
|
+
poll_interval: Seconds between oracle fulfillment polls
|
|
107
|
+
poll_timeout: Max seconds to wait for oracle fulfillment
|
|
108
|
+
data_shards: Default RS data shards
|
|
109
|
+
parity_shards: Default RS parity shards
|
|
110
|
+
timeout: HTTP request timeout in seconds
|
|
111
|
+
"""
|
|
112
|
+
self.api_url = api_url.rstrip("/")
|
|
113
|
+
self.api_key = api_key
|
|
114
|
+
self.network = network
|
|
115
|
+
self.poll_interval = poll_interval
|
|
116
|
+
self.poll_timeout = poll_timeout
|
|
117
|
+
self.data_shards = data_shards
|
|
118
|
+
self.parity_shards = parity_shards
|
|
119
|
+
self.timeout = timeout
|
|
120
|
+
|
|
121
|
+
# ── Internal request ──────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
def _request(
|
|
124
|
+
self,
|
|
125
|
+
method: str,
|
|
126
|
+
path: str,
|
|
127
|
+
*,
|
|
128
|
+
body: Optional[dict] = None,
|
|
129
|
+
) -> Any:
|
|
130
|
+
url = f"{self.api_url}{path}"
|
|
131
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
132
|
+
|
|
133
|
+
headers = {
|
|
134
|
+
"Content-Type": "application/json",
|
|
135
|
+
"Accept": "application/json",
|
|
136
|
+
"User-Agent": "healchain-sdk-python/0.1.0",
|
|
137
|
+
}
|
|
138
|
+
if self.api_key:
|
|
139
|
+
headers["X-API-Key"] = self.api_key
|
|
140
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
144
|
+
raw = resp.read()
|
|
145
|
+
return json.loads(raw) if raw else {}
|
|
146
|
+
except urllib.error.HTTPError as exc:
|
|
147
|
+
raw = exc.read()
|
|
148
|
+
try:
|
|
149
|
+
body_json = json.loads(raw)
|
|
150
|
+
msg = body_json.get("error", str(exc))
|
|
151
|
+
except Exception:
|
|
152
|
+
msg = raw.decode(errors="replace") or str(exc)
|
|
153
|
+
raise HealChainError(msg, status=exc.code, response=body_json if raw else None)
|
|
154
|
+
except urllib.error.URLError as exc:
|
|
155
|
+
raise HealChainError(
|
|
156
|
+
f"Network error: {exc.reason}", code="NETWORK_ERROR"
|
|
157
|
+
) from exc
|
|
158
|
+
|
|
159
|
+
# ── health() ──────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
def health(self) -> dict:
|
|
162
|
+
"""
|
|
163
|
+
Check service health.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
dict with keys: status, version, geth, last_block
|
|
167
|
+
"""
|
|
168
|
+
return self._request("GET", "/health")
|
|
169
|
+
|
|
170
|
+
# ── store() ───────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
def store(
|
|
173
|
+
self,
|
|
174
|
+
data: Union[str, bytes, bytearray],
|
|
175
|
+
*,
|
|
176
|
+
label: str = "sdk upload",
|
|
177
|
+
network: Optional[str] = None,
|
|
178
|
+
data_shards: Optional[int] = None,
|
|
179
|
+
parity_shards: Optional[int] = None,
|
|
180
|
+
on_pending: Optional[Callable[[dict], None]] = None,
|
|
181
|
+
on_fulfilled: Optional[Callable[[dict], None]] = None,
|
|
182
|
+
) -> dict:
|
|
183
|
+
"""
|
|
184
|
+
Store data on-chain. Blocks until the oracle fulfills the request.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
data: Data to store (str or bytes)
|
|
188
|
+
label: Record label (default: 'sdk upload')
|
|
189
|
+
network: Override network: 'sepolia' or 'arbitrum'
|
|
190
|
+
data_shards: Override RS data shards
|
|
191
|
+
parity_shards: Override RS parity shards
|
|
192
|
+
on_pending: Called with {'request_id', 'tx'} when submitted
|
|
193
|
+
on_fulfilled: Called with result dict when oracle fulfills
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
dict with keys: record_id, request_id, tx, chain_id,
|
|
197
|
+
original_size, encoded_size
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
HealChainError: on network error or fulfillment timeout
|
|
201
|
+
"""
|
|
202
|
+
hex_data = _to_hex(data)
|
|
203
|
+
|
|
204
|
+
payload = {
|
|
205
|
+
"data": hex_data,
|
|
206
|
+
"label": label,
|
|
207
|
+
"network": network or self.network,
|
|
208
|
+
"dataShards": data_shards or self.data_shards,
|
|
209
|
+
"parityShards": parity_shards or self.parity_shards,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
submitted = self._request("POST", "/storeOnChain", body=payload)
|
|
213
|
+
|
|
214
|
+
# Devnet returns synchronously
|
|
215
|
+
if submitted.get("status") == "success" and "recordId" in submitted:
|
|
216
|
+
return self._normalise_store(submitted)
|
|
217
|
+
|
|
218
|
+
# Testnet: poll for oracle fulfillment
|
|
219
|
+
request_id = submitted.get("requestId")
|
|
220
|
+
tx = submitted.get("tx")
|
|
221
|
+
if not request_id:
|
|
222
|
+
raise HealChainError(
|
|
223
|
+
"No requestId returned from store", response=submitted
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if on_pending:
|
|
227
|
+
on_pending({"request_id": str(request_id), "tx": tx})
|
|
228
|
+
|
|
229
|
+
return self._poll_fulfillment(request_id, tx, on_fulfilled)
|
|
230
|
+
|
|
231
|
+
def _poll_fulfillment(
|
|
232
|
+
self,
|
|
233
|
+
request_id: str,
|
|
234
|
+
tx: str,
|
|
235
|
+
on_fulfilled: Optional[Callable[[dict], None]],
|
|
236
|
+
) -> dict:
|
|
237
|
+
deadline = time.monotonic() + self.poll_timeout
|
|
238
|
+
|
|
239
|
+
while time.monotonic() < deadline:
|
|
240
|
+
time.sleep(self.poll_interval)
|
|
241
|
+
try:
|
|
242
|
+
status = self._request(
|
|
243
|
+
"GET",
|
|
244
|
+
f"/sepoliaStatus?requestId={urllib.parse.quote(str(request_id))}",
|
|
245
|
+
)
|
|
246
|
+
except HealChainError:
|
|
247
|
+
continue # transient error — keep polling
|
|
248
|
+
|
|
249
|
+
if status.get("status") == "fulfilled":
|
|
250
|
+
result = {
|
|
251
|
+
"record_id": str(status.get("recordId", request_id)),
|
|
252
|
+
"request_id": str(request_id),
|
|
253
|
+
"tx": tx,
|
|
254
|
+
"chain_id": status.get("chainId"),
|
|
255
|
+
"original_size": status.get("originalSize"),
|
|
256
|
+
"encoded_size": status.get("encodedSize"),
|
|
257
|
+
"total_records": status.get("totalRecords"),
|
|
258
|
+
}
|
|
259
|
+
if on_fulfilled:
|
|
260
|
+
on_fulfilled(result)
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
raise HealChainError(
|
|
264
|
+
f"Oracle fulfillment timed out after {self.poll_timeout}s "
|
|
265
|
+
f"(request_id={request_id})",
|
|
266
|
+
code="FULFILLMENT_TIMEOUT",
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
@staticmethod
|
|
270
|
+
def _normalise_store(resp: dict) -> dict:
|
|
271
|
+
return {
|
|
272
|
+
"record_id": str(resp.get("recordId", "")),
|
|
273
|
+
"request_id": resp.get("requestId"),
|
|
274
|
+
"tx": resp.get("tx"),
|
|
275
|
+
"chain_id": resp.get("chainId"),
|
|
276
|
+
"original_size": resp.get("originalSize"),
|
|
277
|
+
"encoded_size": resp.get("encodedSize"),
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
# ── retrieve() ────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
def retrieve(self, record_id: Union[str, int]) -> dict:
|
|
283
|
+
"""
|
|
284
|
+
Retrieve a record by ID. Automatically routes to the correct chain
|
|
285
|
+
via the GlobalRegistry.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
record_id: Record ID (str or int)
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
dict with keys: record_id, data (hex), text, bytes, chain_id
|
|
292
|
+
|
|
293
|
+
Raises:
|
|
294
|
+
HealChainError: if record not found or network error
|
|
295
|
+
"""
|
|
296
|
+
path = f"/retrieve?id={urllib.parse.quote(str(record_id))}"
|
|
297
|
+
result = self._request("GET", path)
|
|
298
|
+
return {
|
|
299
|
+
"record_id": str(result.get("recordId", record_id)),
|
|
300
|
+
"data": result.get("data"),
|
|
301
|
+
"text": result.get("text"),
|
|
302
|
+
"bytes": result.get("bytes"),
|
|
303
|
+
"chain_id": result.get("chainId"),
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
# ── get_metadata() ────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
def get_metadata(self, record_id: Union[str, int]) -> dict:
|
|
309
|
+
"""
|
|
310
|
+
Get record metadata without fetching the full data payload.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
record_id: Record ID (str or int)
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
dict with label, owner, original_size, encoded_size,
|
|
317
|
+
data_shards, parity_shards, timestamp, data_hash
|
|
318
|
+
"""
|
|
319
|
+
path = f"/getMetadata?id={urllib.parse.quote(str(record_id))}"
|
|
320
|
+
result = self._request("GET", path)
|
|
321
|
+
return {
|
|
322
|
+
"record_id": str(result.get("recordId", record_id)),
|
|
323
|
+
"label": result.get("label"),
|
|
324
|
+
"owner": result.get("owner"),
|
|
325
|
+
"original_size": result.get("originalSize"),
|
|
326
|
+
"encoded_size": result.get("encodedSize"),
|
|
327
|
+
"data_shards": result.get("dataShards"),
|
|
328
|
+
"parity_shards": result.get("parityShards"),
|
|
329
|
+
"timestamp": result.get("timestamp"),
|
|
330
|
+
"data_hash": result.get("dataHash"),
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
# ── list() ────────────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
def list(self, page: int = 0, limit: int = 10) -> dict:
|
|
336
|
+
"""
|
|
337
|
+
List records with pagination.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
page: Page number, 0-indexed (default: 0)
|
|
341
|
+
limit: Records per page, max 50 (default: 10)
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
dict with keys: records, total, pages, page, limit
|
|
345
|
+
"""
|
|
346
|
+
path = f"/listRecords?page={page}&limit={limit}"
|
|
347
|
+
result = self._request("GET", path)
|
|
348
|
+
return {
|
|
349
|
+
"records": result.get("records", []),
|
|
350
|
+
"total": result.get("total", 0),
|
|
351
|
+
"pages": result.get("pages", 1),
|
|
352
|
+
"page": result.get("page", page),
|
|
353
|
+
"limit": result.get("limit", limit),
|
|
354
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: healchain-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for HealChain — self-healing decentralized storage via Reed-Solomon erasure coding
|
|
5
|
+
Author-email: HealChain Team <info@healchain.org>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://healchain.org
|
|
8
|
+
Project-URL: Repository, https://github.com/karmaxul/ci-quantum-storage
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/karmaxul/ci-quantum-storage/issues
|
|
10
|
+
Keywords: healchain,blockchain,storage,reed-solomon,erasure-coding,ethereum,arbitrum,decentralized
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Internet
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.8
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE (1)
|
|
25
|
+
License-File: LICENSE (1):Zone.Identifier
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-mock; extra == "dev"
|
|
29
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE (1)
|
|
2
|
+
LICENSE (1):Zone.Identifier
|
|
3
|
+
pyproject.toml
|
|
4
|
+
healchain/__init__.py
|
|
5
|
+
healchain/client.py
|
|
6
|
+
healchain_sdk.egg-info/PKG-INFO
|
|
7
|
+
healchain_sdk.egg-info/SOURCES.txt
|
|
8
|
+
healchain_sdk.egg-info/dependency_links.txt
|
|
9
|
+
healchain_sdk.egg-info/requires.txt
|
|
10
|
+
healchain_sdk.egg-info/top_level.txt
|
|
11
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
healchain
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=42", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "healchain-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for HealChain — self-healing decentralized storage via Reed-Solomon erasure coding"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "HealChain Team", email = "info@healchain.org" }]
|
|
12
|
+
keywords = [
|
|
13
|
+
"healchain", "blockchain", "storage", "reed-solomon",
|
|
14
|
+
"erasure-coding", "ethereum", "arbitrum", "decentralized",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.8",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Topic :: Internet",
|
|
27
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
28
|
+
]
|
|
29
|
+
requires-python = ">=3.8"
|
|
30
|
+
dependencies = [] # zero dependencies — stdlib only
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = ["pytest", "pytest-mock"]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://healchain.org"
|
|
37
|
+
Repository = "https://github.com/karmaxul/ci-quantum-storage"
|
|
38
|
+
"Bug Tracker" = "https://github.com/karmaxul/ci-quantum-storage/issues"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["."]
|
|
42
|
+
include = ["healchain*"]
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HealChain Python SDK — smoke tests
|
|
3
|
+
Run: python -m pytest tests/ -v
|
|
4
|
+
or: python tests/test_client.py
|
|
5
|
+
|
|
6
|
+
Set HC_API_URL env var to override the default API endpoint.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import traceback
|
|
12
|
+
|
|
13
|
+
# Allow running directly without pytest
|
|
14
|
+
try:
|
|
15
|
+
import pytest
|
|
16
|
+
HAS_PYTEST = True
|
|
17
|
+
except ImportError:
|
|
18
|
+
HAS_PYTEST = False
|
|
19
|
+
|
|
20
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
21
|
+
|
|
22
|
+
from healchain import HealChain, HealChainError
|
|
23
|
+
|
|
24
|
+
API_URL = os.environ.get("HC_API_URL", "https://api.healchain.org")
|
|
25
|
+
hc = HealChain(api_url=API_URL)
|
|
26
|
+
|
|
27
|
+
# ── Simple test runner (no pytest required) ───────────────────────────────────
|
|
28
|
+
|
|
29
|
+
passed = 0
|
|
30
|
+
failed = 0
|
|
31
|
+
|
|
32
|
+
def test(name):
|
|
33
|
+
"""Decorator for standalone test functions."""
|
|
34
|
+
def decorator(fn):
|
|
35
|
+
global passed, failed
|
|
36
|
+
try:
|
|
37
|
+
fn()
|
|
38
|
+
print(f" ✅ {name}")
|
|
39
|
+
passed += 1
|
|
40
|
+
except Exception as e:
|
|
41
|
+
print(f" ❌ {name}")
|
|
42
|
+
print(f" {e}")
|
|
43
|
+
failed += 1
|
|
44
|
+
return fn
|
|
45
|
+
return decorator
|
|
46
|
+
|
|
47
|
+
def assert_eq(a, b, msg=""):
|
|
48
|
+
if a != b:
|
|
49
|
+
raise AssertionError(msg or f"{a!r} != {b!r}")
|
|
50
|
+
|
|
51
|
+
def assert_true(condition, msg="Assertion failed"):
|
|
52
|
+
if not condition:
|
|
53
|
+
raise AssertionError(msg)
|
|
54
|
+
|
|
55
|
+
def assert_in(key, obj, msg=""):
|
|
56
|
+
if key not in obj:
|
|
57
|
+
raise AssertionError(msg or f"{key!r} not in {obj!r}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── health() ──────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
print(f"\nHealChain Python SDK tests — {API_URL}\n")
|
|
63
|
+
print("health()")
|
|
64
|
+
|
|
65
|
+
@test("returns status field")
|
|
66
|
+
def _():
|
|
67
|
+
h = hc.health()
|
|
68
|
+
assert_in("status", h)
|
|
69
|
+
|
|
70
|
+
@test("returns version field")
|
|
71
|
+
def _():
|
|
72
|
+
h = hc.health()
|
|
73
|
+
assert_true(isinstance(h.get("version"), str), "version should be a string")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── retrieve() ────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
print("\nretrieve()")
|
|
79
|
+
|
|
80
|
+
@test("retrieves record 9")
|
|
81
|
+
def _():
|
|
82
|
+
r = hc.retrieve(9)
|
|
83
|
+
assert_in("record_id", r)
|
|
84
|
+
assert_true(isinstance(r["bytes"], int), "bytes should be int")
|
|
85
|
+
|
|
86
|
+
@test("returns chain_id in response")
|
|
87
|
+
def _():
|
|
88
|
+
r = hc.retrieve(9)
|
|
89
|
+
assert_in("chain_id", r)
|
|
90
|
+
# record 9 is on Arbitrum Sepolia
|
|
91
|
+
assert_eq(r["chain_id"], "421614", f"Expected Arbitrum chain, got {r['chain_id']}")
|
|
92
|
+
|
|
93
|
+
@test("returns text field")
|
|
94
|
+
def _():
|
|
95
|
+
r = hc.retrieve(9)
|
|
96
|
+
assert_true(r.get("text") is not None, "text field missing")
|
|
97
|
+
|
|
98
|
+
@test("raises HealChainError for missing record")
|
|
99
|
+
def _():
|
|
100
|
+
try:
|
|
101
|
+
hc.retrieve(999999)
|
|
102
|
+
raise AssertionError("Should have raised HealChainError")
|
|
103
|
+
except HealChainError as e:
|
|
104
|
+
assert_true(e.status is not None or e.code is not None,
|
|
105
|
+
"Expected status or code on error")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ── list() ────────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
print("\nlist()")
|
|
111
|
+
|
|
112
|
+
@test("returns records list")
|
|
113
|
+
def _():
|
|
114
|
+
result = hc.list(0, 5)
|
|
115
|
+
assert_true(isinstance(result["records"], list), "records should be a list")
|
|
116
|
+
assert_true(isinstance(result["total"], int), "total should be int")
|
|
117
|
+
|
|
118
|
+
@test("respects limit parameter")
|
|
119
|
+
def _():
|
|
120
|
+
result = hc.list(0, 2)
|
|
121
|
+
assert_true(len(result["records"]) <= 2,
|
|
122
|
+
f"Expected ≤2 records, got {len(result['records'])}")
|
|
123
|
+
|
|
124
|
+
@test("returns pagination fields")
|
|
125
|
+
def _():
|
|
126
|
+
result = hc.list(0, 5)
|
|
127
|
+
for key in ("records", "total", "pages", "page", "limit"):
|
|
128
|
+
assert_in(key, result)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ── get_metadata() ────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
print("\nget_metadata()")
|
|
134
|
+
|
|
135
|
+
@test("returns metadata fields")
|
|
136
|
+
def _():
|
|
137
|
+
m = hc.get_metadata(9)
|
|
138
|
+
for key in ("label", "original_size", "encoded_size", "data_shards", "parity_shards"):
|
|
139
|
+
assert_in(key, m)
|
|
140
|
+
|
|
141
|
+
@test("label is a non-empty string")
|
|
142
|
+
def _():
|
|
143
|
+
m = hc.get_metadata(9)
|
|
144
|
+
assert_true(isinstance(m["label"], str) and len(m["label"]) > 0,
|
|
145
|
+
f"Expected non-empty label, got: {m['label']!r}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ── HealChainError ────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
print("\nHealChainError")
|
|
151
|
+
|
|
152
|
+
@test("has status attribute")
|
|
153
|
+
def _():
|
|
154
|
+
try:
|
|
155
|
+
hc.retrieve(999999)
|
|
156
|
+
except HealChainError as e:
|
|
157
|
+
assert_true(hasattr(e, "status"), "Missing status attribute")
|
|
158
|
+
|
|
159
|
+
@test("has code attribute")
|
|
160
|
+
def _():
|
|
161
|
+
bad = HealChain(api_url="http://localhost:99999", poll_timeout=1)
|
|
162
|
+
try:
|
|
163
|
+
bad.retrieve(0)
|
|
164
|
+
except HealChainError as e:
|
|
165
|
+
assert_eq(e.code, "NETWORK_ERROR")
|
|
166
|
+
|
|
167
|
+
@test("to_hex handles str input")
|
|
168
|
+
def _():
|
|
169
|
+
from healchain.client import _to_hex
|
|
170
|
+
result = _to_hex("Hello")
|
|
171
|
+
assert_true(result.startswith("0x"), "Should start with 0x")
|
|
172
|
+
assert_eq(result, "0x48656c6c6f")
|
|
173
|
+
|
|
174
|
+
@test("to_hex handles bytes input")
|
|
175
|
+
def _():
|
|
176
|
+
from healchain.client import _to_hex
|
|
177
|
+
result = _to_hex(b"Hello")
|
|
178
|
+
assert_eq(result, "0x48656c6c6f")
|
|
179
|
+
|
|
180
|
+
@test("to_hex passes through existing hex")
|
|
181
|
+
def _():
|
|
182
|
+
from healchain.client import _to_hex
|
|
183
|
+
result = _to_hex("0xdeadbeef")
|
|
184
|
+
assert_eq(result, "0xdeadbeef")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ── Summary ───────────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
print(f"\n{'─' * 40}")
|
|
190
|
+
print(f"{passed + failed} tests: {passed} passed, {failed} failed\n")
|
|
191
|
+
|
|
192
|
+
if failed > 0:
|
|
193
|
+
sys.exit(1)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ── pytest compatibility ──────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
def test_health_status():
|
|
199
|
+
h = hc.health()
|
|
200
|
+
assert "status" in h
|
|
201
|
+
|
|
202
|
+
def test_retrieve_record():
|
|
203
|
+
r = hc.retrieve(9)
|
|
204
|
+
assert r["chain_id"] == "421614"
|
|
205
|
+
|
|
206
|
+
def test_list_records():
|
|
207
|
+
result = hc.list(0, 5)
|
|
208
|
+
assert isinstance(result["records"], list)
|
|
209
|
+
|
|
210
|
+
def test_get_metadata():
|
|
211
|
+
m = hc.get_metadata(9)
|
|
212
|
+
assert "label" in m
|
|
213
|
+
|
|
214
|
+
def test_error_on_missing_record():
|
|
215
|
+
try:
|
|
216
|
+
hc.retrieve(999999)
|
|
217
|
+
assert False, "Should have raised"
|
|
218
|
+
except HealChainError:
|
|
219
|
+
pass
|