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/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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any