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.
- poh_sdk-0.1.0/.gitignore +13 -0
- poh_sdk-0.1.0/PKG-INFO +56 -0
- poh_sdk-0.1.0/README.md +42 -0
- poh_sdk-0.1.0/poh_sdk/__init__.py +28 -0
- poh_sdk-0.1.0/poh_sdk/client.py +403 -0
- poh_sdk-0.1.0/poh_sdk/types.py +179 -0
- poh_sdk-0.1.0/pyproject.toml +19 -0
- poh_sdk-0.1.0/tests/test_client.py +130 -0
poh_sdk-0.1.0/.gitignore
ADDED
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
|
+
```
|
poh_sdk-0.1.0/README.md
ADDED
|
@@ -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"
|