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