synapsai 0.1.0__py3-none-any.whl
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.
- synapsai/__init__.py +165 -0
- synapsai/_client.py +163 -0
- synapsai/_errors.py +55 -0
- synapsai/_multipart.py +38 -0
- synapsai/_types.py +455 -0
- synapsai/resources/__init__.py +0 -0
- synapsai/resources/account.py +89 -0
- synapsai/resources/health.py +27 -0
- synapsai/resources/trust.py +31 -0
- synapsai/resources/verify.py +194 -0
- synapsai/resources/webhooks.py +91 -0
- synapsai-0.1.0.dist-info/METADATA +292 -0
- synapsai-0.1.0.dist-info/RECORD +14 -0
- synapsai-0.1.0.dist-info/WHEEL +4 -0
synapsai/__init__.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ._client import (
|
|
7
|
+
_AsyncTransport,
|
|
8
|
+
_SyncTransport,
|
|
9
|
+
_DEFAULT_BASE_URL,
|
|
10
|
+
_DEFAULT_MAX_RETRIES,
|
|
11
|
+
_DEFAULT_TIMEOUT,
|
|
12
|
+
)
|
|
13
|
+
from ._errors import (
|
|
14
|
+
AuthError,
|
|
15
|
+
NotFoundError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
ServerError,
|
|
18
|
+
SynapsAIError,
|
|
19
|
+
ValidationError,
|
|
20
|
+
)
|
|
21
|
+
from ._types import (
|
|
22
|
+
ClaimResult,
|
|
23
|
+
ClaimStatus,
|
|
24
|
+
DocumentVerifyResult,
|
|
25
|
+
HealthStatus,
|
|
26
|
+
HistoryEntry,
|
|
27
|
+
HistoryPage,
|
|
28
|
+
ImageVerifyResult,
|
|
29
|
+
KeyInfo,
|
|
30
|
+
ProtocolStats,
|
|
31
|
+
RawImageScores,
|
|
32
|
+
TrustScore,
|
|
33
|
+
UsageStats,
|
|
34
|
+
VisualAuthResult,
|
|
35
|
+
Webhook,
|
|
36
|
+
WebhookCreateResult,
|
|
37
|
+
WebhookDelivery,
|
|
38
|
+
)
|
|
39
|
+
from .resources.account import AccountResource, AsyncAccountResource
|
|
40
|
+
from .resources.health import AsyncHealthResource, HealthResource
|
|
41
|
+
from .resources.trust import AsyncTrustResource, TrustResource
|
|
42
|
+
from .resources.verify import AsyncVerifyResource, VerifyResource
|
|
43
|
+
from .resources.webhooks import AsyncWebhooksResource, WebhooksResource
|
|
44
|
+
|
|
45
|
+
__version__ = "0.1.0"
|
|
46
|
+
__all__ = [
|
|
47
|
+
"SynapsAI",
|
|
48
|
+
"AsyncSynapsAI",
|
|
49
|
+
# errors
|
|
50
|
+
"SynapsAIError",
|
|
51
|
+
"AuthError",
|
|
52
|
+
"RateLimitError",
|
|
53
|
+
"ValidationError",
|
|
54
|
+
"NotFoundError",
|
|
55
|
+
"ServerError",
|
|
56
|
+
# types
|
|
57
|
+
"ClaimResult",
|
|
58
|
+
"ClaimStatus",
|
|
59
|
+
"ImageVerifyResult",
|
|
60
|
+
"RawImageScores",
|
|
61
|
+
"DocumentVerifyResult",
|
|
62
|
+
"VisualAuthResult",
|
|
63
|
+
"TrustScore",
|
|
64
|
+
"Webhook",
|
|
65
|
+
"WebhookCreateResult",
|
|
66
|
+
"WebhookDelivery",
|
|
67
|
+
"KeyInfo",
|
|
68
|
+
"UsageStats",
|
|
69
|
+
"HistoryEntry",
|
|
70
|
+
"HistoryPage",
|
|
71
|
+
"HealthStatus",
|
|
72
|
+
"ProtocolStats",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _resolve_api_key(api_key: Optional[str]) -> str:
|
|
77
|
+
key = api_key or os.environ.get("SYNAPSAI_API_KEY")
|
|
78
|
+
if not key:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
"No API key provided. Pass api_key= to the constructor "
|
|
81
|
+
"or set the SYNAPSAI_API_KEY environment variable."
|
|
82
|
+
)
|
|
83
|
+
return key
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class SynapsAI:
|
|
87
|
+
"""Synchronous SynapsAI client.
|
|
88
|
+
|
|
89
|
+
Example::
|
|
90
|
+
|
|
91
|
+
from synapsai import SynapsAI
|
|
92
|
+
client = SynapsAI(api_key="taas_your_key")
|
|
93
|
+
result = client.verify.image(image=Path("photo.jpg"))
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
api_key: Optional[str] = None,
|
|
99
|
+
*,
|
|
100
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
101
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
102
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
103
|
+
) -> None:
|
|
104
|
+
transport = _SyncTransport(
|
|
105
|
+
api_key=_resolve_api_key(api_key),
|
|
106
|
+
base_url=base_url,
|
|
107
|
+
max_retries=max_retries,
|
|
108
|
+
timeout=timeout,
|
|
109
|
+
)
|
|
110
|
+
self.verify = VerifyResource(transport)
|
|
111
|
+
self.trust = TrustResource(transport)
|
|
112
|
+
self.webhooks = WebhooksResource(transport)
|
|
113
|
+
self.account = AccountResource(transport)
|
|
114
|
+
self.health = HealthResource(transport)
|
|
115
|
+
self._transport = transport
|
|
116
|
+
|
|
117
|
+
def close(self) -> None:
|
|
118
|
+
self._transport.close()
|
|
119
|
+
|
|
120
|
+
def __enter__(self) -> SynapsAI:
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
def __exit__(self, *_: object) -> None:
|
|
124
|
+
self.close()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class AsyncSynapsAI:
|
|
128
|
+
"""Asynchronous SynapsAI client for use with asyncio / FastAPI.
|
|
129
|
+
|
|
130
|
+
Example::
|
|
131
|
+
|
|
132
|
+
from synapsai import AsyncSynapsAI
|
|
133
|
+
async with AsyncSynapsAI(api_key="taas_your_key") as client:
|
|
134
|
+
result = await client.verify.image(image=Path("photo.jpg"))
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
api_key: Optional[str] = None,
|
|
140
|
+
*,
|
|
141
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
142
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
143
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
144
|
+
) -> None:
|
|
145
|
+
transport = _AsyncTransport(
|
|
146
|
+
api_key=_resolve_api_key(api_key),
|
|
147
|
+
base_url=base_url,
|
|
148
|
+
max_retries=max_retries,
|
|
149
|
+
timeout=timeout,
|
|
150
|
+
)
|
|
151
|
+
self.verify = AsyncVerifyResource(transport)
|
|
152
|
+
self.trust = AsyncTrustResource(transport)
|
|
153
|
+
self.webhooks = AsyncWebhooksResource(transport)
|
|
154
|
+
self.account = AsyncAccountResource(transport)
|
|
155
|
+
self.health = AsyncHealthResource(transport)
|
|
156
|
+
self._transport = transport
|
|
157
|
+
|
|
158
|
+
async def aclose(self) -> None:
|
|
159
|
+
await self._transport.aclose()
|
|
160
|
+
|
|
161
|
+
async def __aenter__(self) -> AsyncSynapsAI:
|
|
162
|
+
return self
|
|
163
|
+
|
|
164
|
+
async def __aexit__(self, *_: object) -> None:
|
|
165
|
+
await self.aclose()
|
synapsai/_client.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._errors import (
|
|
10
|
+
AuthError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
ServerError,
|
|
14
|
+
SynapsAIError,
|
|
15
|
+
ValidationError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_DEFAULT_BASE_URL = "https://api.synapsai.org"
|
|
19
|
+
_DEFAULT_MAX_RETRIES = 3
|
|
20
|
+
_DEFAULT_TIMEOUT = 30.0 # seconds
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _map_error(response: httpx.Response) -> SynapsAIError:
|
|
24
|
+
try:
|
|
25
|
+
body = response.json()
|
|
26
|
+
except Exception:
|
|
27
|
+
body = {}
|
|
28
|
+
code = body.get("error", "unknown_error")
|
|
29
|
+
message = body.get("message", response.text or f"HTTP {response.status_code}")
|
|
30
|
+
status = response.status_code
|
|
31
|
+
|
|
32
|
+
if status == 401:
|
|
33
|
+
return AuthError(message, status=status, code=code)
|
|
34
|
+
if status == 404:
|
|
35
|
+
return NotFoundError(message, status=status, code=code)
|
|
36
|
+
if status == 429:
|
|
37
|
+
retry_after_s = body.get("retryAfter", 60)
|
|
38
|
+
return RateLimitError(
|
|
39
|
+
message,
|
|
40
|
+
status=status,
|
|
41
|
+
code=code,
|
|
42
|
+
retry_after_ms=int(retry_after_s * 1000),
|
|
43
|
+
)
|
|
44
|
+
if status == 400:
|
|
45
|
+
return ValidationError(message, status=status, code=code, field=body.get("field"))
|
|
46
|
+
return ServerError(message, status=status, code=code)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _backoff(attempt: int) -> float:
|
|
50
|
+
"""Exponential backoff with ±20% jitter, in seconds."""
|
|
51
|
+
base = 0.5 * (2 ** attempt)
|
|
52
|
+
return base * (0.8 + 0.4 * random.random())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _SyncTransport:
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
api_key: str,
|
|
59
|
+
base_url: str,
|
|
60
|
+
max_retries: int,
|
|
61
|
+
timeout: float,
|
|
62
|
+
) -> None:
|
|
63
|
+
self._base_url = base_url.rstrip("/")
|
|
64
|
+
self._max_retries = max_retries
|
|
65
|
+
self._headers = {
|
|
66
|
+
"x-api-key": api_key,
|
|
67
|
+
"ngrok-skip-browser-warning": "true",
|
|
68
|
+
}
|
|
69
|
+
self._http = httpx.Client(timeout=timeout)
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
self._http.close()
|
|
73
|
+
|
|
74
|
+
def __enter__(self) -> _SyncTransport:
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def __exit__(self, *_: Any) -> None:
|
|
78
|
+
self.close()
|
|
79
|
+
|
|
80
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Dict[str, Any]:
|
|
81
|
+
url = self._base_url + path
|
|
82
|
+
kwargs.setdefault("headers", {}).update(self._headers)
|
|
83
|
+
last_exc: Optional[SynapsAIError] = None
|
|
84
|
+
|
|
85
|
+
for attempt in range(self._max_retries + 1):
|
|
86
|
+
resp = self._http.request(method, url, **kwargs)
|
|
87
|
+
if resp.status_code < 500:
|
|
88
|
+
if resp.status_code >= 400:
|
|
89
|
+
raise _map_error(resp)
|
|
90
|
+
return resp.json() if resp.content else {}
|
|
91
|
+
last_exc = _map_error(resp)
|
|
92
|
+
if attempt < self._max_retries:
|
|
93
|
+
time.sleep(_backoff(attempt))
|
|
94
|
+
|
|
95
|
+
raise last_exc # type: ignore[misc]
|
|
96
|
+
|
|
97
|
+
def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
98
|
+
return self._request("GET", path, params=params)
|
|
99
|
+
|
|
100
|
+
def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
101
|
+
return self._request("POST", path, json=json)
|
|
102
|
+
|
|
103
|
+
def post_form(self, path: str, files: list) -> Dict[str, Any]:
|
|
104
|
+
return self._request("POST", path, files=files)
|
|
105
|
+
|
|
106
|
+
def delete(self, path: str) -> Dict[str, Any]:
|
|
107
|
+
return self._request("DELETE", path)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _AsyncTransport:
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
api_key: str,
|
|
114
|
+
base_url: str,
|
|
115
|
+
max_retries: int,
|
|
116
|
+
timeout: float,
|
|
117
|
+
) -> None:
|
|
118
|
+
self._base_url = base_url.rstrip("/")
|
|
119
|
+
self._max_retries = max_retries
|
|
120
|
+
self._headers = {
|
|
121
|
+
"x-api-key": api_key,
|
|
122
|
+
"ngrok-skip-browser-warning": "true",
|
|
123
|
+
}
|
|
124
|
+
self._http = httpx.AsyncClient(timeout=timeout)
|
|
125
|
+
|
|
126
|
+
async def aclose(self) -> None:
|
|
127
|
+
await self._http.aclose()
|
|
128
|
+
|
|
129
|
+
async def __aenter__(self) -> _AsyncTransport:
|
|
130
|
+
return self
|
|
131
|
+
|
|
132
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
133
|
+
await self.aclose()
|
|
134
|
+
|
|
135
|
+
async def _request(self, method: str, path: str, **kwargs: Any) -> Dict[str, Any]:
|
|
136
|
+
import asyncio
|
|
137
|
+
url = self._base_url + path
|
|
138
|
+
kwargs.setdefault("headers", {}).update(self._headers)
|
|
139
|
+
last_exc: Optional[SynapsAIError] = None
|
|
140
|
+
|
|
141
|
+
for attempt in range(self._max_retries + 1):
|
|
142
|
+
resp = await self._http.request(method, url, **kwargs)
|
|
143
|
+
if resp.status_code < 500:
|
|
144
|
+
if resp.status_code >= 400:
|
|
145
|
+
raise _map_error(resp)
|
|
146
|
+
return resp.json() if resp.content else {}
|
|
147
|
+
last_exc = _map_error(resp)
|
|
148
|
+
if attempt < self._max_retries:
|
|
149
|
+
await asyncio.sleep(_backoff(attempt))
|
|
150
|
+
|
|
151
|
+
raise last_exc # type: ignore[misc]
|
|
152
|
+
|
|
153
|
+
async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
154
|
+
return await self._request("GET", path, params=params)
|
|
155
|
+
|
|
156
|
+
async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
157
|
+
return await self._request("POST", path, json=json)
|
|
158
|
+
|
|
159
|
+
async def post_form(self, path: str, files: list) -> Dict[str, Any]:
|
|
160
|
+
return await self._request("POST", path, files=files)
|
|
161
|
+
|
|
162
|
+
async def delete(self, path: str) -> Dict[str, Any]:
|
|
163
|
+
return await self._request("DELETE", path)
|
synapsai/_errors.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SynapsAIError(Exception):
|
|
7
|
+
"""Base exception for all SynapsAI SDK errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, *, status: int, code: str) -> None:
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.message = message
|
|
12
|
+
self.status = status
|
|
13
|
+
self.code = code
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthError(SynapsAIError):
|
|
17
|
+
"""API key is missing or invalid (HTTP 401)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RateLimitError(SynapsAIError):
|
|
21
|
+
"""Rate limit exceeded (HTTP 429)."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
message: str,
|
|
26
|
+
*,
|
|
27
|
+
status: int,
|
|
28
|
+
code: str,
|
|
29
|
+
retry_after_ms: int,
|
|
30
|
+
) -> None:
|
|
31
|
+
super().__init__(message, status=status, code=code)
|
|
32
|
+
self.retry_after_ms = retry_after_ms # milliseconds, consistent with TS SDK
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ValidationError(SynapsAIError):
|
|
36
|
+
"""Request payload is invalid (HTTP 400)."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
message: str,
|
|
41
|
+
*,
|
|
42
|
+
status: int,
|
|
43
|
+
code: str,
|
|
44
|
+
field: Optional[str] = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
super().__init__(message, status=status, code=code)
|
|
47
|
+
self.field = field
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NotFoundError(SynapsAIError):
|
|
51
|
+
"""Requested resource does not exist (HTTP 404)."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ServerError(SynapsAIError):
|
|
55
|
+
"""Server returned 5xx after all retries exhausted."""
|
synapsai/_multipart.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import BinaryIO, List, Tuple, Union
|
|
5
|
+
|
|
6
|
+
ImageInput = Union[Path, bytes, BinaryIO]
|
|
7
|
+
|
|
8
|
+
# httpx file tuple: (filename, content, content_type)
|
|
9
|
+
_FileTuple = Tuple[str, bytes, str]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _to_bytes(inp: ImageInput, fallback_name: str = "file") -> Tuple[str, bytes]:
|
|
13
|
+
if isinstance(inp, Path):
|
|
14
|
+
return inp.name, inp.read_bytes()
|
|
15
|
+
if isinstance(inp, bytes):
|
|
16
|
+
return fallback_name, inp
|
|
17
|
+
# file-like object
|
|
18
|
+
raw_name = getattr(inp, "name", fallback_name)
|
|
19
|
+
name = Path(raw_name).name if raw_name else fallback_name
|
|
20
|
+
return name, inp.read()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_files(
|
|
24
|
+
field: str,
|
|
25
|
+
inputs: Union[ImageInput, List[ImageInput]],
|
|
26
|
+
extra: Union[dict, None] = None,
|
|
27
|
+
) -> list:
|
|
28
|
+
"""Return httpx-compatible `files` list for multipart upload."""
|
|
29
|
+
items = inputs if isinstance(inputs, list) else [inputs]
|
|
30
|
+
result: list = []
|
|
31
|
+
for i, inp in enumerate(items):
|
|
32
|
+
name, content = _to_bytes(inp, f"file{i}")
|
|
33
|
+
result.append((field, (name, content, "application/octet-stream")))
|
|
34
|
+
if extra:
|
|
35
|
+
for k, v in extra.items():
|
|
36
|
+
if v is not None:
|
|
37
|
+
result.append((k, (None, str(v), "text/plain")))
|
|
38
|
+
return result
|