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 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