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/__init__.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""The official Python client for the Synthient API: IP and domain intelligence, account
|
|
2
|
+
details, and real-time and bulk data feeds.
|
|
3
|
+
|
|
4
|
+
Create a client and reuse it for every request::
|
|
5
|
+
|
|
6
|
+
import synthient
|
|
7
|
+
|
|
8
|
+
with synthient.Client() as client:
|
|
9
|
+
info = client.lookup_ip("8.8.8.8")
|
|
10
|
+
if info.intelligence is not None:
|
|
11
|
+
print(info.intelligence.risk_score)
|
|
12
|
+
|
|
13
|
+
``AsyncClient`` offers the same API for asyncio and Trio.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from importlib.metadata import version
|
|
17
|
+
|
|
18
|
+
from synthient._async_client import AsyncClient, AsyncFeed
|
|
19
|
+
from synthient._base import DEFAULT_BASE_URL, ResponseHook, ResponseMetadata
|
|
20
|
+
from synthient._client import Client, Feed
|
|
21
|
+
from synthient._errors import APIConnectionError, APIError, APITimeoutError, SynthientError
|
|
22
|
+
from synthient._models import (
|
|
23
|
+
Account,
|
|
24
|
+
AccountAPIKey,
|
|
25
|
+
ActivityStats,
|
|
26
|
+
AnonymizerEvent,
|
|
27
|
+
ASNCount,
|
|
28
|
+
CountryCount,
|
|
29
|
+
Device,
|
|
30
|
+
DomainEvent,
|
|
31
|
+
DomainInfo,
|
|
32
|
+
DomainStats,
|
|
33
|
+
Export,
|
|
34
|
+
ExportMeta,
|
|
35
|
+
ExportPage,
|
|
36
|
+
HeliosADBEvent,
|
|
37
|
+
HeliosDNSEvent,
|
|
38
|
+
HeliosHTTPEvent,
|
|
39
|
+
HeliosHTTPSEvent,
|
|
40
|
+
HeliosMeta,
|
|
41
|
+
HTTPRequestDetails,
|
|
42
|
+
Intelligence,
|
|
43
|
+
IPInfo,
|
|
44
|
+
JA4TEvent,
|
|
45
|
+
Location,
|
|
46
|
+
LookupQuota,
|
|
47
|
+
Network,
|
|
48
|
+
Organization,
|
|
49
|
+
ParquetColumn,
|
|
50
|
+
ParquetSchema,
|
|
51
|
+
PortCount,
|
|
52
|
+
Provider,
|
|
53
|
+
ProxyEvent,
|
|
54
|
+
SubdomainCount,
|
|
55
|
+
TimeSeriesPoint,
|
|
56
|
+
TLSClientHello,
|
|
57
|
+
TLSCodeName,
|
|
58
|
+
TLSExtension,
|
|
59
|
+
TorrentEvent,
|
|
60
|
+
TorrentFile,
|
|
61
|
+
TorrentPeer,
|
|
62
|
+
UniqueIPs,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
__version__ = version("synthient")
|
|
66
|
+
|
|
67
|
+
__all__ = [
|
|
68
|
+
"DEFAULT_BASE_URL",
|
|
69
|
+
"APIConnectionError",
|
|
70
|
+
"APIError",
|
|
71
|
+
"APITimeoutError",
|
|
72
|
+
"ASNCount",
|
|
73
|
+
"Account",
|
|
74
|
+
"AccountAPIKey",
|
|
75
|
+
"ActivityStats",
|
|
76
|
+
"AnonymizerEvent",
|
|
77
|
+
"AsyncClient",
|
|
78
|
+
"AsyncFeed",
|
|
79
|
+
"Client",
|
|
80
|
+
"CountryCount",
|
|
81
|
+
"Device",
|
|
82
|
+
"DomainEvent",
|
|
83
|
+
"DomainInfo",
|
|
84
|
+
"DomainStats",
|
|
85
|
+
"Export",
|
|
86
|
+
"ExportMeta",
|
|
87
|
+
"ExportPage",
|
|
88
|
+
"Feed",
|
|
89
|
+
"HTTPRequestDetails",
|
|
90
|
+
"HeliosADBEvent",
|
|
91
|
+
"HeliosDNSEvent",
|
|
92
|
+
"HeliosHTTPEvent",
|
|
93
|
+
"HeliosHTTPSEvent",
|
|
94
|
+
"HeliosMeta",
|
|
95
|
+
"IPInfo",
|
|
96
|
+
"Intelligence",
|
|
97
|
+
"JA4TEvent",
|
|
98
|
+
"Location",
|
|
99
|
+
"LookupQuota",
|
|
100
|
+
"Network",
|
|
101
|
+
"Organization",
|
|
102
|
+
"ParquetColumn",
|
|
103
|
+
"ParquetSchema",
|
|
104
|
+
"PortCount",
|
|
105
|
+
"Provider",
|
|
106
|
+
"ProxyEvent",
|
|
107
|
+
"ResponseHook",
|
|
108
|
+
"ResponseMetadata",
|
|
109
|
+
"SubdomainCount",
|
|
110
|
+
"SynthientError",
|
|
111
|
+
"TLSClientHello",
|
|
112
|
+
"TLSCodeName",
|
|
113
|
+
"TLSExtension",
|
|
114
|
+
"TimeSeriesPoint",
|
|
115
|
+
"TorrentEvent",
|
|
116
|
+
"TorrentFile",
|
|
117
|
+
"TorrentPeer",
|
|
118
|
+
"UniqueIPs",
|
|
119
|
+
"__version__",
|
|
120
|
+
]
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Iterable
|
|
4
|
+
from contextlib import aclosing, asynccontextmanager
|
|
5
|
+
from datetime import date
|
|
6
|
+
from http import HTTPStatus
|
|
7
|
+
from ipaddress import IPv4Address, IPv6Address
|
|
8
|
+
from typing import Generic, Self, cast
|
|
9
|
+
|
|
10
|
+
import anyio
|
|
11
|
+
import httpx
|
|
12
|
+
from anyio import sleep
|
|
13
|
+
|
|
14
|
+
from synthient._base import (
|
|
15
|
+
DEFAULT_BASE_URL,
|
|
16
|
+
DEFAULT_MAX_RETRIES,
|
|
17
|
+
DEFAULT_TIMEOUT,
|
|
18
|
+
MAX_ERROR_SIZE,
|
|
19
|
+
MAX_REDIRECTS,
|
|
20
|
+
MAX_RESPONSE_SIZE,
|
|
21
|
+
BaseTransport,
|
|
22
|
+
Config,
|
|
23
|
+
ModelT,
|
|
24
|
+
Request,
|
|
25
|
+
ResponseHook,
|
|
26
|
+
connection_error,
|
|
27
|
+
decode,
|
|
28
|
+
export_path,
|
|
29
|
+
list_exports_request,
|
|
30
|
+
lookup_domain_request,
|
|
31
|
+
lookup_ip_request,
|
|
32
|
+
lookup_ips_request,
|
|
33
|
+
storage_redirect,
|
|
34
|
+
)
|
|
35
|
+
from synthient._errors import APIError, SynthientError, api_error
|
|
36
|
+
from synthient._models import (
|
|
37
|
+
Account,
|
|
38
|
+
AnonymizerEvent,
|
|
39
|
+
DomainInfo,
|
|
40
|
+
Export,
|
|
41
|
+
ExportMeta,
|
|
42
|
+
ExportPage,
|
|
43
|
+
HealthResponse,
|
|
44
|
+
HeliosADBEvent,
|
|
45
|
+
HeliosDNSEvent,
|
|
46
|
+
HeliosHTTPEvent,
|
|
47
|
+
HeliosHTTPSEvent,
|
|
48
|
+
IPBatchResponse,
|
|
49
|
+
IPInfo,
|
|
50
|
+
JA4TEvent,
|
|
51
|
+
ProxyEvent,
|
|
52
|
+
TorrentEvent,
|
|
53
|
+
)
|
|
54
|
+
from synthient._ndjson import LineBuffer
|
|
55
|
+
from synthient._retry import RetryPolicy, backoff, is_transient, retry_delay
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def _close_http(
|
|
59
|
+
resource: httpx.AsyncClient | httpx.Response | httpx.AsyncByteStream,
|
|
60
|
+
) -> None:
|
|
61
|
+
# AnyIO cancellation would otherwise interrupt the awaits that release connections.
|
|
62
|
+
with anyio.move_on_after(5, shield=True):
|
|
63
|
+
await resource.aclose()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _ClosingStream(httpx.AsyncByteStream):
|
|
67
|
+
"""Routes the close HTTPX makes itself after a full read through ``_close_http``."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, stream: httpx.AsyncByteStream) -> None:
|
|
70
|
+
self._stream = stream
|
|
71
|
+
|
|
72
|
+
def __aiter__(self) -> AsyncIterator[bytes]:
|
|
73
|
+
return self._stream.__aiter__()
|
|
74
|
+
|
|
75
|
+
async def aclose(self) -> None:
|
|
76
|
+
await _close_http(self._stream)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AsyncTransport(BaseTransport):
|
|
80
|
+
def __init__(self, config: Config, http: httpx.AsyncClient | None) -> None:
|
|
81
|
+
super().__init__(config)
|
|
82
|
+
self.owns_http: bool = http is None
|
|
83
|
+
self.http: httpx.AsyncClient = http or httpx.AsyncClient()
|
|
84
|
+
|
|
85
|
+
async def aclose(self) -> None:
|
|
86
|
+
if self.owns_http and not self.closed:
|
|
87
|
+
await _close_http(self.http)
|
|
88
|
+
self.closed = True
|
|
89
|
+
|
|
90
|
+
async def send(self, request: Request) -> httpx.Response:
|
|
91
|
+
"""Return the unread response, which the caller must close."""
|
|
92
|
+
attempt = 0
|
|
93
|
+
while True:
|
|
94
|
+
can_retry = attempt < self.config.max_retries
|
|
95
|
+
try:
|
|
96
|
+
response = await self.http.send(
|
|
97
|
+
self.build_request(self.http, request), stream=True, follow_redirects=False
|
|
98
|
+
)
|
|
99
|
+
except httpx.TransportError as exc:
|
|
100
|
+
if not (
|
|
101
|
+
can_retry and request.policy is RetryPolicy.TRANSIENT and is_transient(exc)
|
|
102
|
+
):
|
|
103
|
+
raise connection_error(exc) from exc
|
|
104
|
+
await sleep(backoff(attempt))
|
|
105
|
+
attempt += 1
|
|
106
|
+
continue
|
|
107
|
+
if isinstance(response.stream, httpx.AsyncByteStream):
|
|
108
|
+
response.stream = _ClosingStream(response.stream)
|
|
109
|
+
try:
|
|
110
|
+
self.notify(response)
|
|
111
|
+
except BaseException:
|
|
112
|
+
await _close_http(response)
|
|
113
|
+
raise
|
|
114
|
+
delay = retry_delay(request.policy, response, attempt) if can_retry else None
|
|
115
|
+
if delay is None:
|
|
116
|
+
return response
|
|
117
|
+
await _close_http(response)
|
|
118
|
+
await sleep(delay)
|
|
119
|
+
attempt += 1
|
|
120
|
+
|
|
121
|
+
async def read(self, response: httpx.Response, limit: int) -> bytes:
|
|
122
|
+
"""Read at most ``limit + 1`` bytes, so callers can tell when a body exceeds ``limit``."""
|
|
123
|
+
body = bytearray()
|
|
124
|
+
chunks = cast(AsyncGenerator[bytes, None], response.aiter_bytes())
|
|
125
|
+
async with aclosing(chunks):
|
|
126
|
+
async for chunk in chunks:
|
|
127
|
+
body += chunk[: limit + 1 - len(body)]
|
|
128
|
+
if len(body) > limit:
|
|
129
|
+
break
|
|
130
|
+
return bytes(body)
|
|
131
|
+
|
|
132
|
+
async def api_error(self, response: httpx.Response) -> APIError:
|
|
133
|
+
try:
|
|
134
|
+
body = await self.read(response, MAX_ERROR_SIZE)
|
|
135
|
+
except (httpx.TransportError, httpx.DecodingError) as exc:
|
|
136
|
+
error = api_error(response, b"")
|
|
137
|
+
error.__cause__ = exc
|
|
138
|
+
return error
|
|
139
|
+
return api_error(response, body[:MAX_ERROR_SIZE])
|
|
140
|
+
|
|
141
|
+
async def request(self, request: Request, model: type[ModelT]) -> ModelT:
|
|
142
|
+
response = await self.send(request)
|
|
143
|
+
try:
|
|
144
|
+
if not response.is_success:
|
|
145
|
+
raise await self.api_error(response)
|
|
146
|
+
try:
|
|
147
|
+
body = await self.read(response, MAX_RESPONSE_SIZE)
|
|
148
|
+
except httpx.TransportError as exc:
|
|
149
|
+
raise connection_error(exc) from exc
|
|
150
|
+
except httpx.DecodingError as exc:
|
|
151
|
+
raise SynthientError("invalid response encoding") from exc
|
|
152
|
+
finally:
|
|
153
|
+
await _close_http(response)
|
|
154
|
+
if len(body) > MAX_RESPONSE_SIZE:
|
|
155
|
+
raise SynthientError(f"response body exceeds {MAX_RESPONSE_SIZE} bytes")
|
|
156
|
+
return decode(body, model)
|
|
157
|
+
|
|
158
|
+
async def open_stream(self, path: str) -> httpx.Response:
|
|
159
|
+
response = await self.send(Request(path=f"{path}/stream", accept="application/x-ndjson"))
|
|
160
|
+
if response.status_code != HTTPStatus.OK:
|
|
161
|
+
try:
|
|
162
|
+
raise await self.api_error(response)
|
|
163
|
+
finally:
|
|
164
|
+
await _close_http(response)
|
|
165
|
+
return response
|
|
166
|
+
|
|
167
|
+
async def download_url(self, path: str) -> str:
|
|
168
|
+
response = await self.send(Request(path=path))
|
|
169
|
+
try:
|
|
170
|
+
if response.status_code >= HTTPStatus.BAD_REQUEST:
|
|
171
|
+
raise await self.api_error(response)
|
|
172
|
+
return self.export_location(response)
|
|
173
|
+
finally:
|
|
174
|
+
await _close_http(response)
|
|
175
|
+
|
|
176
|
+
async def open_download(self, url: str) -> httpx.Response:
|
|
177
|
+
target = httpx.URL(url)
|
|
178
|
+
for _ in range(MAX_REDIRECTS + 1):
|
|
179
|
+
try:
|
|
180
|
+
response = await self.http.send(
|
|
181
|
+
self.build_storage_request(target),
|
|
182
|
+
auth=None,
|
|
183
|
+
stream=True,
|
|
184
|
+
follow_redirects=False,
|
|
185
|
+
)
|
|
186
|
+
except httpx.TransportError as exc:
|
|
187
|
+
raise connection_error(exc) from exc
|
|
188
|
+
if isinstance(response.stream, httpx.AsyncByteStream):
|
|
189
|
+
response.stream = _ClosingStream(response.stream)
|
|
190
|
+
if not response.is_redirect:
|
|
191
|
+
break
|
|
192
|
+
try:
|
|
193
|
+
target = storage_redirect(response)
|
|
194
|
+
finally:
|
|
195
|
+
await _close_http(response)
|
|
196
|
+
else:
|
|
197
|
+
raise SynthientError("too many export redirects")
|
|
198
|
+
if response.status_code != HTTPStatus.OK:
|
|
199
|
+
try:
|
|
200
|
+
raise await self.api_error(response)
|
|
201
|
+
finally:
|
|
202
|
+
await _close_http(response)
|
|
203
|
+
return response
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class AsyncClient:
|
|
207
|
+
"""An asyncio and Trio client for the Synthient API.
|
|
208
|
+
|
|
209
|
+
It takes the same arguments and behaves the same as ``Client``, with an
|
|
210
|
+
``httpx.AsyncClient`` for ``http_client``. Close it with ``aclose()`` or an
|
|
211
|
+
``async with`` block. Use one client per event loop.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
proxies: AsyncFeed[ProxyEvent]
|
|
215
|
+
"""The residential, mobile, and datacenter proxy feed."""
|
|
216
|
+
anonymizers: AsyncFeed[AnonymizerEvent]
|
|
217
|
+
"""The VPN and anonymizer IP range feed."""
|
|
218
|
+
torrents: AsyncFeed[TorrentEvent]
|
|
219
|
+
"""The BitTorrent DHT and tracker feed."""
|
|
220
|
+
ja4t: AsyncFeed[JA4TEvent]
|
|
221
|
+
"""The JA4T TCP fingerprint feed."""
|
|
222
|
+
helios_http: AsyncFeed[HeliosHTTPEvent]
|
|
223
|
+
"""The Helios honeypot HTTP capture feed."""
|
|
224
|
+
helios_https: AsyncFeed[HeliosHTTPSEvent]
|
|
225
|
+
"""The Helios honeypot TLS ClientHello capture feed."""
|
|
226
|
+
helios_dns: AsyncFeed[HeliosDNSEvent]
|
|
227
|
+
"""The Helios honeypot DNS tunnel destination feed."""
|
|
228
|
+
helios_adb: AsyncFeed[HeliosADBEvent]
|
|
229
|
+
"""The Helios honeypot Android Debug Bridge command feed."""
|
|
230
|
+
|
|
231
|
+
def __init__(
|
|
232
|
+
self,
|
|
233
|
+
api_key: str | None = None,
|
|
234
|
+
*,
|
|
235
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
236
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
237
|
+
timeout: float | httpx.Timeout | None = DEFAULT_TIMEOUT,
|
|
238
|
+
http_client: httpx.AsyncClient | None = None,
|
|
239
|
+
user_agent: str | None = None,
|
|
240
|
+
response_hook: ResponseHook | None = None,
|
|
241
|
+
) -> None:
|
|
242
|
+
config = Config.create(
|
|
243
|
+
api_key,
|
|
244
|
+
base_url=base_url,
|
|
245
|
+
max_retries=max_retries,
|
|
246
|
+
timeout=timeout,
|
|
247
|
+
user_agent=user_agent,
|
|
248
|
+
response_hook=response_hook,
|
|
249
|
+
)
|
|
250
|
+
self._transport = AsyncTransport(config, http_client)
|
|
251
|
+
self.proxies = AsyncFeed(self._transport, "/feeds/proxies", ProxyEvent)
|
|
252
|
+
self.anonymizers = AsyncFeed(self._transport, "/feeds/anonymizers", AnonymizerEvent)
|
|
253
|
+
self.torrents = AsyncFeed(self._transport, "/feeds/torrents", TorrentEvent)
|
|
254
|
+
self.ja4t = AsyncFeed(self._transport, "/feeds/ja4t", JA4TEvent)
|
|
255
|
+
self.helios_http = AsyncFeed(self._transport, "/feeds/helio/http", HeliosHTTPEvent)
|
|
256
|
+
self.helios_https = AsyncFeed(self._transport, "/feeds/helio/https", HeliosHTTPSEvent)
|
|
257
|
+
self.helios_dns = AsyncFeed(self._transport, "/feeds/helio/dns", HeliosDNSEvent)
|
|
258
|
+
self.helios_adb = AsyncFeed(self._transport, "/feeds/helio/adb", HeliosADBEvent)
|
|
259
|
+
|
|
260
|
+
async def aclose(self) -> None:
|
|
261
|
+
"""Close the connection pool, unless it belongs to ``http_client``.
|
|
262
|
+
|
|
263
|
+
Calling it again does nothing, and later requests raise ``RuntimeError``.
|
|
264
|
+
"""
|
|
265
|
+
await self._transport.aclose()
|
|
266
|
+
|
|
267
|
+
async def __aenter__(self) -> Self:
|
|
268
|
+
return self
|
|
269
|
+
|
|
270
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
271
|
+
await self.aclose()
|
|
272
|
+
|
|
273
|
+
async def account(self) -> Account:
|
|
274
|
+
"""Return the account that owns the API key, including its remaining credits."""
|
|
275
|
+
return await self._transport.request(Request(path="/account/me"), Account)
|
|
276
|
+
|
|
277
|
+
async def health(self) -> None:
|
|
278
|
+
"""Check that the API is available. See ``Client.health``."""
|
|
279
|
+
# A 503 from /health is the answer, so only 429 is retried.
|
|
280
|
+
await self._transport.request(
|
|
281
|
+
Request(path="/health", policy=RetryPolicy.RATE_LIMIT), HealthResponse
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
async def lookup_ip(self, ip: str | IPv4Address | IPv6Address) -> IPInfo:
|
|
285
|
+
"""Return intelligence for an IPv4 or IPv6 address. See ``Client.lookup_ip``."""
|
|
286
|
+
return await self._transport.request(lookup_ip_request(ip), IPInfo)
|
|
287
|
+
|
|
288
|
+
async def lookup_ips(self, ips: Iterable[str | IPv4Address | IPv6Address]) -> list[IPInfo]:
|
|
289
|
+
"""Look up as many as 1000 addresses in one request. See ``Client.lookup_ips``."""
|
|
290
|
+
addresses = list(ips)
|
|
291
|
+
if not addresses:
|
|
292
|
+
return []
|
|
293
|
+
batch = await self._transport.request(lookup_ips_request(addresses), IPBatchResponse)
|
|
294
|
+
return batch.results
|
|
295
|
+
|
|
296
|
+
async def lookup_domain(self, domain: str, *, limit: int | None = None) -> DomainInfo:
|
|
297
|
+
"""Return the honeypot traffic observed for a domain. See ``Client.lookup_domain``."""
|
|
298
|
+
return await self._transport.request(lookup_domain_request(domain, limit), DomainInfo)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class AsyncFeed(Generic[ModelT]):
|
|
302
|
+
"""The async counterpart of ``Feed``. Access feeds through an ``AsyncClient``."""
|
|
303
|
+
|
|
304
|
+
def __init__(self, transport: AsyncTransport, path: str, event: type[ModelT]) -> None:
|
|
305
|
+
self._transport = transport
|
|
306
|
+
self._path = path
|
|
307
|
+
self._event = event
|
|
308
|
+
|
|
309
|
+
@asynccontextmanager
|
|
310
|
+
async def stream(self) -> AsyncGenerator[AsyncIterator[ModelT], None]:
|
|
311
|
+
"""Open the feed's real-time stream and yield an async iterator over its events.
|
|
312
|
+
|
|
313
|
+
See ``Feed.stream``.
|
|
314
|
+
"""
|
|
315
|
+
response = await self._transport.open_stream(self._path)
|
|
316
|
+
events = self._events(response)
|
|
317
|
+
try:
|
|
318
|
+
yield events
|
|
319
|
+
finally:
|
|
320
|
+
try:
|
|
321
|
+
await events.aclose()
|
|
322
|
+
finally:
|
|
323
|
+
await _close_http(response)
|
|
324
|
+
|
|
325
|
+
async def _events(self, response: httpx.Response) -> AsyncGenerator[ModelT, None]:
|
|
326
|
+
lines = LineBuffer()
|
|
327
|
+
try:
|
|
328
|
+
async for chunk in response.aiter_bytes():
|
|
329
|
+
for line in lines.feed(chunk):
|
|
330
|
+
yield decode(line, self._event)
|
|
331
|
+
except httpx.TransportError as exc:
|
|
332
|
+
raise connection_error(exc) from exc
|
|
333
|
+
except httpx.DecodingError as exc:
|
|
334
|
+
raise SynthientError("invalid response encoding") from exc
|
|
335
|
+
last = lines.finish()
|
|
336
|
+
if last is not None:
|
|
337
|
+
yield decode(last, self._event)
|
|
338
|
+
|
|
339
|
+
async def list_exports(
|
|
340
|
+
self, *, limit: int | None = None, cursor: str | None = None
|
|
341
|
+
) -> ExportPage:
|
|
342
|
+
"""Return one page of the feed's exports, newest first. See ``Feed.list_exports``."""
|
|
343
|
+
return await self._transport.request(
|
|
344
|
+
list_exports_request(self._path, limit, cursor), ExportPage
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
async def iter_exports(
|
|
348
|
+
self, *, limit: int | None = None, cursor: str | None = None
|
|
349
|
+
) -> AsyncIterator[Export]:
|
|
350
|
+
"""Iterate over the feed's exports, newest first. See ``Feed.iter_exports``."""
|
|
351
|
+
seen = {cursor}
|
|
352
|
+
while True:
|
|
353
|
+
page = await self.list_exports(limit=limit, cursor=cursor)
|
|
354
|
+
for export in page.exports:
|
|
355
|
+
yield export
|
|
356
|
+
if not page.next_cursor:
|
|
357
|
+
return
|
|
358
|
+
if page.next_cursor in seen:
|
|
359
|
+
raise SynthientError("export listing returned a repeated cursor")
|
|
360
|
+
seen.add(page.next_cursor)
|
|
361
|
+
cursor = page.next_cursor
|
|
362
|
+
|
|
363
|
+
async def export_meta(self, export: str | date) -> ExportMeta:
|
|
364
|
+
"""Return the checksum, size, row count, and parquet schema of an export."""
|
|
365
|
+
return await self._transport.request(
|
|
366
|
+
Request(path=f"{self._path}/export/{export_path(export)}/meta"), ExportMeta
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
async def download_url(self, export: str | date) -> str:
|
|
370
|
+
"""Return a presigned URL for an export's parquet file. See ``Feed.download_url``."""
|
|
371
|
+
return await self._transport.download_url(f"{self._path}/export/{export_path(export)}")
|
|
372
|
+
|
|
373
|
+
@asynccontextmanager
|
|
374
|
+
async def download(self, export: str | date) -> AsyncGenerator[httpx.Response, None]:
|
|
375
|
+
"""Download an export's parquet file and yield the streaming httpx response.
|
|
376
|
+
|
|
377
|
+
Read the body with ``response.aiter_bytes()``. See ``Feed.download``.
|
|
378
|
+
"""
|
|
379
|
+
response = await self._transport.open_download(await self.download_url(export))
|
|
380
|
+
try:
|
|
381
|
+
yield response
|
|
382
|
+
finally:
|
|
383
|
+
await _close_http(response)
|