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 +85 -0
- sightingdb/__version__.py +10 -0
- sightingdb/_async_client.py +306 -0
- sightingdb/_client.py +399 -0
- sightingdb/_protocol.py +349 -0
- sightingdb/config.py +278 -0
- sightingdb/errors.py +150 -0
- sightingdb/models.py +275 -0
- sightingdb/py.typed +0 -0
- sightingdb_client-0.0.1.dist-info/METADATA +249 -0
- sightingdb_client-0.0.1.dist-info/RECORD +14 -0
- sightingdb_client-0.0.1.dist-info/WHEEL +5 -0
- sightingdb_client-0.0.1.dist-info/licenses/LICENSE +21 -0
- sightingdb_client-0.0.1.dist-info/top_level.txt +1 -0
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()
|