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.
@@ -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.
@@ -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,4 @@
1
+
2
+ [dev]
3
+ pytest
4
+ pytest-mock
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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