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