bookcicle-eventing 1.0.1__tar.gz
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.
- bookcicle_eventing-1.0.1/LICENSE +21 -0
- bookcicle_eventing-1.0.1/MANIFEST.in +1 -0
- bookcicle_eventing-1.0.1/PKG-INFO +45 -0
- bookcicle_eventing-1.0.1/README.md +33 -0
- bookcicle_eventing-1.0.1/bookcicle_eventing.egg-info/PKG-INFO +45 -0
- bookcicle_eventing-1.0.1/bookcicle_eventing.egg-info/SOURCES.txt +9 -0
- bookcicle_eventing-1.0.1/bookcicle_eventing.egg-info/dependency_links.txt +1 -0
- bookcicle_eventing-1.0.1/bookcicle_eventing.egg-info/top_level.txt +1 -0
- bookcicle_eventing-1.0.1/eventing_sdk/__init__.py +380 -0
- bookcicle_eventing-1.0.1/pyproject.toml +19 -0
- bookcicle_eventing-1.0.1/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bookcicle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
prune tests
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bookcicle-eventing
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Privacy-conscious Eventing by Bookcicle SDK
|
|
5
|
+
Author: Bookcicle
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/bookcicle/bookcicle-eventing
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# Eventing SDK — Python 3.9+
|
|
14
|
+
|
|
15
|
+
Install after its initial public release: `pip install bookcicle-eventing`.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from eventing_sdk import EventingClient
|
|
19
|
+
|
|
20
|
+
events = EventingClient('https://events.example.com/events')
|
|
21
|
+
events.track({'name': 'checkout_completed', 'properties': {'product': 'book'}})
|
|
22
|
+
result = events.flush()
|
|
23
|
+
events.close()
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Automatic batching: 25 events, first-event timer of 1 second (maximum age 5 seconds),
|
|
27
|
+
8192 UTF-8 bytes including the envelope. Three attempts per flush round with
|
|
28
|
+
200/400 ms backoff; retry 429/500/502/503/504 and network failures. Only HTTP 202
|
|
29
|
+
acknowledges ingestion. IDs and serialized bytes stay unchanged within a retry round.
|
|
30
|
+
|
|
31
|
+
Events are persisted before `track` succeeds. Transient failures stay queued and
|
|
32
|
+
retry automatically after 1 second; restart resumes pending events. Permanent
|
|
33
|
+
responses retain events and stop background retries until another track/flush.
|
|
34
|
+
Use queue inspection and discard to handle poison events. Queue capacity is 1000;
|
|
35
|
+
overflow rejects the new event without evicting older events. Close stops timers
|
|
36
|
+
and tries a final flush; failed events remain persisted.
|
|
37
|
+
|
|
38
|
+
Recursive privacy filtering removes common PII/click identifiers by default.
|
|
39
|
+
Opting into sensitive properties never permits token/authorization fields. Auth is
|
|
40
|
+
obtained once per batch, never persisted, and refreshed for the next batch.
|
|
41
|
+
Use HTTPS with /events, /public or /secure; redirects are never followed.
|
|
42
|
+
|
|
43
|
+
See the repository's [SDK contract](https://github.com/bookcicle/bookcicle-eventing/blob/main/sdk/README.md) for configuration, persistence,
|
|
44
|
+
behavioral guarantees and publishing setup. SDKs are MIT licensed; the ingestion
|
|
45
|
+
runtime retains its separate commercial license.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Eventing SDK — Python 3.9+
|
|
2
|
+
|
|
3
|
+
Install after its initial public release: `pip install bookcicle-eventing`.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from eventing_sdk import EventingClient
|
|
7
|
+
|
|
8
|
+
events = EventingClient('https://events.example.com/events')
|
|
9
|
+
events.track({'name': 'checkout_completed', 'properties': {'product': 'book'}})
|
|
10
|
+
result = events.flush()
|
|
11
|
+
events.close()
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Automatic batching: 25 events, first-event timer of 1 second (maximum age 5 seconds),
|
|
15
|
+
8192 UTF-8 bytes including the envelope. Three attempts per flush round with
|
|
16
|
+
200/400 ms backoff; retry 429/500/502/503/504 and network failures. Only HTTP 202
|
|
17
|
+
acknowledges ingestion. IDs and serialized bytes stay unchanged within a retry round.
|
|
18
|
+
|
|
19
|
+
Events are persisted before `track` succeeds. Transient failures stay queued and
|
|
20
|
+
retry automatically after 1 second; restart resumes pending events. Permanent
|
|
21
|
+
responses retain events and stop background retries until another track/flush.
|
|
22
|
+
Use queue inspection and discard to handle poison events. Queue capacity is 1000;
|
|
23
|
+
overflow rejects the new event without evicting older events. Close stops timers
|
|
24
|
+
and tries a final flush; failed events remain persisted.
|
|
25
|
+
|
|
26
|
+
Recursive privacy filtering removes common PII/click identifiers by default.
|
|
27
|
+
Opting into sensitive properties never permits token/authorization fields. Auth is
|
|
28
|
+
obtained once per batch, never persisted, and refreshed for the next batch.
|
|
29
|
+
Use HTTPS with /events, /public or /secure; redirects are never followed.
|
|
30
|
+
|
|
31
|
+
See the repository's [SDK contract](https://github.com/bookcicle/bookcicle-eventing/blob/main/sdk/README.md) for configuration, persistence,
|
|
32
|
+
behavioral guarantees and publishing setup. SDKs are MIT licensed; the ingestion
|
|
33
|
+
runtime retains its separate commercial license.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bookcicle-eventing
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Privacy-conscious Eventing by Bookcicle SDK
|
|
5
|
+
Author: Bookcicle
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/bookcicle/bookcicle-eventing
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# Eventing SDK — Python 3.9+
|
|
14
|
+
|
|
15
|
+
Install after its initial public release: `pip install bookcicle-eventing`.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from eventing_sdk import EventingClient
|
|
19
|
+
|
|
20
|
+
events = EventingClient('https://events.example.com/events')
|
|
21
|
+
events.track({'name': 'checkout_completed', 'properties': {'product': 'book'}})
|
|
22
|
+
result = events.flush()
|
|
23
|
+
events.close()
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Automatic batching: 25 events, first-event timer of 1 second (maximum age 5 seconds),
|
|
27
|
+
8192 UTF-8 bytes including the envelope. Three attempts per flush round with
|
|
28
|
+
200/400 ms backoff; retry 429/500/502/503/504 and network failures. Only HTTP 202
|
|
29
|
+
acknowledges ingestion. IDs and serialized bytes stay unchanged within a retry round.
|
|
30
|
+
|
|
31
|
+
Events are persisted before `track` succeeds. Transient failures stay queued and
|
|
32
|
+
retry automatically after 1 second; restart resumes pending events. Permanent
|
|
33
|
+
responses retain events and stop background retries until another track/flush.
|
|
34
|
+
Use queue inspection and discard to handle poison events. Queue capacity is 1000;
|
|
35
|
+
overflow rejects the new event without evicting older events. Close stops timers
|
|
36
|
+
and tries a final flush; failed events remain persisted.
|
|
37
|
+
|
|
38
|
+
Recursive privacy filtering removes common PII/click identifiers by default.
|
|
39
|
+
Opting into sensitive properties never permits token/authorization fields. Auth is
|
|
40
|
+
obtained once per batch, never persisted, and refreshed for the next batch.
|
|
41
|
+
Use HTTPS with /events, /public or /secure; redirects are never followed.
|
|
42
|
+
|
|
43
|
+
See the repository's [SDK contract](https://github.com/bookcicle/bookcicle-eventing/blob/main/sdk/README.md) for configuration, persistence,
|
|
44
|
+
behavioral guarantees and publishing setup. SDKs are MIT licensed; the ingestion
|
|
45
|
+
runtime retains its separate commercial license.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
eventing_sdk
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Privacy-conscious synchronous Eventing producer. Explicitly flush before shutdown."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import datetime as dt
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import pathlib
|
|
9
|
+
import re
|
|
10
|
+
import math
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import urllib.request
|
|
17
|
+
import uuid
|
|
18
|
+
|
|
19
|
+
__version__ = "1.0.1"
|
|
20
|
+
RETRYABLE = {429, 500, 502, 503, 504}
|
|
21
|
+
FORBIDDEN = set(
|
|
22
|
+
"email name full_name phone ssn source_ip ip gclid fbclid msclkid li_fat_id rdt_cid oppref x-event-auth authorization token".split()
|
|
23
|
+
)
|
|
24
|
+
CREDENTIALS = {"x-event-auth", "authorization", "token"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def sanitize(value, allow=False):
|
|
28
|
+
if isinstance(value, dict):
|
|
29
|
+
return {
|
|
30
|
+
k: sanitize(v, allow)
|
|
31
|
+
for k, v in value.items()
|
|
32
|
+
if k.lower() not in CREDENTIALS and (allow or k.lower() not in FORBIDDEN)
|
|
33
|
+
}
|
|
34
|
+
if isinstance(value, list):
|
|
35
|
+
return [sanitize(v, allow) for v in value]
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_endpoint(value):
|
|
40
|
+
u = urllib.parse.urlsplit(value)
|
|
41
|
+
local = u.scheme == "http" and u.hostname in ("localhost", "127.0.0.1", "::1")
|
|
42
|
+
if (
|
|
43
|
+
not u.hostname
|
|
44
|
+
or (u.scheme != "https" and not local)
|
|
45
|
+
or u.username
|
|
46
|
+
or u.password
|
|
47
|
+
or u.query
|
|
48
|
+
or u.fragment
|
|
49
|
+
or u.path not in ("/events", "/public", "/secure")
|
|
50
|
+
):
|
|
51
|
+
raise ValueError("Invalid public Eventing endpoint")
|
|
52
|
+
_ = u.port
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
57
|
+
def redirect_request(self, *args):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _transport(endpoint, body, headers, timeout):
|
|
62
|
+
opener = urllib.request.build_opener(_NoRedirect())
|
|
63
|
+
try:
|
|
64
|
+
with opener.open(
|
|
65
|
+
urllib.request.Request(endpoint, data=body, headers=headers, method="POST"),
|
|
66
|
+
timeout=timeout,
|
|
67
|
+
) as r:
|
|
68
|
+
return r.status
|
|
69
|
+
except urllib.error.HTTPError as e:
|
|
70
|
+
status = e.code
|
|
71
|
+
e.close()
|
|
72
|
+
return status
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class EventingClient:
|
|
76
|
+
"""Durable bounded queue with automatic batching and outage recovery.
|
|
77
|
+
|
|
78
|
+
Calls are serialized by a lock. On any failed batch its events remain queued.
|
|
79
|
+
max_retries is the total number of attempts, matching the JS API.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
endpoint,
|
|
85
|
+
*,
|
|
86
|
+
default_namespace="general",
|
|
87
|
+
get_auth_token=None,
|
|
88
|
+
max_batch_events=25,
|
|
89
|
+
max_queue_items=1000,
|
|
90
|
+
max_batch_bytes=8192,
|
|
91
|
+
max_retries=3,
|
|
92
|
+
base_backoff_ms=200,
|
|
93
|
+
timeout=5,
|
|
94
|
+
allow_sensitive_properties=False,
|
|
95
|
+
transport=None,
|
|
96
|
+
sleep=time.sleep,
|
|
97
|
+
flush_interval_ms=1000,
|
|
98
|
+
max_batch_age_ms=5000,
|
|
99
|
+
queue_path=None,
|
|
100
|
+
on_error=None,
|
|
101
|
+
):
|
|
102
|
+
self.endpoint = validate_endpoint(endpoint)
|
|
103
|
+
for value in (
|
|
104
|
+
max_batch_events,
|
|
105
|
+
max_queue_items,
|
|
106
|
+
max_batch_bytes,
|
|
107
|
+
max_retries,
|
|
108
|
+
flush_interval_ms,
|
|
109
|
+
max_batch_age_ms,
|
|
110
|
+
):
|
|
111
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
|
112
|
+
raise ValueError("Limits must be positive integers")
|
|
113
|
+
if (
|
|
114
|
+
max_batch_events > 500
|
|
115
|
+
or max_retries > 10
|
|
116
|
+
or not math.isfinite(base_backoff_ms)
|
|
117
|
+
or not math.isfinite(timeout)
|
|
118
|
+
or base_backoff_ms < 0
|
|
119
|
+
or timeout <= 0
|
|
120
|
+
or not default_namespace.strip()
|
|
121
|
+
):
|
|
122
|
+
raise ValueError("Invalid client configuration")
|
|
123
|
+
self.namespace, self.get_auth_token = default_namespace, get_auth_token
|
|
124
|
+
self.max_batch_events, self.max_queue_items = max_batch_events, max_queue_items
|
|
125
|
+
self.max_batch_bytes, self.max_retries = max_batch_bytes, max_retries
|
|
126
|
+
self.backoff, self.timeout, self.allow = (
|
|
127
|
+
base_backoff_ms / 1000,
|
|
128
|
+
timeout,
|
|
129
|
+
allow_sensitive_properties,
|
|
130
|
+
)
|
|
131
|
+
self.transport, self.sleep = transport or _transport, sleep
|
|
132
|
+
self._lock = threading.RLock()
|
|
133
|
+
self._timer, self._started, self._closed = None, None, False
|
|
134
|
+
self.interval, self.max_age = flush_interval_ms / 1000, max_batch_age_ms / 1000
|
|
135
|
+
self.on_error = on_error or (lambda result: None)
|
|
136
|
+
# None chooses durable user storage; False explicitly selects memory only.
|
|
137
|
+
key = hashlib.sha256(
|
|
138
|
+
(self.endpoint + ":" + self.namespace).encode()
|
|
139
|
+
).hexdigest()
|
|
140
|
+
self.queue_path = (
|
|
141
|
+
pathlib.Path.home() / ".cache/bookcicle/eventing/python" / (key + ".json")
|
|
142
|
+
if queue_path is None
|
|
143
|
+
else pathlib.Path(queue_path) if queue_path else None
|
|
144
|
+
)
|
|
145
|
+
self._events = []
|
|
146
|
+
if self.queue_path and self.queue_path.exists():
|
|
147
|
+
self._events = json.loads(self.queue_path.read_text(encoding="utf-8"))
|
|
148
|
+
if (
|
|
149
|
+
not isinstance(self._events, list)
|
|
150
|
+
or len(self._events) > max_queue_items
|
|
151
|
+
):
|
|
152
|
+
raise ValueError("Invalid persisted queue")
|
|
153
|
+
if self._events:
|
|
154
|
+
self._schedule()
|
|
155
|
+
|
|
156
|
+
def _persist(self):
|
|
157
|
+
if self.queue_path:
|
|
158
|
+
self.queue_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
159
|
+
fd, name = tempfile.mkstemp(dir=self.queue_path.parent)
|
|
160
|
+
try:
|
|
161
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
162
|
+
json.dump(self._events, f, ensure_ascii=False, allow_nan=False)
|
|
163
|
+
f.flush()
|
|
164
|
+
os.fsync(f.fileno())
|
|
165
|
+
os.replace(name, self.queue_path)
|
|
166
|
+
finally:
|
|
167
|
+
if os.path.exists(name):
|
|
168
|
+
os.unlink(name)
|
|
169
|
+
|
|
170
|
+
def _schedule(self):
|
|
171
|
+
if self._closed:
|
|
172
|
+
return
|
|
173
|
+
if self._timer and len(self._events) < self.max_batch_events:
|
|
174
|
+
return
|
|
175
|
+
if self._timer:
|
|
176
|
+
self._timer.cancel()
|
|
177
|
+
self._started = self._started or time.monotonic()
|
|
178
|
+
delay = (
|
|
179
|
+
0
|
|
180
|
+
if len(self._events) >= self.max_batch_events
|
|
181
|
+
else min(
|
|
182
|
+
self.interval, max(0, self.max_age - (time.monotonic() - self._started))
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
self._timer = threading.Timer(delay, self._background)
|
|
186
|
+
self._timer.daemon = True
|
|
187
|
+
self._timer.start()
|
|
188
|
+
|
|
189
|
+
def _background(self):
|
|
190
|
+
try:
|
|
191
|
+
result = self.flush()
|
|
192
|
+
if not result["ok"]:
|
|
193
|
+
self.on_error(result)
|
|
194
|
+
except Exception as error:
|
|
195
|
+
try:
|
|
196
|
+
self.on_error(error)
|
|
197
|
+
except Exception:
|
|
198
|
+
pass
|
|
199
|
+
with self._lock:
|
|
200
|
+
if (
|
|
201
|
+
self._events
|
|
202
|
+
and not self._closed
|
|
203
|
+
and ("result" not in locals() or result.get("retryable", False))
|
|
204
|
+
):
|
|
205
|
+
self._schedule_recovery()
|
|
206
|
+
|
|
207
|
+
def _schedule_recovery(self):
|
|
208
|
+
if not self._closed:
|
|
209
|
+
if self._timer:
|
|
210
|
+
self._timer.cancel()
|
|
211
|
+
self._started = None
|
|
212
|
+
self._timer = threading.Timer(self.interval, self._background)
|
|
213
|
+
self._timer.daemon = True
|
|
214
|
+
self._timer.start()
|
|
215
|
+
|
|
216
|
+
def close(self):
|
|
217
|
+
with self._lock:
|
|
218
|
+
self._closed = True
|
|
219
|
+
if self._timer:
|
|
220
|
+
self._timer.cancel()
|
|
221
|
+
return self.flush()
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
def _body(events, batch_id=None):
|
|
225
|
+
return json.dumps(
|
|
226
|
+
{
|
|
227
|
+
"schema_version": "1",
|
|
228
|
+
"batch_id": batch_id or str(uuid.uuid4()),
|
|
229
|
+
"events": events,
|
|
230
|
+
},
|
|
231
|
+
ensure_ascii=False,
|
|
232
|
+
separators=(",", ":"),
|
|
233
|
+
allow_nan=False,
|
|
234
|
+
).encode("utf-8")
|
|
235
|
+
|
|
236
|
+
def track(self, event):
|
|
237
|
+
with self._lock:
|
|
238
|
+
if self._closed:
|
|
239
|
+
raise ValueError("Client is closed")
|
|
240
|
+
name = event.get("name")
|
|
241
|
+
if (
|
|
242
|
+
not isinstance(name, str)
|
|
243
|
+
or not name.strip()
|
|
244
|
+
or len(name.strip().encode()) > 128
|
|
245
|
+
or any(ord(c) < 32 or ord(c) == 127 for c in name)
|
|
246
|
+
):
|
|
247
|
+
raise ValueError("Invalid event name")
|
|
248
|
+
namespace = event.get(
|
|
249
|
+
"namespace", event.get("context", {}).get("namespace", self.namespace)
|
|
250
|
+
)
|
|
251
|
+
props = event.get("properties", event.get("props", {}))
|
|
252
|
+
if (
|
|
253
|
+
not isinstance(namespace, str)
|
|
254
|
+
or not namespace.strip()
|
|
255
|
+
or not isinstance(props, dict)
|
|
256
|
+
):
|
|
257
|
+
raise ValueError("Invalid namespace or properties")
|
|
258
|
+
event_id = event.get("event_id", str(uuid.uuid4()))
|
|
259
|
+
event_time = event.get(
|
|
260
|
+
"event_time",
|
|
261
|
+
dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
262
|
+
)
|
|
263
|
+
if (
|
|
264
|
+
not isinstance(event_id, str)
|
|
265
|
+
or not event_id
|
|
266
|
+
or not isinstance(event_time, str)
|
|
267
|
+
):
|
|
268
|
+
raise ValueError("Invalid event ID or time")
|
|
269
|
+
if not re.fullmatch(
|
|
270
|
+
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})",
|
|
271
|
+
event_time,
|
|
272
|
+
):
|
|
273
|
+
raise ValueError("event_time must use RFC 3339")
|
|
274
|
+
dt.datetime.fromisoformat(event_time.replace("Z", "+00:00"))
|
|
275
|
+
normalized = {
|
|
276
|
+
"event_id": event_id,
|
|
277
|
+
"name": name.strip(),
|
|
278
|
+
"event_time": event_time,
|
|
279
|
+
"context": {"namespace": namespace},
|
|
280
|
+
"properties": sanitize(props, self.allow),
|
|
281
|
+
}
|
|
282
|
+
if len(self._body([normalized])) > self.max_batch_bytes:
|
|
283
|
+
raise ValueError("Event exceeds max_batch_bytes")
|
|
284
|
+
if any(e["event_id"] == event_id for e in self._events):
|
|
285
|
+
raise ValueError("Duplicate queued event ID")
|
|
286
|
+
if len(self._events) >= self.max_queue_items:
|
|
287
|
+
raise ValueError("Event queue is full")
|
|
288
|
+
self._events.append(copy.deepcopy(normalized))
|
|
289
|
+
try:
|
|
290
|
+
self._persist()
|
|
291
|
+
except Exception:
|
|
292
|
+
self._events.pop()
|
|
293
|
+
raise
|
|
294
|
+
self._schedule()
|
|
295
|
+
return event_id
|
|
296
|
+
|
|
297
|
+
def get_queue_items(self):
|
|
298
|
+
with self._lock:
|
|
299
|
+
return copy.deepcopy(self._events)
|
|
300
|
+
|
|
301
|
+
def discard(self, event_ids):
|
|
302
|
+
with self._lock:
|
|
303
|
+
previous = self._events[:]
|
|
304
|
+
self._events = [e for e in self._events if e["event_id"] not in event_ids]
|
|
305
|
+
try:
|
|
306
|
+
self._persist()
|
|
307
|
+
except Exception:
|
|
308
|
+
self._events = previous
|
|
309
|
+
raise
|
|
310
|
+
|
|
311
|
+
def flush(self):
|
|
312
|
+
try:
|
|
313
|
+
return self._flush()
|
|
314
|
+
except Exception:
|
|
315
|
+
with self._lock:
|
|
316
|
+
self._schedule_recovery()
|
|
317
|
+
raise
|
|
318
|
+
|
|
319
|
+
def _flush(self):
|
|
320
|
+
with self._lock:
|
|
321
|
+
if self._timer:
|
|
322
|
+
self._timer.cancel()
|
|
323
|
+
self._started = None
|
|
324
|
+
self._timer = None
|
|
325
|
+
flushed = 0
|
|
326
|
+
while self._events:
|
|
327
|
+
batch = []
|
|
328
|
+
for event in self._events[: self.max_batch_events]:
|
|
329
|
+
if len(self._body(batch + [event])) > self.max_batch_bytes:
|
|
330
|
+
break
|
|
331
|
+
batch.append(event)
|
|
332
|
+
if not batch:
|
|
333
|
+
raise ValueError("Queued event exceeds max_batch_bytes")
|
|
334
|
+
body = self._body(batch)
|
|
335
|
+
headers = {
|
|
336
|
+
"Content-Type": "application/json",
|
|
337
|
+
"x-amz-content-sha256": hashlib.sha256(body).hexdigest(),
|
|
338
|
+
}
|
|
339
|
+
token = self.get_auth_token() if self.get_auth_token else None
|
|
340
|
+
if token:
|
|
341
|
+
if not isinstance(token, str) or any(
|
|
342
|
+
ord(c) < 32 or ord(c) == 127 for c in token
|
|
343
|
+
):
|
|
344
|
+
raise ValueError("Invalid auth token")
|
|
345
|
+
if token.strip():
|
|
346
|
+
headers["x-event-auth"] = (
|
|
347
|
+
token.strip()
|
|
348
|
+
if token.strip().startswith("Bearer ")
|
|
349
|
+
else "Bearer " + token.strip()
|
|
350
|
+
)
|
|
351
|
+
status = 503
|
|
352
|
+
for attempt in range(self.max_retries):
|
|
353
|
+
try:
|
|
354
|
+
status = self.transport(
|
|
355
|
+
self.endpoint, body, headers, self.timeout
|
|
356
|
+
)
|
|
357
|
+
except (OSError, urllib.error.URLError):
|
|
358
|
+
status = 503
|
|
359
|
+
if status == 202 or status not in RETRYABLE:
|
|
360
|
+
break
|
|
361
|
+
if attempt + 1 < self.max_retries:
|
|
362
|
+
self.sleep(self.backoff * 2**attempt)
|
|
363
|
+
if status != 202:
|
|
364
|
+
if status in RETRYABLE:
|
|
365
|
+
self._schedule_recovery()
|
|
366
|
+
return {
|
|
367
|
+
"ok": False,
|
|
368
|
+
"status": status,
|
|
369
|
+
"retryable": status in RETRYABLE,
|
|
370
|
+
"flushed": flushed,
|
|
371
|
+
}
|
|
372
|
+
previous = self._events[:]
|
|
373
|
+
del self._events[: len(batch)]
|
|
374
|
+
try:
|
|
375
|
+
self._persist()
|
|
376
|
+
except Exception:
|
|
377
|
+
self._events = previous
|
|
378
|
+
raise
|
|
379
|
+
flushed += len(batch)
|
|
380
|
+
return {"ok": True, "flushed": flushed}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bookcicle-eventing"
|
|
7
|
+
version = "1.0.1"
|
|
8
|
+
description = "Privacy-conscious Eventing by Bookcicle SDK"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{name = "Bookcicle"}]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Repository = "https://github.com/bookcicle/bookcicle-eventing"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools]
|
|
19
|
+
packages = ["eventing_sdk"]
|