poh-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,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ venv/
8
+ .env
9
+ .env.*
10
+ .pytest_cache/
11
+ .mypy_cache/
12
+ .ruff_cache/
13
+ .DS_Store
poh_sdk-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: poh-sdk
3
+ Version: 0.1.0
4
+ Summary: Proof of Human SDK — scan wallets, poll async jobs, access signal methods
5
+ License: MIT
6
+ Keywords: identity,proof-of-human,solana,wallet,web3
7
+ Requires-Python: >=3.8
8
+ Requires-Dist: httpx>=0.27
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest; extra == 'dev'
11
+ Requires-Dist: pytest-asyncio; extra == 'dev'
12
+ Requires-Dist: respx; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # poh-sdk
16
+
17
+ Python SDK for the [Proof of Human](https://proofofhuman.ge) API.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install poh-sdk
23
+ ```
24
+
25
+ ## Quick start
26
+
27
+ ```python
28
+ import asyncio
29
+ from poh_sdk import PohClient
30
+
31
+ async def main():
32
+ async with PohClient("https://proofofhuman.ge") as poh:
33
+ result = await poh.scan("0xabc...")
34
+ print(result.result) # True = human, False = bot, None = inconclusive
35
+
36
+ asyncio.run(main())
37
+ ```
38
+
39
+ ## Sync usage
40
+
41
+ ```python
42
+ from poh_sdk import PohClient
43
+
44
+ poh = PohClient.sync("https://proofofhuman.ge")
45
+ result = poh.scan("0xabc...")
46
+ ```
47
+
48
+ ## Bulk scanning
49
+
50
+ ```python
51
+ async with PohClient("https://proofofhuman.ge") as poh:
52
+ job = await poh.scan_bulk(["0xaaa", "0xbbb", "0xccc"])
53
+
54
+ async for snap in poh.watch_job(job.job_id):
55
+ print(f"{snap.percent:.0f}% done")
56
+ ```
@@ -0,0 +1,42 @@
1
+ # poh-sdk
2
+
3
+ Python SDK for the [Proof of Human](https://proofofhuman.ge) API.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install poh-sdk
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```python
14
+ import asyncio
15
+ from poh_sdk import PohClient
16
+
17
+ async def main():
18
+ async with PohClient("https://proofofhuman.ge") as poh:
19
+ result = await poh.scan("0xabc...")
20
+ print(result.result) # True = human, False = bot, None = inconclusive
21
+
22
+ asyncio.run(main())
23
+ ```
24
+
25
+ ## Sync usage
26
+
27
+ ```python
28
+ from poh_sdk import PohClient
29
+
30
+ poh = PohClient.sync("https://proofofhuman.ge")
31
+ result = poh.scan("0xabc...")
32
+ ```
33
+
34
+ ## Bulk scanning
35
+
36
+ ```python
37
+ async with PohClient("https://proofofhuman.ge") as poh:
38
+ job = await poh.scan_bulk(["0xaaa", "0xbbb", "0xccc"])
39
+
40
+ async for snap in poh.watch_job(job.job_id):
41
+ print(f"{snap.percent:.0f}% done")
42
+ ```
@@ -0,0 +1,28 @@
1
+ from .client import PohClient, PohError
2
+ from .types import (
3
+ ScanResult,
4
+ BulkScanResult,
5
+ JobStatus,
6
+ ScanResultItem,
7
+ BrainVerdict,
8
+ BrainPollOptions,
9
+ ScanWithVerdict,
10
+ Method,
11
+ ScanOptions,
12
+ PollOptions,
13
+ )
14
+
15
+ __all__ = [
16
+ "PohClient",
17
+ "PohError",
18
+ "ScanResult",
19
+ "BulkScanResult",
20
+ "JobStatus",
21
+ "ScanResultItem",
22
+ "BrainVerdict",
23
+ "BrainPollOptions",
24
+ "ScanWithVerdict",
25
+ "Method",
26
+ "ScanOptions",
27
+ "PollOptions",
28
+ ]
@@ -0,0 +1,403 @@
1
+ """
2
+ Proof of Human Python SDK — async + sync client.
3
+
4
+ Async usage (recommended):
5
+ import asyncio
6
+ from poh_sdk import PohClient
7
+
8
+ async def main():
9
+ async with PohClient("https://proofofhuman.ge", api_key="...") as poh:
10
+ res = await poh.scan("0xabc...")
11
+ print(res.result)
12
+
13
+ asyncio.run(main())
14
+
15
+ Sync usage:
16
+ from poh_sdk import PohClient
17
+
18
+ poh = PohClient.sync("https://proofofhuman.ge", api_key="...")
19
+ res = poh.scan_sync("0xabc...")
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import time
25
+ from typing import AsyncIterator, List, Optional
26
+ from urllib.parse import quote
27
+
28
+ import httpx
29
+
30
+ from .types import (
31
+ BrainPollOptions,
32
+ BrainVerdict,
33
+ BulkScanResult,
34
+ JobStatus,
35
+ Method,
36
+ PollOptions,
37
+ ScanOptions,
38
+ ScanResult,
39
+ ScanWithVerdict,
40
+ )
41
+
42
+
43
+ class PohError(Exception):
44
+ """Raised when the POH API returns a non-2xx response."""
45
+
46
+ def __init__(self, message: str, status: int) -> None:
47
+ super().__init__(message)
48
+ self.status = status
49
+
50
+ def __repr__(self) -> str:
51
+ return f"PohError(status={self.status}, message={str(self)!r})"
52
+
53
+
54
+ class PohClient:
55
+ """
56
+ Async Proof of Human API client.
57
+
58
+ Use as an async context manager or call :meth:`aclose` when done.
59
+ For one-off synchronous use, see :meth:`sync`.
60
+
61
+ Parameters
62
+ ----------
63
+ base_url:
64
+ Base URL of the POH API, e.g. ``"https://proofofhuman.ge"``.
65
+ api_key:
66
+ API key for paid tier.
67
+ wallet_address:
68
+ Solana wallet address for free-tier request tracking.
69
+ timeout:
70
+ Per-request timeout in seconds (default: 30).
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ base_url: str,
76
+ *,
77
+ api_key: Optional[str] = None,
78
+ wallet_address: Optional[str] = None,
79
+ timeout: float = 30.0,
80
+ ) -> None:
81
+ self._base_url = base_url.rstrip("/")
82
+ self._api_key = api_key
83
+ self._wallet_address = wallet_address
84
+ headers: dict = {"Accept": "application/json"}
85
+ if api_key:
86
+ headers["x-api-key"] = api_key
87
+ self._client = httpx.AsyncClient(
88
+ base_url = self._base_url,
89
+ headers = headers,
90
+ timeout = timeout,
91
+ )
92
+
93
+ async def __aenter__(self) -> "PohClient":
94
+ return self
95
+
96
+ async def __aexit__(self, *_: object) -> None:
97
+ await self.aclose()
98
+
99
+ async def aclose(self) -> None:
100
+ await self._client.aclose()
101
+
102
+ # ── Internal ──────────────────────────────────────────────────────────────
103
+
104
+ async def _request(self, method: str, path: str, **kwargs: object) -> dict:
105
+ try:
106
+ res = await self._client.request(method, path, **kwargs)
107
+ except httpx.TimeoutException as exc:
108
+ raise PohError("Request timed out", 408) from exc
109
+
110
+ if res.is_error:
111
+ try:
112
+ msg = res.json().get("error", res.text)
113
+ except Exception:
114
+ msg = res.text or f"HTTP {res.status_code}"
115
+ raise PohError(str(msg), res.status_code)
116
+
117
+ return res.json()
118
+
119
+ # ── Scan ──────────────────────────────────────────────────────────────────
120
+
121
+ async def scan(
122
+ self,
123
+ input: str,
124
+ options: Optional[ScanOptions] = None,
125
+ ) -> ScanResult:
126
+ """Scan a single wallet address.
127
+
128
+ Returns ``result=True`` for human, ``False`` for not-human,
129
+ ``None`` for inconclusive.
130
+ """
131
+ body: dict = {"input": input}
132
+ if self._wallet_address:
133
+ body["walletAddress"] = self._wallet_address
134
+ if options:
135
+ body.update(options.to_dict())
136
+ return ScanResult.from_dict(await self._request("POST", "/checker", json=body))
137
+
138
+ async def scan_bulk(
139
+ self,
140
+ inputs: List[str],
141
+ options: Optional[ScanOptions] = None,
142
+ ) -> BulkScanResult:
143
+ """Submit a bulk scan.
144
+
145
+ Returns a :class:`BulkScanResult` with a ``job_id``.
146
+ Use :meth:`poll_job` or :meth:`watch_job` to retrieve results.
147
+ """
148
+ if not inputs:
149
+ raise ValueError("inputs list must not be empty")
150
+ body: dict = {"input": inputs}
151
+ if self._wallet_address:
152
+ body["walletAddress"] = self._wallet_address
153
+ if options:
154
+ body.update(options.to_dict())
155
+ return BulkScanResult.from_dict(await self._request("POST", "/checker", json=body))
156
+
157
+ # ── Job polling ───────────────────────────────────────────────────────────
158
+
159
+ async def get_job(self, job_id: str) -> JobStatus:
160
+ """Fetch the current status snapshot of an async scan job."""
161
+ return JobStatus.from_dict(await self._request("GET", f"/checker/job/{quote(job_id)}"))
162
+
163
+ async def poll_job(
164
+ self,
165
+ job_id: str,
166
+ options: Optional[PollOptions] = None,
167
+ ) -> JobStatus:
168
+ """Poll a job until it reaches ``done`` or ``error``.
169
+
170
+ Raises :class:`asyncio.TimeoutError` if the job does not finish
171
+ within ``options.timeout`` seconds.
172
+ """
173
+ opts = options or PollOptions()
174
+ deadline = time.monotonic() + opts.timeout
175
+
176
+ while True:
177
+ job = await self.get_job(job_id)
178
+ if opts.on_progress:
179
+ opts.on_progress(job)
180
+ if job.is_terminal:
181
+ return job
182
+ if time.monotonic() + opts.interval > deadline:
183
+ raise TimeoutError(
184
+ f"POH job '{job_id}' did not complete within {opts.timeout}s"
185
+ )
186
+ await asyncio.sleep(opts.interval)
187
+
188
+ async def watch_job(
189
+ self,
190
+ job_id: str,
191
+ options: Optional[PollOptions] = None,
192
+ ) -> AsyncIterator[JobStatus]:
193
+ """Async generator that yields a status snapshot on each poll tick.
194
+
195
+ Terminates when the job is ``done`` or ``error``.
196
+
197
+ Example::
198
+
199
+ async for snap in poh.watch_job(job_id):
200
+ print(f"{snap.percent:.0f}% ({snap.done}/{snap.total})")
201
+ """
202
+ opts = options or PollOptions()
203
+ deadline = time.monotonic() + opts.timeout
204
+
205
+ while True:
206
+ job = await self.get_job(job_id)
207
+ yield job
208
+ if job.is_terminal:
209
+ return
210
+ if time.monotonic() + opts.interval > deadline:
211
+ raise TimeoutError(
212
+ f"POH job '{job_id}' did not complete within {opts.timeout}s"
213
+ )
214
+ await asyncio.sleep(opts.interval)
215
+
216
+ async def scan_and_wait(
217
+ self,
218
+ inputs: List[str],
219
+ scan_options: Optional[ScanOptions] = None,
220
+ poll_options: Optional[PollOptions] = None,
221
+ ) -> JobStatus:
222
+ """Convenience: submit a bulk scan and wait for all results."""
223
+ job = await self.scan_bulk(inputs, scan_options)
224
+ return await self.poll_job(job.job_id, poll_options)
225
+
226
+ # ── Brain verdict ──────────────────────────────────────────────────────────
227
+
228
+ async def get_brain_verdict(self, brain_key: str) -> BrainVerdict:
229
+ """Retrieve the AI brain verdict for a completed scan."""
230
+ return BrainVerdict.from_dict(
231
+ await self._request("GET", f"/checker/brain/{quote(brain_key)}")
232
+ )
233
+
234
+ async def poll_brain_verdict(
235
+ self,
236
+ brain_key: str,
237
+ options: Optional[BrainPollOptions] = None,
238
+ ) -> BrainVerdict:
239
+ """Poll the brain verdict until the status leaves ``pending``.
240
+
241
+ Raises :class:`TimeoutError` if the verdict does not resolve within
242
+ ``options.timeout`` seconds.
243
+
244
+ Example::
245
+
246
+ verdict = await poh.poll_brain_verdict(scan.brain_key)
247
+ print(verdict.verdict, verdict.confidence)
248
+ """
249
+ opts = options or BrainPollOptions()
250
+ deadline = time.monotonic() + opts.timeout
251
+
252
+ while True:
253
+ v = await self.get_brain_verdict(brain_key)
254
+ if v.status != "pending":
255
+ return v
256
+ if time.monotonic() + opts.interval > deadline:
257
+ raise TimeoutError(
258
+ f"Brain verdict for '{brain_key}' did not resolve within {opts.timeout}s"
259
+ )
260
+ await asyncio.sleep(opts.interval)
261
+
262
+ async def scan_and_verdict(
263
+ self,
264
+ input: str,
265
+ scan_options: Optional[ScanOptions] = None,
266
+ brain_options: Optional[BrainPollOptions] = None,
267
+ ) -> ScanWithVerdict:
268
+ """Convenience: scan a single address and wait for the AI brain verdict.
269
+
270
+ Returns a :class:`ScanWithVerdict` with both the raw scan evidence and
271
+ the resolved AI verdict.
272
+
273
+ Example::
274
+
275
+ sv = await poh.scan_and_verdict("0xabc...")
276
+ print(sv.verdict.verdict, sv.verdict.confidence)
277
+ """
278
+ scan = await self.scan(input, scan_options)
279
+ if not scan.brain_key:
280
+ return ScanWithVerdict(scan=scan, verdict=BrainVerdict(status="not_found"))
281
+ verdict = await self.poll_brain_verdict(scan.brain_key, brain_options)
282
+ return ScanWithVerdict(scan=scan, verdict=verdict)
283
+
284
+ # ── Methods ───────────────────────────────────────────────────────────────
285
+
286
+ async def get_methods(self, wallet_address: Optional[str] = None) -> List[Method]:
287
+ """List available signal verification methods."""
288
+ addr = wallet_address or self._wallet_address
289
+ qs = f"?address={quote(addr)}" if addr else ""
290
+ data = await self._request("GET", f"/verifyer{qs}")
291
+ return [Method.from_dict(m) for m in data]
292
+
293
+ async def get_method(self, method_id: str) -> Method:
294
+ """Fetch a single signal method by ID."""
295
+ return Method.from_dict(
296
+ await self._request("GET", f"/verifyer/{quote(method_id)}")
297
+ )
298
+
299
+ # ── Sync convenience ──────────────────────────────────────────────────────
300
+
301
+ @classmethod
302
+ def sync(
303
+ cls,
304
+ base_url: str,
305
+ *,
306
+ api_key: Optional[str] = None,
307
+ wallet_address: Optional[str] = None,
308
+ timeout: float = 30.0,
309
+ ) -> "_SyncPohClient":
310
+ """Return a synchronous wrapper around the async client.
311
+
312
+ Example::
313
+
314
+ poh = PohClient.sync("https://proofofhuman.ge")
315
+ res = poh.scan_sync("0xabc...")
316
+ """
317
+ return _SyncPohClient(
318
+ base_url = base_url,
319
+ api_key = api_key,
320
+ wallet_address = wallet_address,
321
+ timeout = timeout,
322
+ )
323
+
324
+
325
+ class _SyncPohClient:
326
+ """Synchronous wrapper that runs an event loop internally."""
327
+
328
+ def __init__(self, **kwargs: object) -> None:
329
+ self._kwargs = kwargs
330
+
331
+ def _run(self, coro): # type: ignore[no-untyped-def]
332
+ return asyncio.get_event_loop().run_until_complete(coro)
333
+
334
+ def _client(self) -> PohClient:
335
+ return PohClient(**self._kwargs) # type: ignore[arg-type]
336
+
337
+ def scan(self, input: str, options: Optional[ScanOptions] = None) -> ScanResult:
338
+ async def _go():
339
+ async with self._client() as c:
340
+ return await c.scan(input, options)
341
+ return self._run(_go())
342
+
343
+ def scan_bulk(self, inputs: List[str], options: Optional[ScanOptions] = None) -> BulkScanResult:
344
+ async def _go():
345
+ async with self._client() as c:
346
+ return await c.scan_bulk(inputs, options)
347
+ return self._run(_go())
348
+
349
+ def get_job(self, job_id: str) -> JobStatus:
350
+ async def _go():
351
+ async with self._client() as c:
352
+ return await c.get_job(job_id)
353
+ return self._run(_go())
354
+
355
+ def poll_job(self, job_id: str, options: Optional[PollOptions] = None) -> JobStatus:
356
+ async def _go():
357
+ async with self._client() as c:
358
+ return await c.poll_job(job_id, options)
359
+ return self._run(_go())
360
+
361
+ def scan_and_wait(
362
+ self,
363
+ inputs: List[str],
364
+ scan_options: Optional[ScanOptions] = None,
365
+ poll_options: Optional[PollOptions] = None,
366
+ ) -> JobStatus:
367
+ async def _go():
368
+ async with self._client() as c:
369
+ return await c.scan_and_wait(inputs, scan_options, poll_options)
370
+ return self._run(_go())
371
+
372
+ def get_brain_verdict(self, brain_key: str) -> BrainVerdict:
373
+ async def _go():
374
+ async with self._client() as c:
375
+ return await c.get_brain_verdict(brain_key)
376
+ return self._run(_go())
377
+
378
+ def poll_brain_verdict(
379
+ self,
380
+ brain_key: str,
381
+ options: Optional[BrainPollOptions] = None,
382
+ ) -> BrainVerdict:
383
+ async def _go():
384
+ async with self._client() as c:
385
+ return await c.poll_brain_verdict(brain_key, options)
386
+ return self._run(_go())
387
+
388
+ def scan_and_verdict(
389
+ self,
390
+ input: str,
391
+ scan_options: Optional[ScanOptions] = None,
392
+ brain_options: Optional[BrainPollOptions] = None,
393
+ ) -> ScanWithVerdict:
394
+ async def _go():
395
+ async with self._client() as c:
396
+ return await c.scan_and_verdict(input, scan_options, brain_options)
397
+ return self._run(_go())
398
+
399
+ def get_methods(self, wallet_address: Optional[str] = None) -> List[Method]:
400
+ async def _go():
401
+ async with self._client() as c:
402
+ return await c.get_methods(wallet_address)
403
+ return self._run(_go())
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from typing import Callable, Dict, List, Optional
4
+
5
+
6
+ # ── Scan ──────────────────────────────────────────────────────────────────────
7
+
8
+ @dataclass
9
+ class ScanOptions:
10
+ chain_ids: Optional[List[str]] = None
11
+ tx_hash: Optional[str] = None
12
+
13
+ def to_dict(self) -> dict:
14
+ d = {}
15
+ if self.chain_ids is not None:
16
+ d["chainIds"] = self.chain_ids
17
+ if self.tx_hash is not None:
18
+ d["txHash"] = self.tx_hash
19
+ return d
20
+
21
+
22
+ @dataclass
23
+ class ScanResult:
24
+ result: Optional[bool]
25
+ brain_key: Optional[str] = None
26
+ free_scans_left: Optional[int] = None
27
+ source: Optional[str] = None
28
+ count: Optional[int] = None
29
+
30
+ @classmethod
31
+ def from_dict(cls, d: dict) -> "ScanResult":
32
+ return cls(
33
+ result = d.get("result"),
34
+ brain_key = d.get("brainKey"),
35
+ free_scans_left = d.get("freeScansLeft"),
36
+ source = d.get("source"),
37
+ count = d.get("count"),
38
+ )
39
+
40
+
41
+ @dataclass
42
+ class BulkScanResult:
43
+ job_id: str
44
+ status: str
45
+ total: int
46
+ poll_url: str
47
+ free_scans_left: Optional[int] = None
48
+
49
+ @classmethod
50
+ def from_dict(cls, d: dict) -> "BulkScanResult":
51
+ return cls(
52
+ job_id = d["jobId"],
53
+ status = d["status"],
54
+ total = d["total"],
55
+ poll_url = d["pollUrl"],
56
+ free_scans_left = d.get("freeScansLeft"),
57
+ )
58
+
59
+
60
+ # ── Jobs ──────────────────────────────────────────────────────────────────────
61
+
62
+ @dataclass
63
+ class ScanResultItem:
64
+ input: str
65
+ result: Optional[bool]
66
+ error: Optional[str] = None
67
+
68
+ @classmethod
69
+ def from_dict(cls, d: dict) -> "ScanResultItem":
70
+ return cls(input=d["input"], result=d.get("result"), error=d.get("error"))
71
+
72
+
73
+ @dataclass
74
+ class JobStatus:
75
+ job_id: str
76
+ status: str
77
+ total: int
78
+ done: int
79
+ percent: float
80
+ results: List[ScanResultItem]
81
+ errors: List[str]
82
+ created_at: str
83
+ completed_at: Optional[str] = None
84
+
85
+ @property
86
+ def is_terminal(self) -> bool:
87
+ return self.status in ("done", "error")
88
+
89
+ @classmethod
90
+ def from_dict(cls, d: dict) -> "JobStatus":
91
+ return cls(
92
+ job_id = d["jobId"],
93
+ status = d["status"],
94
+ total = d["total"],
95
+ done = d["done"],
96
+ percent = d["percent"],
97
+ results = [ScanResultItem.from_dict(r) for r in d.get("results", [])],
98
+ errors = d.get("errors", []),
99
+ created_at = d["createdAt"],
100
+ completed_at = d.get("completedAt"),
101
+ )
102
+
103
+
104
+ # ── Poll options ──────────────────────────────────────────────────────────────
105
+
106
+ @dataclass
107
+ class PollOptions:
108
+ interval: float = 1.5
109
+ """Seconds between status checks."""
110
+ timeout: float = 120.0
111
+ """Maximum total wait time in seconds."""
112
+ on_progress: Optional[Callable[[JobStatus], None]] = field(default=None, repr=False)
113
+
114
+
115
+ # ── Brain poll options ────────────────────────────────────────────────────────
116
+
117
+ @dataclass
118
+ class BrainPollOptions:
119
+ interval: float = 1.5
120
+ """Seconds between brain verdict checks."""
121
+ timeout: float = 30.0
122
+ """Maximum total wait time in seconds."""
123
+
124
+
125
+ # ── Scan + verdict combined ───────────────────────────────────────────────────
126
+
127
+ @dataclass
128
+ class ScanWithVerdict:
129
+ """Combined result of :meth:`PohClient.scan_and_verdict`."""
130
+ scan: "ScanResult"
131
+ verdict: "BrainVerdict"
132
+
133
+
134
+ # ── Brain verdict ──────────────────────────────────────────────────────────────
135
+
136
+ @dataclass
137
+ class BrainVerdict:
138
+ status: str
139
+ verdict: Optional[str] = None # "HUMAN" | "AI" | "UNCERTAIN"
140
+ confidence: Optional[float] = None
141
+ signals: Optional[Dict[str, float]] = None
142
+ reasoning: Optional[str] = None
143
+
144
+ @classmethod
145
+ def from_dict(cls, d: dict) -> "BrainVerdict":
146
+ return cls(
147
+ status = d["status"],
148
+ verdict = d.get("verdict"),
149
+ confidence = d.get("confidence"),
150
+ signals = d.get("signals"),
151
+ reasoning = d.get("reasoning"),
152
+ )
153
+
154
+
155
+ # ── Methods ───────────────────────────────────────────────────────────────────
156
+
157
+ @dataclass
158
+ class Method:
159
+ id: str
160
+ type: str
161
+ description: str
162
+ score: float
163
+ address: Optional[str] = None
164
+ method: Optional[str] = None
165
+ vote_count: Optional[int] = None
166
+ chain_id: Optional[str] = None
167
+
168
+ @classmethod
169
+ def from_dict(cls, d: dict) -> "Method":
170
+ return cls(
171
+ id = d["id"],
172
+ type = d["type"],
173
+ description = d["description"],
174
+ score = d["score"],
175
+ address = d.get("address"),
176
+ method = d.get("method"),
177
+ vote_count = d.get("voteCount"),
178
+ chain_id = d.get("chainId"),
179
+ )
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "poh-sdk"
7
+ version = "0.1.0"
8
+ description = "Proof of Human SDK — scan wallets, poll async jobs, access signal methods"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.8"
12
+ keywords = ["proof-of-human", "identity", "solana", "wallet", "web3"]
13
+ dependencies = ["httpx>=0.27"]
14
+
15
+ [project.optional-dependencies]
16
+ dev = ["pytest", "pytest-asyncio", "respx"]
17
+
18
+ [tool.pytest.ini_options]
19
+ asyncio_mode = "auto"
@@ -0,0 +1,130 @@
1
+ """Unit tests — no live server required (uses respx to mock httpx)."""
2
+ import asyncio
3
+ import json
4
+ import pytest
5
+ import respx
6
+ from httpx import Response
7
+
8
+ from poh_sdk import PohClient, PohError, PollOptions, ScanResult, JobStatus
9
+
10
+
11
+ BASE = "http://mock-poh"
12
+
13
+ # ── scan ──────────────────────────────────────────────────────────────────────
14
+
15
+ @pytest.mark.asyncio
16
+ async def test_scan_returns_result():
17
+ with respx.mock:
18
+ respx.post(f"{BASE}/checker").mock(return_value=Response(200, json={
19
+ "result": True, "brainKey": "bk1", "freeScansLeft": 9
20
+ }))
21
+ async with PohClient(BASE) as poh:
22
+ res = await poh.scan("0xabc")
23
+ assert res.result is True
24
+ assert res.brain_key == "bk1"
25
+ assert res.free_scans_left == 9
26
+
27
+
28
+ @pytest.mark.asyncio
29
+ async def test_scan_raises_poh_error_on_4xx():
30
+ with respx.mock:
31
+ respx.post(f"{BASE}/checker").mock(return_value=Response(
32
+ 401, json={"error": "unauthorized"}
33
+ ))
34
+ async with PohClient(BASE) as poh:
35
+ with pytest.raises(PohError) as exc_info:
36
+ await poh.scan("0xabc")
37
+ assert exc_info.value.status == 401
38
+ assert "unauthorized" in str(exc_info.value)
39
+
40
+
41
+ # ── bulk + poll ───────────────────────────────────────────────────────────────
42
+
43
+ SNAPS = [
44
+ {"jobId":"j1","status":"processing","total":2,"done":1,"percent":50,"results":[],"errors":[],"createdAt":""},
45
+ {"jobId":"j1","status":"done","total":2,"done":2,"percent":100,"results":[
46
+ {"input":"0xaaa","result":True},{"input":"0xbbb","result":False}
47
+ ],"errors":[],"createdAt":"","completedAt":"2024-01-01T00:00:00Z"},
48
+ ]
49
+
50
+ @pytest.mark.asyncio
51
+ async def test_poll_job_resolves_on_done():
52
+ call = 0
53
+ def side_effect(_):
54
+ nonlocal call
55
+ snap = SNAPS[min(call, len(SNAPS)-1)]
56
+ call += 1
57
+ return Response(200, json=snap)
58
+
59
+ with respx.mock:
60
+ respx.post(f"{BASE}/checker").mock(return_value=Response(200, json={
61
+ "jobId":"j1","status":"queued","total":2,"pollUrl":"/checker/job/j1","freeScansLeft":5
62
+ }))
63
+ respx.get(f"{BASE}/checker/job/j1").mock(side_effect=side_effect)
64
+
65
+ async with PohClient(BASE) as poh:
66
+ bulk = await poh.scan_bulk(["0xaaa","0xbbb"])
67
+ opts = PollOptions(interval=0.01)
68
+ done = await poh.poll_job(bulk.job_id, opts)
69
+
70
+ assert done.status == "done"
71
+ assert done.percent == 100
72
+ assert len(done.results) == 2
73
+
74
+
75
+ @pytest.mark.asyncio
76
+ async def test_poll_job_raises_on_timeout():
77
+ with respx.mock:
78
+ respx.get(f"{BASE}/checker/job/jx").mock(return_value=Response(200, json={
79
+ "jobId":"jx","status":"processing","total":1,"done":0,"percent":0,
80
+ "results":[],"errors":[],"createdAt":""
81
+ }))
82
+ async with PohClient(BASE) as poh:
83
+ with pytest.raises(TimeoutError):
84
+ await poh.poll_job("jx", PollOptions(interval=0.05, timeout=0.06))
85
+
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_watch_job_yields_all_snapshots():
89
+ call = 0
90
+ snaps = [
91
+ {"jobId":"jw","status":"processing","total":3,"done":1,"percent":33,"results":[],"errors":[],"createdAt":""},
92
+ {"jobId":"jw","status":"processing","total":3,"done":2,"percent":66,"results":[],"errors":[],"createdAt":""},
93
+ {"jobId":"jw","status":"done","total":3,"done":3,"percent":100,"results":[],"errors":[],"createdAt":""},
94
+ ]
95
+ def side(_):
96
+ nonlocal call
97
+ s = snaps[min(call, len(snaps)-1)]
98
+ call += 1
99
+ return Response(200, json=s)
100
+
101
+ with respx.mock:
102
+ respx.get(f"{BASE}/checker/job/jw").mock(side_effect=side)
103
+ async with PohClient(BASE) as poh:
104
+ seen = [snap.percent async for snap in poh.watch_job("jw", PollOptions(interval=0.01))]
105
+
106
+ assert seen == [33.0, 66.0, 100.0]
107
+
108
+
109
+ # ── scan_bulk validation ───────────────────────────────────────────────────────
110
+
111
+ @pytest.mark.asyncio
112
+ async def test_scan_bulk_rejects_empty_list():
113
+ async with PohClient(BASE) as poh:
114
+ with pytest.raises(ValueError):
115
+ await poh.scan_bulk([])
116
+
117
+
118
+ # ── methods ───────────────────────────────────────────────────────────────────
119
+
120
+ @pytest.mark.asyncio
121
+ async def test_get_methods_returns_list():
122
+ with respx.mock:
123
+ respx.get(f"{BASE}/verifyer").mock(return_value=Response(200, json=[
124
+ {"id":"m1","type":"evm","description":"ETH balance","score":42.0}
125
+ ]))
126
+ async with PohClient(BASE) as poh:
127
+ methods = await poh.get_methods()
128
+ assert len(methods) == 1
129
+ assert methods[0].id == "m1"
130
+ assert methods[0].type == "evm"