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.
@@ -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,9 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ bookcicle_eventing.egg-info/PKG-INFO
6
+ bookcicle_eventing.egg-info/SOURCES.txt
7
+ bookcicle_eventing.egg-info/dependency_links.txt
8
+ bookcicle_eventing.egg-info/top_level.txt
9
+ eventing_sdk/__init__.py
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+