sightingdb-client 0.0.1__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.
sightingdb/__init__.py ADDED
@@ -0,0 +1,85 @@
1
+ """The Python client for SightingDB.
2
+
3
+ SightingDB counts things: how many times a value was seen, when it was first
4
+ seen, when it was last seen, and how many namespaces hold it. This library is
5
+ that REST API as Python objects.
6
+
7
+ import sightingdb
8
+
9
+ with sightingdb.SightingDB("https://localhost:9999", apikey="changeme") as db:
10
+ db.write("feeds/misp/ips", "127.0.0.1")
11
+ attribute = db.read("feeds/misp/ips", "127.0.0.1")
12
+ print(attribute.count, attribute.first_seen_at)
13
+
14
+ The same API is available for asyncio as
15
+ :class:`~sightingdb.AsyncSightingDB`, with every call awaited.
16
+ """
17
+
18
+ from .__version__ import (
19
+ __author__,
20
+ __author_email__,
21
+ __description__,
22
+ __license__,
23
+ __url__,
24
+ __version__,
25
+ )
26
+ from ._async_client import AsyncSightingDB, AsyncWriteBatch
27
+ from ._client import SightingDB, WriteBatch
28
+ from .config import Config
29
+ from .errors import (
30
+ APIError,
31
+ AuthenticationError,
32
+ BadRequestError,
33
+ BulkWriteError,
34
+ ConfigurationError,
35
+ NotFoundError,
36
+ NotImplementedError,
37
+ PermissionDeniedError,
38
+ ProtocolError,
39
+ ServerError,
40
+ SightingDBError,
41
+ TimeoutError,
42
+ TransportError,
43
+ )
44
+ from .models import (
45
+ Attribute,
46
+ BulkWriteResult,
47
+ Miss,
48
+ ReadResult,
49
+ ServerInfo,
50
+ Sighting,
51
+ WriteError,
52
+ )
53
+
54
+ __all__ = [
55
+ # clients
56
+ "SightingDB",
57
+ "AsyncSightingDB",
58
+ "WriteBatch",
59
+ "AsyncWriteBatch",
60
+ "Config",
61
+ # models
62
+ "Sighting",
63
+ "Attribute",
64
+ "Miss",
65
+ "ReadResult",
66
+ "BulkWriteResult",
67
+ "WriteError",
68
+ "ServerInfo",
69
+ # errors
70
+ "SightingDBError",
71
+ "ConfigurationError",
72
+ "TransportError",
73
+ "TimeoutError",
74
+ "ProtocolError",
75
+ "APIError",
76
+ "BadRequestError",
77
+ "AuthenticationError",
78
+ "PermissionDeniedError",
79
+ "NotFoundError",
80
+ "NotImplementedError",
81
+ "ServerError",
82
+ "BulkWriteError",
83
+ # metadata
84
+ "__version__",
85
+ ]
@@ -0,0 +1,10 @@
1
+ """Version and details for sightingdb"""
2
+
3
+ __description__ = "The Python Client Library for SightingDB"
4
+ __url__ = "https://www.github.com/stricaud/sightingdb-client"
5
+ __version__ = "0.0.1"
6
+ __author__ = "Sebastien Tricaud"
7
+ __author_email__ = "sebastien@honeynet.org"
8
+ __license__ = "MIT"
9
+ __maintainer__ = __author__
10
+ __maintainer_email__ = __author_email__
@@ -0,0 +1,306 @@
1
+ """The asynchronous client: the same API as :class:`~sightingdb._client.SightingDB`, awaited."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import itertools
7
+ from types import TracebackType
8
+ from typing import Any, Iterable
9
+
10
+ import httpx
11
+
12
+ from . import _protocol as proto
13
+ from ._client import USER_AGENT, _chunks
14
+ from .config import Config
15
+ from .errors import BulkWriteError, NotFoundError, SightingDBError
16
+ from .models import Attribute, BulkWriteResult, ReadResult, ServerInfo, Sighting
17
+
18
+ __all__ = ["AsyncSightingDB", "AsyncWriteBatch"]
19
+
20
+
21
+ class AsyncSightingDB:
22
+ """A connection to a SightingDB server, for asyncio.
23
+
24
+ Every method matches :class:`~sightingdb.SightingDB` — same arguments, same
25
+ return types — but is awaited, and requests issued together with
26
+ ``asyncio.gather`` share one connection pool.
27
+
28
+ >>> async with AsyncSightingDB(apikey="changeme") as db:
29
+ ... await db.write("feeds/ips", "127.0.0.1")
30
+ ... found = await db.read_many([("feeds/ips", "127.0.0.1")])
31
+
32
+ Args:
33
+ http_client: An ``httpx.AsyncClient`` to use instead of the one this
34
+ client would build. Yours is not closed by :meth:`aclose`.
35
+
36
+ See :class:`~sightingdb.SightingDB` for the rest.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ url: str | None = None,
42
+ *,
43
+ host: str | None = None,
44
+ port: int | None = None,
45
+ ssl: bool | None = None,
46
+ apikey: str | None = None,
47
+ verify: bool | str | None = None,
48
+ timeout: float | None = None,
49
+ max_retries: int | None = None,
50
+ config: Config | None = None,
51
+ config_file: str | None = None,
52
+ use_env: bool = True,
53
+ use_file: bool = True,
54
+ http_client: httpx.AsyncClient | None = None,
55
+ transport: httpx.AsyncBaseTransport | None = None,
56
+ ) -> None:
57
+ self.config = config or Config.resolve(
58
+ url=url,
59
+ host=host,
60
+ port=port,
61
+ ssl=ssl,
62
+ apikey=apikey,
63
+ verify=verify,
64
+ timeout=timeout,
65
+ max_retries=max_retries,
66
+ config_file=config_file,
67
+ use_env=use_env,
68
+ use_file=use_file,
69
+ )
70
+ self._owns_http = http_client is None
71
+ self._http = http_client or httpx.AsyncClient(
72
+ base_url=self.config.url,
73
+ headers=self._headers(),
74
+ verify=self.config.verify,
75
+ timeout=self.config.timeout,
76
+ transport=transport,
77
+ )
78
+
79
+ def _headers(self) -> dict[str, str]:
80
+ headers = {"user-agent": USER_AGENT, "accept": "application/json"}
81
+ if self.config.apikey is not None:
82
+ headers["authorization"] = self.config.apikey
83
+ return headers
84
+
85
+ # -- lifecycle -------------------------------------------------------
86
+
87
+ async def aclose(self) -> None:
88
+ """Close the connection pool, unless the ``httpx.AsyncClient`` is yours."""
89
+ if self._owns_http:
90
+ await self._http.aclose()
91
+
92
+ async def __aenter__(self) -> AsyncSightingDB:
93
+ return self
94
+
95
+ async def __aexit__(self, *exc: Any) -> None:
96
+ await self.aclose()
97
+
98
+ def __repr__(self) -> str:
99
+ return f"<AsyncSightingDB {self.config.url}>"
100
+
101
+ # -- writing ---------------------------------------------------------
102
+
103
+ async def write(
104
+ self,
105
+ namespace: str,
106
+ value: str,
107
+ *,
108
+ timestamp: Any = None,
109
+ ttl: Any = None,
110
+ ) -> int:
111
+ """Record one sighting, and return how many times it has been seen."""
112
+ return await self._send(
113
+ proto.write(namespace, value, timestamp=timestamp, ttl=ttl)
114
+ )
115
+
116
+ async def write_many(
117
+ self,
118
+ sightings: Iterable[Sighting | tuple[str, str] | dict[str, Any]],
119
+ *,
120
+ strict: bool = True,
121
+ chunk_size: int | None = None,
122
+ ) -> BulkWriteResult:
123
+ """Record many sightings in one request."""
124
+ items = proto.coerce_sightings(sightings)
125
+ if not items:
126
+ return BulkWriteResult(written=0)
127
+
128
+ result = BulkWriteResult(written=0)
129
+ for chunk in _chunks(items, chunk_size):
130
+ result = result + await self._send(proto.write_many(chunk))
131
+ if strict and result.errors:
132
+ raise BulkWriteError(result)
133
+ return result
134
+
135
+ def batch(
136
+ self, *, chunk_size: int | None = None, strict: bool = True
137
+ ) -> AsyncWriteBatch:
138
+ """Collect sightings and send them together.
139
+
140
+ >>> async with db.batch(chunk_size=1000) as batch:
141
+ ... async for value in feed:
142
+ ... await batch.add("feeds/misp/ips", value)
143
+ """
144
+ return AsyncWriteBatch(self, chunk_size=chunk_size, strict=strict)
145
+
146
+ # -- reading ---------------------------------------------------------
147
+
148
+ async def read(
149
+ self,
150
+ namespace: str,
151
+ value: str,
152
+ *,
153
+ stats: bool = False,
154
+ shadow: bool = True,
155
+ ) -> Attribute:
156
+ """Read one value. Raises :class:`~sightingdb.errors.NotFoundError` on a miss."""
157
+ return await self._send(
158
+ proto.read(namespace, value, stats=stats, shadow=shadow)
159
+ )
160
+
161
+ async def read_many(
162
+ self,
163
+ sightings: Iterable[Sighting | tuple[str, str] | dict[str, Any]],
164
+ *,
165
+ stats: bool = False,
166
+ chunk_size: int | None = None,
167
+ ) -> list[ReadResult]:
168
+ """Read many values in one request, a miss being a result rather than an error."""
169
+ items = proto.coerce_sightings(sightings)
170
+ if not items:
171
+ return []
172
+ results: list[ReadResult] = []
173
+ for chunk in _chunks(items, chunk_size):
174
+ results.extend(await self._send(proto.read_many(chunk, stats=stats)))
175
+ return results
176
+
177
+ async def list_values(
178
+ self, namespace: str, *, shadow: bool = True
179
+ ) -> list[Attribute]:
180
+ """Every value in a namespace."""
181
+ return await self._send(proto.list_values(namespace, shadow=shadow))
182
+
183
+ async def exists(self, namespace: str, value: str, *, shadow: bool = True) -> bool:
184
+ """Whether a value has ever been seen in a namespace."""
185
+ results = await self.read_many([Sighting(namespace, value, noshadow=not shadow)])
186
+ return bool(results and results[0].found)
187
+
188
+ # -- namespaces ------------------------------------------------------
189
+
190
+ async def delete(self, namespace: str, *, missing_ok: bool = False) -> None:
191
+ """Delete a namespace and everything in it."""
192
+ try:
193
+ await self._send(proto.delete(namespace))
194
+ except NotFoundError:
195
+ if not missing_ok:
196
+ raise
197
+
198
+ async def info(self) -> ServerInfo:
199
+ """What the server reports about itself."""
200
+ return await self._send(proto.info())
201
+
202
+ async def ping(self) -> bool:
203
+ """Whether the server is reachable and answering."""
204
+ try:
205
+ await self.info()
206
+ except SightingDBError:
207
+ return False
208
+ return True
209
+
210
+ # -- transport -------------------------------------------------------
211
+
212
+ async def _send(self, request: proto.Request) -> Any:
213
+ max_retries = self.config.max_retries
214
+ for attempt in itertools.count():
215
+ try:
216
+ response = await self._http.request(
217
+ request.method,
218
+ request.path,
219
+ params=request.params or None,
220
+ json=request.json,
221
+ )
222
+ except httpx.HTTPError as exc:
223
+ if attempt < max_retries and proto.retry_after_exception(
224
+ exc, idempotent=request.idempotent
225
+ ):
226
+ await asyncio.sleep(proto.backoff(attempt))
227
+ continue
228
+ raise proto.transport_error(
229
+ exc, request, f"{self.config.url}{request.path}"
230
+ ) from exc
231
+
232
+ if attempt < max_retries and proto.retry_after_status(
233
+ response.status_code, idempotent=request.idempotent
234
+ ):
235
+ await asyncio.sleep(proto.backoff(attempt, response))
236
+ continue
237
+ return proto.parse(response, request)
238
+ raise AssertionError("unreachable") # pragma: no cover
239
+
240
+
241
+ class AsyncWriteBatch:
242
+ """Sightings queued up to be written together. See :meth:`AsyncSightingDB.batch`."""
243
+
244
+ def __init__(
245
+ self,
246
+ client: AsyncSightingDB,
247
+ *,
248
+ chunk_size: int | None = None,
249
+ strict: bool = True,
250
+ ) -> None:
251
+ self._client = client
252
+ self._chunk_size = chunk_size
253
+ self._strict = strict
254
+ self._pending: list[Sighting] = []
255
+ self.result = BulkWriteResult(written=0)
256
+
257
+ async def add(
258
+ self,
259
+ namespace: str,
260
+ value: str,
261
+ *,
262
+ timestamp: Any = None,
263
+ ttl: Any = None,
264
+ noshadow: bool = False,
265
+ ) -> None:
266
+ """Queue one sighting, sending the batch if it is now full."""
267
+ self._pending.append(Sighting(namespace, value, timestamp, ttl, noshadow))
268
+ if self._chunk_size and len(self._pending) >= self._chunk_size:
269
+ await self.flush()
270
+
271
+ async def extend(
272
+ self, sightings: Iterable[Sighting | tuple[str, str] | dict[str, Any]]
273
+ ) -> None:
274
+ """Queue many sightings."""
275
+ for sighting in proto.coerce_sightings(sightings):
276
+ await self.add(
277
+ sighting.namespace,
278
+ sighting.value,
279
+ timestamp=sighting.timestamp,
280
+ ttl=sighting.ttl,
281
+ noshadow=sighting.noshadow,
282
+ )
283
+
284
+ async def flush(self) -> BulkWriteResult:
285
+ """Send whatever is queued, and return the total sent so far."""
286
+ if self._pending:
287
+ pending, self._pending = self._pending, []
288
+ self.result = self.result + await self._client.write_many(
289
+ pending, strict=self._strict
290
+ )
291
+ return self.result
292
+
293
+ def __len__(self) -> int:
294
+ return len(self._pending)
295
+
296
+ async def __aenter__(self) -> AsyncWriteBatch:
297
+ return self
298
+
299
+ async def __aexit__(
300
+ self,
301
+ exc_type: type[BaseException] | None,
302
+ exc: BaseException | None,
303
+ tb: TracebackType | None,
304
+ ) -> None:
305
+ if exc_type is None:
306
+ await self.flush()