synthient 1.0.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.
- synthient/__init__.py +120 -0
- synthient/_async_client.py +383 -0
- synthient/_base.py +300 -0
- synthient/_client.py +439 -0
- synthient/_errors.py +105 -0
- synthient/_models.py +632 -0
- synthient/_ndjson.py +34 -0
- synthient/_retry.py +82 -0
- synthient/grpcschema.py +265 -0
- synthient/py.typed +0 -0
- synthient-1.0.0.dist-info/METADATA +502 -0
- synthient-1.0.0.dist-info/RECORD +14 -0
- synthient-1.0.0.dist-info/WHEEL +4 -0
- synthient-1.0.0.dist-info/licenses/LICENSE +202 -0
synthient/_base.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Callable, Iterable
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import UTC, date, datetime
|
|
10
|
+
from http import HTTPStatus
|
|
11
|
+
from importlib.metadata import version
|
|
12
|
+
from ipaddress import IPv4Address, IPv6Address, ip_address
|
|
13
|
+
from typing import TypeVar
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from pydantic import BaseModel, ValidationError
|
|
18
|
+
|
|
19
|
+
from synthient._errors import APIConnectionError, APITimeoutError, SynthientError
|
|
20
|
+
from synthient._retry import RetryPolicy
|
|
21
|
+
|
|
22
|
+
DEFAULT_BASE_URL = "https://api.synthient.com/api/v4"
|
|
23
|
+
DEFAULT_TIMEOUT = httpx.Timeout(60.0, connect=10.0)
|
|
24
|
+
DEFAULT_MAX_RETRIES = 2
|
|
25
|
+
MAX_RESPONSE_SIZE = 16 << 20
|
|
26
|
+
MAX_ERROR_SIZE = 64 << 10
|
|
27
|
+
MAX_REDIRECTS = 10
|
|
28
|
+
MAX_BATCH_SIZE = 1000
|
|
29
|
+
|
|
30
|
+
# Matches Go's url.PathEscape, so "/" and "?" are escaped and IPv6 colons are kept.
|
|
31
|
+
_PATH_SAFE = "$&+,:;=@"
|
|
32
|
+
_EXPORT_ID = re.compile(r"([0-9]{4}-[0-9]{2}-[0-9]{2})(?:/([0-9]{1,2}))?")
|
|
33
|
+
_USER_AGENT = f"synthient-python/{version('synthient')}"
|
|
34
|
+
|
|
35
|
+
ModelT = TypeVar("ModelT", bound=BaseModel)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ResponseMetadata:
|
|
40
|
+
"""The metadata of one API response, passed to a ``response_hook``.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
request_id: The X-Request-Id header, or "".
|
|
44
|
+
headers: A copy of the response headers, including RateLimit-Limit,
|
|
45
|
+
RateLimit-Remaining, and RateLimit-Reset when present. Headers can contain
|
|
46
|
+
sensitive data, such as presigned download URLs.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
status_code: int
|
|
50
|
+
request_id: str
|
|
51
|
+
headers: httpx.Headers
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
ResponseHook = Callable[[ResponseMetadata], None]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, kw_only=True)
|
|
58
|
+
class Request:
|
|
59
|
+
path: str
|
|
60
|
+
method: str = "GET"
|
|
61
|
+
params: dict[str, str] | None = None
|
|
62
|
+
body: bytes | None = None
|
|
63
|
+
accept: str = "application/json"
|
|
64
|
+
policy: RetryPolicy = RetryPolicy.TRANSIENT
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, kw_only=True)
|
|
68
|
+
class Config:
|
|
69
|
+
api_key: str = field(repr=False)
|
|
70
|
+
base_url: str
|
|
71
|
+
max_retries: int
|
|
72
|
+
timeout: httpx.Timeout
|
|
73
|
+
user_agent: str
|
|
74
|
+
response_hook: ResponseHook | None
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def create(
|
|
78
|
+
cls,
|
|
79
|
+
api_key: str | None,
|
|
80
|
+
*,
|
|
81
|
+
base_url: str,
|
|
82
|
+
max_retries: int,
|
|
83
|
+
timeout: float | httpx.Timeout | None,
|
|
84
|
+
user_agent: str | None,
|
|
85
|
+
response_hook: ResponseHook | None,
|
|
86
|
+
) -> Config:
|
|
87
|
+
api_key = resolve_api_key(api_key)
|
|
88
|
+
if not api_key:
|
|
89
|
+
raise ValueError("missing API key: pass api_key or set SYNTHIENT_API_KEY")
|
|
90
|
+
if isinstance(max_retries, bool) or not isinstance(max_retries, int) or max_retries < 0:
|
|
91
|
+
raise ValueError("max_retries must be a non-negative integer")
|
|
92
|
+
request_timeout = httpx.Timeout(timeout)
|
|
93
|
+
for value in request_timeout.as_dict().values():
|
|
94
|
+
if value is not None and (
|
|
95
|
+
isinstance(value, bool)
|
|
96
|
+
or not isinstance(value, int | float)
|
|
97
|
+
or not math.isfinite(value)
|
|
98
|
+
or value < 0
|
|
99
|
+
):
|
|
100
|
+
raise ValueError("timeout values must be finite, non-negative numbers or None")
|
|
101
|
+
return cls(
|
|
102
|
+
api_key=api_key,
|
|
103
|
+
base_url=_parse_base_url(base_url),
|
|
104
|
+
max_retries=max_retries,
|
|
105
|
+
timeout=request_timeout,
|
|
106
|
+
user_agent=f"{_USER_AGENT} {user_agent}" if user_agent else _USER_AGENT,
|
|
107
|
+
response_hook=response_hook,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class BaseTransport:
|
|
112
|
+
def __init__(self, config: Config) -> None:
|
|
113
|
+
self.config: Config = config
|
|
114
|
+
self.closed: bool = False
|
|
115
|
+
|
|
116
|
+
def build_request(
|
|
117
|
+
self, http: httpx.Client | httpx.AsyncClient, request: Request
|
|
118
|
+
) -> httpx.Request:
|
|
119
|
+
if self.closed:
|
|
120
|
+
raise RuntimeError("the client is closed")
|
|
121
|
+
headers = {
|
|
122
|
+
"X-Api-Key": self.config.api_key,
|
|
123
|
+
"User-Agent": self.config.user_agent,
|
|
124
|
+
"Accept": request.accept,
|
|
125
|
+
}
|
|
126
|
+
if request.body is not None:
|
|
127
|
+
headers["Content-Type"] = "application/json"
|
|
128
|
+
return http.build_request(
|
|
129
|
+
request.method,
|
|
130
|
+
f"{self.config.base_url}{request.path}",
|
|
131
|
+
params=request.params,
|
|
132
|
+
content=request.body,
|
|
133
|
+
headers=headers,
|
|
134
|
+
timeout=self.config.timeout,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def build_storage_request(self, url: httpx.URL) -> httpx.Request:
|
|
138
|
+
# Client defaults can leak credentials or replace a presigned URL's query.
|
|
139
|
+
return httpx.Request(
|
|
140
|
+
"GET",
|
|
141
|
+
url,
|
|
142
|
+
headers={"User-Agent": self.config.user_agent},
|
|
143
|
+
extensions={"timeout": self.config.timeout.as_dict()},
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def notify(self, response: httpx.Response) -> None:
|
|
147
|
+
if self.config.response_hook is not None:
|
|
148
|
+
self.config.response_hook(
|
|
149
|
+
ResponseMetadata(
|
|
150
|
+
status_code=response.status_code,
|
|
151
|
+
request_id=response.headers.get("x-request-id", ""),
|
|
152
|
+
headers=httpx.Headers(response.headers),
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def export_location(self, response: httpx.Response) -> str:
|
|
157
|
+
if response.status_code < HTTPStatus.MULTIPLE_CHOICES:
|
|
158
|
+
raise SynthientError(
|
|
159
|
+
f"expected a redirect to the export, got status {response.status_code}"
|
|
160
|
+
)
|
|
161
|
+
return str(storage_redirect(response))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def resolve_api_key(api_key: str | None) -> str:
|
|
165
|
+
if api_key is None:
|
|
166
|
+
api_key = os.environ.get("SYNTHIENT_API_KEY", "")
|
|
167
|
+
if not isinstance(api_key, str):
|
|
168
|
+
raise TypeError("api_key must be a string")
|
|
169
|
+
api_key = api_key.strip()
|
|
170
|
+
# Validate here because h11's error for an illegal header value quotes the key.
|
|
171
|
+
if not (api_key.isascii() and api_key.isprintable()):
|
|
172
|
+
raise ValueError("invalid API key: it must be printable ASCII")
|
|
173
|
+
return api_key
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _parse_base_url(raw: str) -> str:
|
|
177
|
+
base_url = raw.removesuffix("/")
|
|
178
|
+
try:
|
|
179
|
+
url = httpx.URL(base_url)
|
|
180
|
+
except httpx.InvalidURL as exc:
|
|
181
|
+
raise ValueError(f"invalid base URL {raw!r}") from exc
|
|
182
|
+
if (
|
|
183
|
+
url.scheme not in {"http", "https"}
|
|
184
|
+
or not url.host
|
|
185
|
+
or url.query
|
|
186
|
+
or url.fragment
|
|
187
|
+
or re.search(r"%(?![0-9A-Fa-f]{2})", base_url)
|
|
188
|
+
):
|
|
189
|
+
raise ValueError(f"invalid base URL {raw!r}")
|
|
190
|
+
return base_url
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _ip(value: str | IPv4Address | IPv6Address) -> str:
|
|
194
|
+
if not isinstance(value, str | IPv4Address | IPv6Address):
|
|
195
|
+
raise TypeError(f"expected an IP address, got {type(value).__name__}")
|
|
196
|
+
return str(ip_address(value))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _limit(limit: int | None) -> dict[str, str]:
|
|
200
|
+
if limit is None:
|
|
201
|
+
return {}
|
|
202
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1:
|
|
203
|
+
raise ValueError("limit must be a positive integer")
|
|
204
|
+
return {"limit": str(limit)}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def lookup_ip_request(ip: str | IPv4Address | IPv6Address) -> Request:
|
|
208
|
+
return Request(
|
|
209
|
+
path=f"/lookup/ip/{quote(_ip(ip), safe=_PATH_SAFE)}", policy=RetryPolicy.RATE_LIMIT
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def lookup_ips_request(ips: Iterable[str | IPv4Address | IPv6Address]) -> Request:
|
|
214
|
+
addresses: list[str] = []
|
|
215
|
+
for index, ip in enumerate(ips):
|
|
216
|
+
try:
|
|
217
|
+
addresses.append(_ip(ip))
|
|
218
|
+
except ValueError as exc:
|
|
219
|
+
raise ValueError(f"invalid IP address at index {index}") from exc
|
|
220
|
+
if len(addresses) > MAX_BATCH_SIZE:
|
|
221
|
+
raise ValueError(f"a batch holds at most {MAX_BATCH_SIZE} addresses, got {len(addresses)}")
|
|
222
|
+
return Request(
|
|
223
|
+
method="POST",
|
|
224
|
+
path="/lookup/ips",
|
|
225
|
+
body=json.dumps({"ips": addresses}).encode(),
|
|
226
|
+
policy=RetryPolicy.RATE_LIMIT,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def lookup_domain_request(domain: str, limit: int | None) -> Request:
|
|
231
|
+
if domain in {"", ".", ".."}:
|
|
232
|
+
raise ValueError(f"invalid domain {domain!r}")
|
|
233
|
+
return Request(
|
|
234
|
+
path=f"/lookup/domain/{quote(domain, safe=_PATH_SAFE)}",
|
|
235
|
+
params=_limit(limit),
|
|
236
|
+
policy=RetryPolicy.RATE_LIMIT,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def list_exports_request(feed_path: str, limit: int | None, cursor: str | None) -> Request:
|
|
241
|
+
params = _limit(limit)
|
|
242
|
+
if cursor:
|
|
243
|
+
params["cursor"] = cursor
|
|
244
|
+
return Request(path=f"{feed_path}/export", params=params)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def export_path(export: str | date) -> str:
|
|
248
|
+
if isinstance(export, datetime):
|
|
249
|
+
if export.tzinfo is None:
|
|
250
|
+
raise ValueError("export datetime must be timezone-aware")
|
|
251
|
+
hour = export.astimezone(UTC)
|
|
252
|
+
return f"{hour:%Y-%m-%d}/{hour.hour}"
|
|
253
|
+
if isinstance(export, date):
|
|
254
|
+
return export.isoformat()
|
|
255
|
+
if export == "latest":
|
|
256
|
+
return export
|
|
257
|
+
invalid = ValueError(f"invalid export ID {export!r}")
|
|
258
|
+
match = _EXPORT_ID.fullmatch(export)
|
|
259
|
+
if match is None:
|
|
260
|
+
raise invalid
|
|
261
|
+
try:
|
|
262
|
+
day = date.fromisoformat(match[1])
|
|
263
|
+
except ValueError:
|
|
264
|
+
raise invalid from None
|
|
265
|
+
if match[2] is None:
|
|
266
|
+
return day.isoformat()
|
|
267
|
+
hour = int(match[2])
|
|
268
|
+
if hour > 23: # noqa: PLR2004
|
|
269
|
+
raise invalid
|
|
270
|
+
return f"{day.isoformat()}/{hour}"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def decode(body: bytes, model: type[ModelT]) -> ModelT:
|
|
274
|
+
try:
|
|
275
|
+
return model.model_validate_json(body)
|
|
276
|
+
except ValidationError as exc:
|
|
277
|
+
raise SynthientError(f"invalid {model.__name__} in response") from exc
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def connection_error(exc: httpx.TransportError) -> APIConnectionError:
|
|
281
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
282
|
+
return APITimeoutError("request timed out")
|
|
283
|
+
return APIConnectionError("connection failed")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def storage_redirect(response: httpx.Response) -> httpx.URL:
|
|
287
|
+
location = response.headers.get("location", "").strip()
|
|
288
|
+
if not response.has_redirect_location or not location:
|
|
289
|
+
raise SynthientError("export redirect has no valid location")
|
|
290
|
+
try:
|
|
291
|
+
target = response.url.join(location)
|
|
292
|
+
except httpx.InvalidURL:
|
|
293
|
+
raise SynthientError("export redirect has no valid location") from None
|
|
294
|
+
if target.scheme not in {"http", "https"}:
|
|
295
|
+
raise SynthientError("export redirect has an unsupported scheme")
|
|
296
|
+
if response.url.scheme == "https" and target.scheme != "https":
|
|
297
|
+
raise SynthientError("export redirect does not use https")
|
|
298
|
+
if not target.host or target.userinfo:
|
|
299
|
+
raise SynthientError("export redirect has no valid location")
|
|
300
|
+
return target
|