bytesmap 0.1.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.
- bytesmap/__init__.py +23 -0
- bytesmap/_catalog.json +2192 -0
- bytesmap/_generated.py +804 -0
- bytesmap/_limits.json +9 -0
- bytesmap/_transport.py +120 -0
- bytesmap/client.py +214 -0
- bytesmap/events.py +435 -0
- bytesmap/py.typed +0 -0
- bytesmap-0.1.1.dist-info/METADATA +133 -0
- bytesmap-0.1.1.dist-info/RECORD +12 -0
- bytesmap-0.1.1.dist-info/WHEEL +4 -0
- bytesmap-0.1.1.dist-info/licenses/LICENSE +202 -0
bytesmap/events.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""Append-only event delivery with bounded envelopes and identity-preserving retries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import random
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import traceback
|
|
11
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from email.utils import parsedate_to_datetime
|
|
15
|
+
from importlib.resources import files
|
|
16
|
+
from typing import Any, Self
|
|
17
|
+
from uuid import uuid4
|
|
18
|
+
|
|
19
|
+
from ._transport import (
|
|
20
|
+
Transport,
|
|
21
|
+
TransportError,
|
|
22
|
+
UrllibTransport,
|
|
23
|
+
validate_origin,
|
|
24
|
+
validate_timeout,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_LIMITS = json.loads(files(__package__).joinpath("_limits.json").read_text("utf-8"))
|
|
28
|
+
_LABEL = re.compile(_LIMITS["label_pattern"])
|
|
29
|
+
_DIMENSION = re.compile(_LIMITS["dimension_pattern"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class EventContext:
|
|
34
|
+
namespace: str | None = None
|
|
35
|
+
group: str | None = None
|
|
36
|
+
schema_version: int | None = None
|
|
37
|
+
dimensions: Mapping[str, Any] | None = None
|
|
38
|
+
data: Mapping[str, Any] | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class EventDraft:
|
|
43
|
+
type: str
|
|
44
|
+
id: str | None = None
|
|
45
|
+
timestamp: datetime | str | None = None
|
|
46
|
+
context: EventContext | None = None
|
|
47
|
+
error: BaseException | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class EventDeliveryError(Exception):
|
|
51
|
+
def __init__(self, code: str, payload: bytes, status: int | None = None) -> None:
|
|
52
|
+
super().__init__(code)
|
|
53
|
+
self.code, self.status = code, status
|
|
54
|
+
self._payload = payload
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def batch(self) -> dict[str, Any]:
|
|
58
|
+
"""A detached copy to pass to retry(); no credentials are included."""
|
|
59
|
+
return json.loads(self._payload)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _encode(value: Any) -> bytes:
|
|
63
|
+
return json.dumps(
|
|
64
|
+
value,
|
|
65
|
+
ensure_ascii=False,
|
|
66
|
+
allow_nan=False,
|
|
67
|
+
sort_keys=True,
|
|
68
|
+
separators=(",", ":"),
|
|
69
|
+
).encode("utf-8")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _json_value(value: Any, depth: int = 1) -> None:
|
|
73
|
+
if value is None or isinstance(value, (str, bool)):
|
|
74
|
+
return
|
|
75
|
+
if isinstance(value, (int, float)):
|
|
76
|
+
if abs(value) > 9007199254740991 or not math.isfinite(value):
|
|
77
|
+
raise ValueError(
|
|
78
|
+
"Event numbers must be finite and within the JavaScript safe range"
|
|
79
|
+
)
|
|
80
|
+
return
|
|
81
|
+
if depth > _LIMITS["max_depth"]:
|
|
82
|
+
raise ValueError("Event data is too deep")
|
|
83
|
+
if isinstance(value, dict) and all(isinstance(key, str) for key in value):
|
|
84
|
+
for item in value.values():
|
|
85
|
+
_json_value(item, depth + 1)
|
|
86
|
+
elif isinstance(value, list):
|
|
87
|
+
for item in value:
|
|
88
|
+
_json_value(item, depth + 1)
|
|
89
|
+
else:
|
|
90
|
+
raise TypeError("Event values must be JSON-compatible")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _snapshot(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
94
|
+
if value is None:
|
|
95
|
+
return {}
|
|
96
|
+
if not isinstance(value, Mapping):
|
|
97
|
+
raise TypeError("Event dimensions and data must be mappings")
|
|
98
|
+
result = dict(value)
|
|
99
|
+
_json_value(result)
|
|
100
|
+
return json.loads(_encode(result))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _merge(parent: EventContext, child: EventContext | None) -> EventContext:
|
|
104
|
+
child = child or EventContext()
|
|
105
|
+
result = EventContext(
|
|
106
|
+
namespace=child.namespace if child.namespace is not None else parent.namespace,
|
|
107
|
+
group=child.group if child.group is not None else parent.group,
|
|
108
|
+
schema_version=child.schema_version
|
|
109
|
+
if child.schema_version is not None
|
|
110
|
+
else parent.schema_version,
|
|
111
|
+
dimensions={**_snapshot(parent.dimensions), **_snapshot(child.dimensions)},
|
|
112
|
+
data={**_snapshot(parent.data), **_snapshot(child.data)},
|
|
113
|
+
)
|
|
114
|
+
for label in (result.namespace, result.group):
|
|
115
|
+
if not isinstance(label, str) or not _LABEL.fullmatch(label):
|
|
116
|
+
raise ValueError("Invalid event namespace or group")
|
|
117
|
+
if (
|
|
118
|
+
type(result.schema_version) is not int
|
|
119
|
+
or not 1 <= result.schema_version <= 2147483647
|
|
120
|
+
):
|
|
121
|
+
raise ValueError("Invalid event schema version")
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _error_data(error: BaseException, depth: int = 1) -> dict[str, Any]:
|
|
126
|
+
if depth > _LIMITS["max_depth"]:
|
|
127
|
+
raise ValueError("Exception causes are too deep")
|
|
128
|
+
result: dict[str, Any] = {"name": type(error).__name__, "message": str(error)}
|
|
129
|
+
if error.__traceback__ is not None:
|
|
130
|
+
result["stack"] = "".join(
|
|
131
|
+
traceback.format_exception(
|
|
132
|
+
type(error), error, error.__traceback__, chain=False
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
if error.__cause__ is not None:
|
|
136
|
+
result["cause"] = _error_data(error.__cause__, depth + 1)
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _validate_batch(batch: Any, payload: bytes) -> None:
|
|
141
|
+
if len(payload) > _LIMITS["max_batch_bytes"]:
|
|
142
|
+
raise ValueError("Event batch exceeds 1 MiB")
|
|
143
|
+
if not isinstance(batch, dict) or set(batch) != {"version", "batchId", "events"}:
|
|
144
|
+
raise ValueError("Invalid event batch fields")
|
|
145
|
+
if (
|
|
146
|
+
type(batch["version"]) is not int
|
|
147
|
+
or batch["version"] != 1
|
|
148
|
+
or not isinstance(batch["batchId"], str)
|
|
149
|
+
or not re.fullmatch(r"[a-f0-9-]{36}", batch["batchId"])
|
|
150
|
+
):
|
|
151
|
+
raise ValueError("Invalid event batch identity")
|
|
152
|
+
events = batch["events"]
|
|
153
|
+
if (
|
|
154
|
+
not isinstance(events, list)
|
|
155
|
+
or not 1 <= len(events) <= _LIMITS["max_batch_events"]
|
|
156
|
+
):
|
|
157
|
+
raise ValueError("Invalid event batch count")
|
|
158
|
+
for event in events:
|
|
159
|
+
if (
|
|
160
|
+
not isinstance(event, dict)
|
|
161
|
+
or not {
|
|
162
|
+
"id",
|
|
163
|
+
"namespace",
|
|
164
|
+
"group",
|
|
165
|
+
"type",
|
|
166
|
+
"timestamp",
|
|
167
|
+
"schemaVersion",
|
|
168
|
+
"data",
|
|
169
|
+
}
|
|
170
|
+
<= event.keys()
|
|
171
|
+
or set(event)
|
|
172
|
+
- {
|
|
173
|
+
"id",
|
|
174
|
+
"namespace",
|
|
175
|
+
"group",
|
|
176
|
+
"type",
|
|
177
|
+
"timestamp",
|
|
178
|
+
"schemaVersion",
|
|
179
|
+
"dimensions",
|
|
180
|
+
"data",
|
|
181
|
+
}
|
|
182
|
+
):
|
|
183
|
+
raise ValueError("Invalid event fields")
|
|
184
|
+
for field in ("id", "namespace", "group", "type"):
|
|
185
|
+
if not isinstance(event[field], str) or not _LABEL.fullmatch(event[field]):
|
|
186
|
+
raise ValueError("Invalid event label")
|
|
187
|
+
if (
|
|
188
|
+
type(event["schemaVersion"]) is not int
|
|
189
|
+
or not 1 <= event["schemaVersion"] <= 2147483647
|
|
190
|
+
):
|
|
191
|
+
raise ValueError("Invalid event schema version")
|
|
192
|
+
timestamp = event["timestamp"]
|
|
193
|
+
if not isinstance(timestamp, str) or not re.fullmatch(
|
|
194
|
+
r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z", timestamp
|
|
195
|
+
):
|
|
196
|
+
raise ValueError("Timestamp must be UTC ISO milliseconds")
|
|
197
|
+
at = datetime.fromisoformat(timestamp)
|
|
198
|
+
if not 1900 <= at.year <= 2299:
|
|
199
|
+
raise ValueError("Event timestamp year must be between 1900 and 2299")
|
|
200
|
+
dimensions, data = event.get("dimensions", {}), event["data"]
|
|
201
|
+
if (
|
|
202
|
+
not isinstance(dimensions, dict)
|
|
203
|
+
or len(dimensions) > _LIMITS["max_dimensions"]
|
|
204
|
+
or not isinstance(data, dict)
|
|
205
|
+
):
|
|
206
|
+
raise ValueError("Invalid event dimensions or data")
|
|
207
|
+
for key, value in dimensions.items():
|
|
208
|
+
if (
|
|
209
|
+
not _DIMENSION.fullmatch(key)
|
|
210
|
+
or isinstance(value, (dict, list))
|
|
211
|
+
or isinstance(value, str)
|
|
212
|
+
and len(value.encode("utf-16-le")) > 2048
|
|
213
|
+
):
|
|
214
|
+
raise ValueError("Invalid event dimension")
|
|
215
|
+
_json_value(dimensions)
|
|
216
|
+
_json_value(data)
|
|
217
|
+
if len(_encode([dimensions, data])) > _LIMITS["max_data_bytes"]:
|
|
218
|
+
raise ValueError("Event dimensions and data exceed 64 KiB")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class BytesMapEventClient:
|
|
222
|
+
def __init__(
|
|
223
|
+
self,
|
|
224
|
+
endpoint: str,
|
|
225
|
+
dataset: str,
|
|
226
|
+
token: str,
|
|
227
|
+
*,
|
|
228
|
+
namespace: str = "default",
|
|
229
|
+
group: str = "default",
|
|
230
|
+
context: EventContext | None = None,
|
|
231
|
+
allow_plaintext_loopback: bool = False,
|
|
232
|
+
timeout: float = 10,
|
|
233
|
+
max_attempts: int = 5,
|
|
234
|
+
transport: Transport | None = None,
|
|
235
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
236
|
+
) -> None:
|
|
237
|
+
endpoint, plaintext = validate_origin(endpoint)
|
|
238
|
+
if plaintext and not allow_plaintext_loopback:
|
|
239
|
+
raise ValueError(
|
|
240
|
+
"HTTP loopback event ingestion requires allow_plaintext_loopback=True"
|
|
241
|
+
)
|
|
242
|
+
if not isinstance(dataset, str) or not re.fullmatch(
|
|
243
|
+
r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}",
|
|
244
|
+
dataset,
|
|
245
|
+
):
|
|
246
|
+
raise ValueError("Invalid event dataset ID")
|
|
247
|
+
if not isinstance(token, str) or not re.fullmatch(
|
|
248
|
+
r"bmap_event_[A-Za-z0-9_-]{43}", token
|
|
249
|
+
):
|
|
250
|
+
raise ValueError("Invalid append-only event token")
|
|
251
|
+
validate_timeout(timeout)
|
|
252
|
+
if (
|
|
253
|
+
not 0.01 <= timeout <= 60
|
|
254
|
+
or type(max_attempts) is not int
|
|
255
|
+
or not 1 <= max_attempts <= 10
|
|
256
|
+
):
|
|
257
|
+
raise ValueError("Invalid event timeout or retry limit")
|
|
258
|
+
self._endpoint, self._dataset, self._token = endpoint, dataset, token
|
|
259
|
+
self._url = f"{endpoint}/v1/events/batches?dataset={dataset}"
|
|
260
|
+
self._timeout, self._max_attempts, self._sleep = timeout, max_attempts, sleep
|
|
261
|
+
self._context = _merge(EventContext(namespace, group, 1), context)
|
|
262
|
+
self._transport = transport or UrllibTransport()
|
|
263
|
+
self._owns_transport = transport is None
|
|
264
|
+
self._closed = False
|
|
265
|
+
self._owner: BytesMapEventClient | None = None
|
|
266
|
+
|
|
267
|
+
def scope(
|
|
268
|
+
self, context: EventContext | None = None, **kwargs: Any
|
|
269
|
+
) -> BytesMapEventClient:
|
|
270
|
+
self._check_open()
|
|
271
|
+
if context is not None and kwargs:
|
|
272
|
+
raise ValueError("Pass an EventContext or context keywords")
|
|
273
|
+
merged = _merge(self._context, context or EventContext(**kwargs))
|
|
274
|
+
scoped = BytesMapEventClient(
|
|
275
|
+
self._endpoint,
|
|
276
|
+
self._dataset,
|
|
277
|
+
self._token,
|
|
278
|
+
context=merged,
|
|
279
|
+
allow_plaintext_loopback=self._endpoint.startswith("http:"),
|
|
280
|
+
timeout=self._timeout,
|
|
281
|
+
max_attempts=self._max_attempts,
|
|
282
|
+
transport=self._transport,
|
|
283
|
+
sleep=self._sleep,
|
|
284
|
+
)
|
|
285
|
+
scoped._owner = self._owner or self
|
|
286
|
+
return scoped
|
|
287
|
+
|
|
288
|
+
def emit(
|
|
289
|
+
self,
|
|
290
|
+
event: str | EventDraft,
|
|
291
|
+
*,
|
|
292
|
+
namespace: str | None = None,
|
|
293
|
+
group: str | None = None,
|
|
294
|
+
dimensions: Mapping[str, Any] | None = None,
|
|
295
|
+
data: Mapping[str, Any] | None = None,
|
|
296
|
+
error: BaseException | None = None,
|
|
297
|
+
) -> dict[str, Any]:
|
|
298
|
+
if isinstance(event, str):
|
|
299
|
+
event = EventDraft(
|
|
300
|
+
event,
|
|
301
|
+
context=EventContext(
|
|
302
|
+
namespace=namespace, group=group, dimensions=dimensions, data=data
|
|
303
|
+
),
|
|
304
|
+
error=error,
|
|
305
|
+
)
|
|
306
|
+
elif any(
|
|
307
|
+
value is not None for value in (namespace, group, dimensions, data, error)
|
|
308
|
+
):
|
|
309
|
+
raise ValueError("Supply an EventDraft or event keyword arguments")
|
|
310
|
+
return self.send([event])
|
|
311
|
+
|
|
312
|
+
def send(self, events: Sequence[EventDraft]) -> dict[str, Any]:
|
|
313
|
+
self._check_open()
|
|
314
|
+
if not 1 <= len(events) <= _LIMITS["max_batch_events"]:
|
|
315
|
+
raise ValueError("Invalid event batch count")
|
|
316
|
+
envelopes = []
|
|
317
|
+
for event in events:
|
|
318
|
+
if not isinstance(event, EventDraft):
|
|
319
|
+
raise TypeError("send() expects EventDraft instances")
|
|
320
|
+
context = _merge(self._context, event.context)
|
|
321
|
+
data = _snapshot(context.data)
|
|
322
|
+
if event.error is not None:
|
|
323
|
+
if "error" in data:
|
|
324
|
+
raise ValueError("Supply error or data['error'], not both")
|
|
325
|
+
if not isinstance(event.error, BaseException):
|
|
326
|
+
raise TypeError("Event error must be an exception")
|
|
327
|
+
data["error"] = _error_data(event.error)
|
|
328
|
+
timestamp = event.timestamp
|
|
329
|
+
if timestamp is None:
|
|
330
|
+
timestamp = datetime.now(UTC)
|
|
331
|
+
if isinstance(timestamp, datetime):
|
|
332
|
+
if timestamp.tzinfo is None:
|
|
333
|
+
raise ValueError("Event datetimes must include a timezone")
|
|
334
|
+
timestamp = (
|
|
335
|
+
timestamp.astimezone(UTC)
|
|
336
|
+
.isoformat(timespec="milliseconds")
|
|
337
|
+
.replace("+00:00", "Z")
|
|
338
|
+
)
|
|
339
|
+
envelopes.append(
|
|
340
|
+
{
|
|
341
|
+
"id": event.id if event.id is not None else str(uuid4()),
|
|
342
|
+
"namespace": context.namespace,
|
|
343
|
+
"group": context.group,
|
|
344
|
+
"schemaVersion": context.schema_version,
|
|
345
|
+
"type": event.type,
|
|
346
|
+
"timestamp": timestamp,
|
|
347
|
+
"dimensions": _snapshot(context.dimensions),
|
|
348
|
+
"data": data,
|
|
349
|
+
}
|
|
350
|
+
)
|
|
351
|
+
return self.retry({"version": 1, "batchId": str(uuid4()), "events": envelopes})
|
|
352
|
+
|
|
353
|
+
def retry(self, batch: Mapping[str, Any]) -> dict[str, Any]:
|
|
354
|
+
self._check_open()
|
|
355
|
+
_json_value(batch)
|
|
356
|
+
payload = _encode(batch)
|
|
357
|
+
snapshot = json.loads(payload)
|
|
358
|
+
_validate_batch(snapshot, payload)
|
|
359
|
+
status = None
|
|
360
|
+
code = "event_delivery_unconfirmed"
|
|
361
|
+
for attempt in range(self._max_attempts):
|
|
362
|
+
delay = min(0.1 * 2**attempt * random.uniform(0.5, 1.5), 10)
|
|
363
|
+
try:
|
|
364
|
+
response = self._transport.request(
|
|
365
|
+
"POST",
|
|
366
|
+
self._url,
|
|
367
|
+
{
|
|
368
|
+
"authorization": "Bearer " + self._token,
|
|
369
|
+
"content-type": "application/json",
|
|
370
|
+
"accept": "application/json",
|
|
371
|
+
},
|
|
372
|
+
payload,
|
|
373
|
+
self._timeout,
|
|
374
|
+
)
|
|
375
|
+
status = response.status
|
|
376
|
+
if status == 202:
|
|
377
|
+
try:
|
|
378
|
+
if len(response.body) > 16384:
|
|
379
|
+
raise ValueError("Oversized event acceptance")
|
|
380
|
+
receipt = json.loads(response.body)
|
|
381
|
+
if (
|
|
382
|
+
isinstance(receipt, dict)
|
|
383
|
+
and type(receipt.get("version")) is int
|
|
384
|
+
and receipt["version"] == 1
|
|
385
|
+
and receipt.get("status") == "accepted"
|
|
386
|
+
and receipt.get("batchId") == snapshot["batchId"]
|
|
387
|
+
and type(receipt.get("acceptedEvents")) is int
|
|
388
|
+
and receipt["acceptedEvents"] == len(snapshot["events"])
|
|
389
|
+
and type(receipt.get("duplicate")) is bool
|
|
390
|
+
):
|
|
391
|
+
return receipt
|
|
392
|
+
except (ValueError, UnicodeError):
|
|
393
|
+
pass
|
|
394
|
+
elif status not in (408, 429) and status < 500:
|
|
395
|
+
raise EventDeliveryError("event_rejected", payload, status)
|
|
396
|
+
headers = {
|
|
397
|
+
key.lower(): value for key, value in response.headers.items()
|
|
398
|
+
}
|
|
399
|
+
retry_after = headers.get("retry-after")
|
|
400
|
+
if retry_after is not None:
|
|
401
|
+
try:
|
|
402
|
+
delay = (
|
|
403
|
+
float(retry_after)
|
|
404
|
+
if retry_after.strip().isdigit()
|
|
405
|
+
else (
|
|
406
|
+
parsedate_to_datetime(retry_after) - datetime.now(UTC)
|
|
407
|
+
).total_seconds()
|
|
408
|
+
)
|
|
409
|
+
delay = max(0, delay)
|
|
410
|
+
if delay > 60:
|
|
411
|
+
raise EventDeliveryError("retry_later", payload, status)
|
|
412
|
+
except (ValueError, TypeError, OverflowError):
|
|
413
|
+
pass
|
|
414
|
+
except (TransportError, OSError):
|
|
415
|
+
status = None
|
|
416
|
+
if attempt + 1 < self._max_attempts:
|
|
417
|
+
self._sleep(delay)
|
|
418
|
+
raise EventDeliveryError(code, payload, status)
|
|
419
|
+
|
|
420
|
+
def _check_open(self) -> None:
|
|
421
|
+
if self._closed or self._owner is not None and self._owner._closed:
|
|
422
|
+
raise RuntimeError("BytesMap event client is closed")
|
|
423
|
+
|
|
424
|
+
def close(self) -> None:
|
|
425
|
+
if not self._closed:
|
|
426
|
+
self._closed = True
|
|
427
|
+
if self._owns_transport:
|
|
428
|
+
self._transport.close()
|
|
429
|
+
|
|
430
|
+
def __enter__(self) -> Self:
|
|
431
|
+
self._check_open()
|
|
432
|
+
return self
|
|
433
|
+
|
|
434
|
+
def __exit__(self, *_: object) -> None:
|
|
435
|
+
self.close()
|
bytesmap/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bytesmap
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: The official BytesMap Python SDK: workspace API operations and event ingestion.
|
|
5
|
+
Project-URL: Homepage, https://bytesmap.com
|
|
6
|
+
Project-URL: Repository, https://github.com/oblien/bytesmap
|
|
7
|
+
Project-URL: Documentation, https://github.com/oblien/bytesmap/tree/main/packages/python
|
|
8
|
+
Project-URL: Issues, https://github.com/oblien/bytesmap/issues
|
|
9
|
+
Author: BytesMap
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: analytics,bytesmap,database,events,sdk
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# BytesMap for Python
|
|
26
|
+
|
|
27
|
+
The official Python 3.11+ SDK for the BytesMap workspace API and append-only event
|
|
28
|
+
ingestion. It has no runtime dependencies and uses the standard library with
|
|
29
|
+
verified TLS. All 240 API methods are generated from BytesMap's shared operation
|
|
30
|
+
catalogue, with Python names and keyword arguments.
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
pip install bytesmap
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Send events
|
|
37
|
+
|
|
38
|
+
Create a dataset and an ingestion key in **Events → Connect application**. Set
|
|
39
|
+
`BYTESMAP_EVENTS_URL`, `BYTESMAP_EVENT_DATASET`, and `BYTESMAP_EVENT_TOKEN`, then:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import os
|
|
43
|
+
from bytesmap import EventClient
|
|
44
|
+
|
|
45
|
+
with EventClient(
|
|
46
|
+
endpoint=os.environ["BYTESMAP_EVENTS_URL"],
|
|
47
|
+
dataset=os.environ["BYTESMAP_EVENT_DATASET"],
|
|
48
|
+
token=os.environ["BYTESMAP_EVENT_TOKEN"],
|
|
49
|
+
namespace="production",
|
|
50
|
+
group="application",
|
|
51
|
+
) as events:
|
|
52
|
+
accepted = events.emit("app.started")
|
|
53
|
+
# Declare durationMs on the dataset before sending this dimension.
|
|
54
|
+
events.emit(
|
|
55
|
+
"request.finished", dimensions={"durationMs": 120}, data={"route": "/orders"}
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`emit()` waits for durable acceptance. There is no background buffer or shutdown
|
|
60
|
+
flush. IDs, UTC timestamps and schema version 1 are supplied automatically.
|
|
61
|
+
`events.scope(dimensions={"userId": "user_42"})` snapshots request context while
|
|
62
|
+
sharing the transport. Declare dimension names and types on the dataset first;
|
|
63
|
+
full JSON belongs in `data`. Pass `error=exception` to capture its name, message,
|
|
64
|
+
stack and explicit cause.
|
|
65
|
+
|
|
66
|
+
Use `send([EventDraft("app.started"), ...])` for a batch. Limits match the server:
|
|
67
|
+
1,000 events, 1 MiB per batch, 64 dimensions and 64 KiB of dimensions/data per event.
|
|
68
|
+
Numbers must fit the JavaScript safe range. HTTPS is required. Local testing on
|
|
69
|
+
HTTP loopback requires explicit `allow_plaintext_loopback=True`.
|
|
70
|
+
|
|
71
|
+
Network errors, HTTP 408/429/5xx and invalid acceptance receipts retry with the
|
|
72
|
+
same batch identity and bytes (default five attempts). Other HTTP errors fail
|
|
73
|
+
immediately. `EventDeliveryError.batch` returns the original payload for
|
|
74
|
+
`events.retry(error.batch)`. An unconfirmed send may already have been accepted;
|
|
75
|
+
preserve its batch identity when retrying. `Retry-After` above one minute returns
|
|
76
|
+
`retry_later` so the application can schedule its retry.
|
|
77
|
+
|
|
78
|
+
## Workspace API
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
import os
|
|
82
|
+
from bytesmap import BytesMapClient, CallOptions
|
|
83
|
+
|
|
84
|
+
with BytesMapClient(
|
|
85
|
+
base_url="https://api.example.com",
|
|
86
|
+
token=os.environ["BYTESMAP_API_TOKEN"],
|
|
87
|
+
project_id="project-id",
|
|
88
|
+
) as client:
|
|
89
|
+
dashboards = client.dashboards.list()
|
|
90
|
+
result = client.queries.run(id="saved-query-id")
|
|
91
|
+
workspace = client.scope("workspace-id")
|
|
92
|
+
workspace.sources.list(call_options=CallOptions(project_id="another-project"))
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
API and event ingestion keys are separate credentials. Keep workspace API keys on
|
|
96
|
+
your server. Scopes cannot widen a key's server-enforced permissions. Modules and
|
|
97
|
+
methods use `snake_case` (`client.event_destinations.list()`); keyword arguments
|
|
98
|
+
are mapped to the wire contract's field names (`destination_id` → `destinationId`).
|
|
99
|
+
Pass a dictionary with original wire field names for nested payloads:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
client.dashboards.update({"id": "dashboard-id", "folderId": None})
|
|
103
|
+
client.invoke("queries.run", {"id": "saved-query-id", "params": {"customer": "id"}})
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Omitted fields stay omitted; `None` sends explicit JSON null. Methods return public
|
|
107
|
+
JSON values (dicts, lists and scalars). `BytesMapError` exposes `code`, `params` and
|
|
108
|
+
`status`; its string representation contains only the error code. Export methods
|
|
109
|
+
return `filename`, `contentType` and `contentBase64`.
|
|
110
|
+
|
|
111
|
+
API mutations are never automatically retried. All requests reject redirects and
|
|
112
|
+
use a finite timeout. A `token` callback supports rotating credentials; a
|
|
113
|
+
`project_id` callback supports current project selection. Project selection is
|
|
114
|
+
captured before the token callback. Closing a scope never closes its parent;
|
|
115
|
+
closing the parent makes its scopes unavailable.
|
|
116
|
+
|
|
117
|
+
Clients are synchronous. In async applications, use `asyncio.to_thread` around
|
|
118
|
+
calls; cancelling that await does not cancel the underlying HTTP request, which
|
|
119
|
+
remains bounded by its timeout. An optional `transport` implementing the exported
|
|
120
|
+
`Transport` protocol supports host-owned HTTP clients and tests; the caller owns
|
|
121
|
+
its lifetime and must disable redirects and retain TLS verification.
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
bun --no-env-file scripts/generate-python-sdk.ts
|
|
127
|
+
bun --no-env-file scripts/generate-python-sdk.ts --check
|
|
128
|
+
PYTHONPATH=packages/python/src python3 -m unittest discover -s packages/python/tests -v
|
|
129
|
+
python3 -m build packages/python
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Generated catalogue files are committed. Regenerate them when the shared operation
|
|
133
|
+
catalogue or event limits change. Python releases are independent of npm and NuGet.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
bytesmap/__init__.py,sha256=otj1uuselBTbU9tbMa--2Oy4Q6OXHKxFc6G4Dk0zIIg,612
|
|
2
|
+
bytesmap/_catalog.json,sha256=Q-jRlPceh6lOc9fa3LPBSAkA8Qov7fNsT1uQW3UfeMA,47939
|
|
3
|
+
bytesmap/_generated.py,sha256=WmikKA69hrGbSO3-hjgZ6wd1p-WX6VuXPP4lKdtcm4E,58025
|
|
4
|
+
bytesmap/_limits.json,sha256=qmYmDKzeSjCpONhVjEa_A47smP9q_4v2O-iumidoZu8,246
|
|
5
|
+
bytesmap/_transport.py,sha256=I0aampC99v3EvTMMwksgHCe7yu3skHN2854h8hhpPoo,3715
|
|
6
|
+
bytesmap/client.py,sha256=XXNtk2JoD3LW2839N-uOHTQRadXdF6B9n74HtefBKfo,7709
|
|
7
|
+
bytesmap/events.py,sha256=HMu5vkeZu3FBd63ZpWmcruRD868bzTG4jYIoadS0JRI,16558
|
|
8
|
+
bytesmap/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
bytesmap-0.1.1.dist-info/METADATA,sha256=g1IAGRzMl5c08DG3qmeGy7hLlSV2kdhuhTnoK92aEzM,5747
|
|
10
|
+
bytesmap-0.1.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
11
|
+
bytesmap-0.1.1.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
12
|
+
bytesmap-0.1.1.dist-info/RECORD,,
|