whisperr 0.1.0__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,24 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ test:
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: ${{ matrix.python-version }}
22
+ - run: python -m pip install --upgrade pip
23
+ - run: pip install -e ".[dev]"
24
+ - run: pytest -q
@@ -0,0 +1,33 @@
1
+ name: Publish
2
+
3
+ # Publishes whisperr to PyPI when a version tag is pushed:
4
+ # git tag v0.1.0 && git push origin v0.1.0
5
+ #
6
+ # Auth: PyPI Trusted Publishing (OIDC) — NO stored token. Before the first
7
+ # publish, configure a Trusted Publisher for the `whisperr` project on pypi.org
8
+ # (Publishing → Add a new pending publisher: owner WhisperrAI, repo
9
+ # whisperr-python, workflow publish.yml, environment pypi).
10
+
11
+ on:
12
+ push:
13
+ tags:
14
+ - "v*"
15
+
16
+ permissions:
17
+ contents: read
18
+ id-token: write # required for PyPI trusted publishing
19
+
20
+ jobs:
21
+ publish:
22
+ runs-on: ubuntu-latest
23
+ environment: pypi
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ - uses: actions/setup-python@v5
27
+ with:
28
+ python-version: "3.12"
29
+ - run: python -m pip install --upgrade pip build
30
+ - run: pip install -e ".[dev]"
31
+ - run: pytest -q
32
+ - run: python -m build
33
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .DS_Store
whisperr-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Whisperr
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,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: whisperr
3
+ Version: 0.1.0
4
+ Summary: Whisperr server-side SDK for Python — reliable churn-signal event tracking.
5
+ Project-URL: Homepage, https://whisperr.net
6
+ Project-URL: Repository, https://github.com/WhisperrAI/whisperr-python
7
+ Author: Whisperr
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: analytics,churn,events,retention,tracking,whisperr
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Requires-Python: >=3.8
17
+ Provides-Extra: dev
18
+ Requires-Dist: django>=3.2; extra == 'dev'
19
+ Requires-Dist: pytest>=7; extra == 'dev'
20
+ Provides-Extra: django
21
+ Requires-Dist: django>=3.2; extra == 'django'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # whisperr (Python)
25
+
26
+ The Whisperr **server-side** SDK for Python — reliable churn-signal event
27
+ tracking for any Python backend. The backend is where the highest-signal churn
28
+ events live (payment failures, cancellations, trial expiry, usage drops), so
29
+ this is where Whisperr gets its most valuable signal.
30
+
31
+ ```bash
32
+ pip install whisperr
33
+ ```
34
+
35
+ ## Quick start
36
+
37
+ ```python
38
+ import os
39
+ from whisperr import Whisperr
40
+
41
+ whisperr = Whisperr(api_key=os.environ["WHISPERR_API_KEY"])
42
+
43
+ # A server-side churn signal:
44
+ whisperr.track("user_8842", "payment_failed", {"amount_cents": 4900, "reason": "card_declined"})
45
+
46
+ # Associate traits / contact channels with a user:
47
+ whisperr.identify("user_8842", email="ada@example.com", traits={"plan": "pro"})
48
+
49
+ # Deliver everything before the process exits:
50
+ whisperr.shutdown()
51
+ ```
52
+
53
+ The user id (`external_user_id`) is **always explicit** here — unlike a browser
54
+ SDK, the server has no persisted session to infer it from. Pass the same id you
55
+ use everywhere else for that user, and frontend + backend events land on one
56
+ timeline automatically.
57
+
58
+ ## Design
59
+
60
+ - **Same wire contract as the other Whisperr SDKs.** Events post to
61
+ `/v1/events/batch`, identities to `/v1/identify`, authenticated with
62
+ `X-API-Key`.
63
+ - **Non-blocking.** `track()`/`identify()` enqueue and return immediately; a
64
+ background thread batches and delivers. Call `flush()` for a barrier.
65
+ - **Reliable in-process.** In-memory queue, batching, retry with backoff
66
+ (429/5xx), malformed-4xx drop, per-event idempotency key. On `401/403` or
67
+ exhausted retries, delivery pauses and **events stay buffered for the next
68
+ flush** rather than being dropped. The queue is not crash-durable — call
69
+ `flush()`/`shutdown()` before exit (see *Short-lived processes*).
70
+ - **Process-friendly.** A daemon thread that never blocks exit; `shutdown()` is
71
+ also registered via `atexit`.
72
+ - **Zero runtime dependencies.** Uses the standard library only.
73
+
74
+ ## Django
75
+
76
+ ```python
77
+ # settings.py
78
+ WHISPERR_API_KEY = os.environ["WHISPERR_API_KEY"]
79
+ MIDDLEWARE = [
80
+ # ... after your authentication middleware ...
81
+ "whisperr.django.WhisperrMiddleware",
82
+ ]
83
+ ```
84
+
85
+ ```python
86
+ # views.py
87
+ def upgrade(request):
88
+ request.whisperr.track("plan_upgraded", {"plan": "pro"})
89
+ ...
90
+ ```
91
+
92
+ `request.whisperr.track()` is bound to the request's authenticated user
93
+ (`request.user.pk` by default; override with `WHISPERR_RESOLVE_USER`, a dotted
94
+ path to `def resolve(request) -> str | None`). For events with no request —
95
+ Celery tasks, webhooks, management commands — use a client directly with the
96
+ user id from your domain data:
97
+
98
+ ```python
99
+ from whisperr.django import get_client
100
+ get_client().track(subscription.user_id, "subscription_cancelled", {"reason": reason})
101
+ ```
102
+
103
+ Install the Django extra if you want the dependency pinned: `pip install whisperr[django]`.
104
+
105
+ ## Short-lived processes
106
+
107
+ In scripts, serverless handlers, or management commands, call `flush()` (or
108
+ `shutdown()`) before exit so queued events aren't lost:
109
+
110
+ ```python
111
+ whisperr.track(user_id, "report_generated")
112
+ whisperr.flush()
113
+ ```
114
+
115
+ ## Options
116
+
117
+ | Option | Default | Notes |
118
+ |---|---|---|
119
+ | `api_key` | — | App ingestion key (`wrk_…`). Required. |
120
+ | `base_url` | `https://api.whisperr.net` | Ingestion base URL. |
121
+ | `flush_at` | `20` | Flush when this many events are queued. |
122
+ | `flush_interval` | `10.0` | Background flush cadence (seconds). |
123
+ | `max_queue_size` | `10000` | Oldest events drop on overflow. |
124
+ | `max_batch_size` | `500` | Events per batch (hard backend cap is 500). |
125
+ | `max_retries` | `6` | Consecutive retries before giving up a batch. |
126
+ | `request_timeout` | `10.0` | Per-request timeout (seconds). |
127
+ | `disabled` | `False` | No-op client (useful in tests). |
128
+ | `debug` | `False` | Verbose logging to stderr. |
129
+ | `on_error` | — | `Callable[[WhisperrError], None]` for observability. |
130
+
131
+ ---
132
+
133
+ Whisperr — predict churn, automate interventions, recover revenue.
134
+ [whisperr.net](https://whisperr.net)
@@ -0,0 +1,111 @@
1
+ # whisperr (Python)
2
+
3
+ The Whisperr **server-side** SDK for Python — reliable churn-signal event
4
+ tracking for any Python backend. The backend is where the highest-signal churn
5
+ events live (payment failures, cancellations, trial expiry, usage drops), so
6
+ this is where Whisperr gets its most valuable signal.
7
+
8
+ ```bash
9
+ pip install whisperr
10
+ ```
11
+
12
+ ## Quick start
13
+
14
+ ```python
15
+ import os
16
+ from whisperr import Whisperr
17
+
18
+ whisperr = Whisperr(api_key=os.environ["WHISPERR_API_KEY"])
19
+
20
+ # A server-side churn signal:
21
+ whisperr.track("user_8842", "payment_failed", {"amount_cents": 4900, "reason": "card_declined"})
22
+
23
+ # Associate traits / contact channels with a user:
24
+ whisperr.identify("user_8842", email="ada@example.com", traits={"plan": "pro"})
25
+
26
+ # Deliver everything before the process exits:
27
+ whisperr.shutdown()
28
+ ```
29
+
30
+ The user id (`external_user_id`) is **always explicit** here — unlike a browser
31
+ SDK, the server has no persisted session to infer it from. Pass the same id you
32
+ use everywhere else for that user, and frontend + backend events land on one
33
+ timeline automatically.
34
+
35
+ ## Design
36
+
37
+ - **Same wire contract as the other Whisperr SDKs.** Events post to
38
+ `/v1/events/batch`, identities to `/v1/identify`, authenticated with
39
+ `X-API-Key`.
40
+ - **Non-blocking.** `track()`/`identify()` enqueue and return immediately; a
41
+ background thread batches and delivers. Call `flush()` for a barrier.
42
+ - **Reliable in-process.** In-memory queue, batching, retry with backoff
43
+ (429/5xx), malformed-4xx drop, per-event idempotency key. On `401/403` or
44
+ exhausted retries, delivery pauses and **events stay buffered for the next
45
+ flush** rather than being dropped. The queue is not crash-durable — call
46
+ `flush()`/`shutdown()` before exit (see *Short-lived processes*).
47
+ - **Process-friendly.** A daemon thread that never blocks exit; `shutdown()` is
48
+ also registered via `atexit`.
49
+ - **Zero runtime dependencies.** Uses the standard library only.
50
+
51
+ ## Django
52
+
53
+ ```python
54
+ # settings.py
55
+ WHISPERR_API_KEY = os.environ["WHISPERR_API_KEY"]
56
+ MIDDLEWARE = [
57
+ # ... after your authentication middleware ...
58
+ "whisperr.django.WhisperrMiddleware",
59
+ ]
60
+ ```
61
+
62
+ ```python
63
+ # views.py
64
+ def upgrade(request):
65
+ request.whisperr.track("plan_upgraded", {"plan": "pro"})
66
+ ...
67
+ ```
68
+
69
+ `request.whisperr.track()` is bound to the request's authenticated user
70
+ (`request.user.pk` by default; override with `WHISPERR_RESOLVE_USER`, a dotted
71
+ path to `def resolve(request) -> str | None`). For events with no request —
72
+ Celery tasks, webhooks, management commands — use a client directly with the
73
+ user id from your domain data:
74
+
75
+ ```python
76
+ from whisperr.django import get_client
77
+ get_client().track(subscription.user_id, "subscription_cancelled", {"reason": reason})
78
+ ```
79
+
80
+ Install the Django extra if you want the dependency pinned: `pip install whisperr[django]`.
81
+
82
+ ## Short-lived processes
83
+
84
+ In scripts, serverless handlers, or management commands, call `flush()` (or
85
+ `shutdown()`) before exit so queued events aren't lost:
86
+
87
+ ```python
88
+ whisperr.track(user_id, "report_generated")
89
+ whisperr.flush()
90
+ ```
91
+
92
+ ## Options
93
+
94
+ | Option | Default | Notes |
95
+ |---|---|---|
96
+ | `api_key` | — | App ingestion key (`wrk_…`). Required. |
97
+ | `base_url` | `https://api.whisperr.net` | Ingestion base URL. |
98
+ | `flush_at` | `20` | Flush when this many events are queued. |
99
+ | `flush_interval` | `10.0` | Background flush cadence (seconds). |
100
+ | `max_queue_size` | `10000` | Oldest events drop on overflow. |
101
+ | `max_batch_size` | `500` | Events per batch (hard backend cap is 500). |
102
+ | `max_retries` | `6` | Consecutive retries before giving up a batch. |
103
+ | `request_timeout` | `10.0` | Per-request timeout (seconds). |
104
+ | `disabled` | `False` | No-op client (useful in tests). |
105
+ | `debug` | `False` | Verbose logging to stderr. |
106
+ | `on_error` | — | `Callable[[WhisperrError], None]` for observability. |
107
+
108
+ ---
109
+
110
+ Whisperr — predict churn, automate interventions, recover revenue.
111
+ [whisperr.net](https://whisperr.net)
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "whisperr"
7
+ dynamic = ["version"]
8
+ description = "Whisperr server-side SDK for Python — reliable churn-signal event tracking."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.8"
12
+ authors = [{ name = "Whisperr" }]
13
+ keywords = ["whisperr", "analytics", "churn", "events", "tracking", "retention"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Intended Audience :: Developers",
19
+ "Topic :: Software Development :: Libraries",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.urls]
24
+ Homepage = "https://whisperr.net"
25
+ Repository = "https://github.com/WhisperrAI/whisperr-python"
26
+
27
+ [project.optional-dependencies]
28
+ django = ["django>=3.2"]
29
+ dev = ["pytest>=7", "django>=3.2"]
30
+
31
+ [tool.hatch.version]
32
+ path = "src/whisperr/__init__.py"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/whisperr"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
@@ -0,0 +1,18 @@
1
+ """Whisperr server-side SDK for Python.
2
+
3
+ Reliable churn-signal event tracking for any Python backend. The backend is
4
+ where the highest-signal churn events live (payment failures, cancellations,
5
+ trial expiry, usage drops), so this is where Whisperr gets its best signal.
6
+
7
+ from whisperr import Whisperr
8
+
9
+ whisperr = Whisperr(api_key=os.environ["WHISPERR_API_KEY"])
10
+ whisperr.track("user_8842", "payment_failed", {"amount_cents": 4900})
11
+ whisperr.shutdown() # flush before the process exits
12
+ """
13
+
14
+ from .client import Whisperr
15
+ from .errors import WhisperrError
16
+
17
+ __all__ = ["Whisperr", "WhisperrError", "__version__"]
18
+ __version__ = "0.1.0"
@@ -0,0 +1,275 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import random
5
+ import re
6
+ import sys
7
+ import threading
8
+ import time
9
+ import uuid
10
+ from collections import deque
11
+ from datetime import datetime, timezone
12
+ from typing import Any, Callable, Deque, Dict, List, Optional, Sequence
13
+
14
+ from .errors import WhisperrError
15
+ from .transport import Transport
16
+
17
+ DEFAULT_BASE = "https://api.whisperr.net"
18
+ # Mirrors the server's accepted event_type shape.
19
+ _SNAKE = re.compile(r"^[a-z0-9]+(?:_[a-z0-9]+)*$")
20
+
21
+ Channel = Dict[str, Any]
22
+ OnError = Callable[[WhisperrError], None]
23
+
24
+
25
+ class Whisperr:
26
+ """A Whisperr client for a Python backend.
27
+
28
+ Hold one instance per process and call :meth:`shutdown` before exit (also
29
+ registered via ``atexit``). ``track``/``identify`` are non-blocking: they
30
+ enqueue and a background thread batches + delivers with retries.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ api_key: str,
36
+ *,
37
+ base_url: str = DEFAULT_BASE,
38
+ flush_at: int = 20,
39
+ flush_interval: float = 10.0,
40
+ max_queue_size: int = 10000,
41
+ max_batch_size: int = 500,
42
+ max_retries: int = 6,
43
+ request_timeout: float = 10.0,
44
+ disabled: bool = False,
45
+ debug: bool = False,
46
+ on_error: Optional[OnError] = None,
47
+ transport: Optional[Transport] = None,
48
+ ) -> None:
49
+ if not api_key and not disabled:
50
+ raise ValueError("api_key is required")
51
+ self._muted = bool(disabled)
52
+ self._flush_at = flush_at
53
+ self._max_batch = min(max_batch_size, 500)
54
+ self._max_queue = max_queue_size
55
+ self._max_retries = max_retries
56
+ self._debug = debug
57
+ self._on_error = on_error
58
+ self._flush_interval = flush_interval
59
+ self._transport = transport or Transport(base_url, api_key, request_timeout, self._warn)
60
+
61
+ # Ordered in-memory buffer. A drain pass takes a batch off the front,
62
+ # delivers it, and only puts it back (at the front) if delivery gives up —
63
+ # so a failed flush *retains* events for the next flush instead of
64
+ # dropping them. There's no durable store: a process crash loses unsent
65
+ # events, which is an acceptable trade for zero I/O on the hot path.
66
+ self._buf: Deque[Dict[str, Any]] = deque()
67
+ self._buf_lock = threading.Lock()
68
+ # Serializes delivery so the background thread and an explicit flush()
69
+ # never send concurrently (and never fight over the same batch).
70
+ self._deliver_lock = threading.Lock()
71
+ self._wake = threading.Event()
72
+
73
+ self._running = not self._muted
74
+ self._thread: Optional[threading.Thread] = None
75
+
76
+ if not self._muted:
77
+ self._thread = threading.Thread(target=self._run, name="whisperr", daemon=True)
78
+ self._thread.start()
79
+ atexit.register(self.shutdown)
80
+
81
+ @property
82
+ def ready(self) -> bool:
83
+ return not self._muted
84
+
85
+ def identify(
86
+ self,
87
+ external_user_id: str,
88
+ *,
89
+ traits: Optional[Dict[str, Any]] = None,
90
+ email: Optional[str] = None,
91
+ phone: Optional[str] = None,
92
+ push_token: Optional[str] = None,
93
+ preferred_channel: Optional[str] = None,
94
+ channels: Optional[Sequence[Channel]] = None,
95
+ ) -> None:
96
+ """Associate traits/contact channels with a user. ``external_user_id``
97
+ is always explicit — pass the same id you use in :meth:`track`."""
98
+ if self._muted or not external_user_id:
99
+ return
100
+ self._enqueue(
101
+ {
102
+ "kind": "identify",
103
+ "external_user_id": external_user_id,
104
+ "traits": traits,
105
+ "preferred_channel": preferred_channel,
106
+ "channels": list(channels)
107
+ if channels is not None
108
+ else _build_channels(email, phone, push_token),
109
+ }
110
+ )
111
+
112
+ def track(
113
+ self,
114
+ external_user_id: str,
115
+ event_type: str,
116
+ properties: Optional[Dict[str, Any]] = None,
117
+ context: Optional[Dict[str, Any]] = None,
118
+ ) -> None:
119
+ """Record a product event for a known user."""
120
+ if self._muted or not external_user_id or not event_type:
121
+ return
122
+ if self._debug and not _SNAKE.match(event_type):
123
+ self._warn(f'event_type "{event_type}" is not snake_case — the server will reject it')
124
+ self._enqueue(
125
+ {
126
+ "kind": "track",
127
+ "external_user_id": external_user_id,
128
+ "event_type": event_type,
129
+ "properties": properties,
130
+ "context": context,
131
+ "occurred_at": _now_iso(),
132
+ "message_id": uuid.uuid4().hex,
133
+ }
134
+ )
135
+
136
+ def flush(self) -> None:
137
+ """Best-effort barrier: deliver everything currently buffered, blocking
138
+ until the buffer drains or delivery can't proceed (auth/transport
139
+ failure). Events that can't be delivered stay buffered for the next
140
+ flush rather than being dropped."""
141
+ if self._muted:
142
+ return
143
+ self._drain_once()
144
+
145
+ def shutdown(self) -> None:
146
+ """Stop the background thread after a final delivery pass. Idempotent."""
147
+ if self._muted or not self._running:
148
+ return
149
+ self._running = False
150
+ self._wake.set()
151
+ if self._thread is not None:
152
+ self._thread.join()
153
+
154
+ # ---- internals ----
155
+
156
+ def _enqueue(self, op: Dict[str, Any]) -> None:
157
+ dropped = 0
158
+ with self._buf_lock:
159
+ self._buf.append(op)
160
+ while len(self._buf) > self._max_queue:
161
+ self._buf.popleft()
162
+ dropped += 1
163
+ size = len(self._buf)
164
+ if dropped:
165
+ self._emit("dropped", f"queue full — dropped {dropped} oldest event(s)")
166
+ # Wake the worker to flush early once enough has accumulated.
167
+ if size >= self._flush_at:
168
+ self._wake.set()
169
+
170
+ def _run(self) -> None:
171
+ # Periodic + size-triggered delivery. Always makes one final pass after
172
+ # shutdown so the loop can't spin on an undeliverable (retained) batch.
173
+ while True:
174
+ self._wake.wait(timeout=self._flush_interval)
175
+ self._wake.clear()
176
+ try:
177
+ self._drain_once()
178
+ except Exception:
179
+ pass # never let the worker thread die on a delivery error
180
+ if not self._running:
181
+ return
182
+
183
+ def _take_batch(self) -> List[Dict[str, Any]]:
184
+ """Remove and return the next batch from the front of the buffer.
185
+
186
+ An identify is sent on its own; track ops are grouped into a leading run
187
+ (up to ``max_batch``). Returns ``[]`` when the buffer is empty."""
188
+ with self._buf_lock:
189
+ if not self._buf:
190
+ return []
191
+ if self._buf[0]["kind"] == "identify":
192
+ return [self._buf.popleft()]
193
+ batch: List[Dict[str, Any]] = []
194
+ while self._buf and len(batch) < self._max_batch and self._buf[0]["kind"] == "track":
195
+ batch.append(self._buf.popleft())
196
+ return batch
197
+
198
+ def _requeue_front(self, batch: List[Dict[str, Any]]) -> None:
199
+ with self._buf_lock:
200
+ self._buf.extendleft(reversed(batch))
201
+
202
+ def _drain_once(self) -> None:
203
+ # One delivery pass, serialized against the background thread.
204
+ with self._deliver_lock:
205
+ while True:
206
+ batch = self._take_batch()
207
+ if not batch:
208
+ return
209
+ result = self._deliver(batch)
210
+ if result in ("ok", "drop"):
211
+ continue
212
+ # auth / retry_exhausted: hand the batch back to the front and
213
+ # stop; a later flush retries from where we left off.
214
+ self._requeue_front(batch)
215
+ return
216
+
217
+ def _deliver(self, batch: List[Dict[str, Any]]) -> str:
218
+ if batch[0]["kind"] == "identify":
219
+ send: Callable[[], str] = lambda: self._transport.send_identify(batch[0])
220
+ count = 1
221
+ else:
222
+ send = lambda: self._transport.send_batch(batch)
223
+ count = len(batch)
224
+ retries = 0
225
+ while True:
226
+ result = send()
227
+ if result == "ok":
228
+ return "ok"
229
+ if result == "drop":
230
+ self._emit("dropped", f"dropped {count} event(s) — rejected by server")
231
+ return "drop"
232
+ if result == "auth":
233
+ self._emit("auth", "delivery paused — API key rejected", 401)
234
+ return "auth"
235
+ retries += 1
236
+ # Stop early when shutting down so exit isn't held up by backoff.
237
+ if retries > self._max_retries or not self._running:
238
+ self._emit("retry_exhausted", "delivery failed after retries; will retry on next flush")
239
+ return "retry_exhausted"
240
+ time.sleep(_backoff(retries))
241
+
242
+ def _emit(self, type_: str, message: str, status: Optional[int] = None) -> None:
243
+ if not self._on_error:
244
+ return
245
+ try:
246
+ self._on_error(WhisperrError(type=type_, message=message, status=status))
247
+ except Exception:
248
+ pass # host callback threw — ignore
249
+
250
+ def _warn(self, msg: str) -> None:
251
+ if self._debug:
252
+ print(f"[whisperr] {msg}", file=sys.stderr)
253
+
254
+
255
+ def _build_channels(
256
+ email: Optional[str], phone: Optional[str], push_token: Optional[str]
257
+ ) -> Optional[List[Channel]]:
258
+ out: List[Channel] = []
259
+ if email:
260
+ out.append({"type": "email", "address": email, "opted_in": True})
261
+ if phone:
262
+ out.append({"type": "sms", "address": phone, "opted_in": True})
263
+ if push_token:
264
+ out.append({"type": "push", "address": push_token, "opted_in": True})
265
+ return out or None
266
+
267
+
268
+ def _backoff(attempt: int) -> float:
269
+ base = min(30.0, 1.0 * (2 ** attempt))
270
+ return base + random.random() * 0.25
271
+
272
+
273
+ def _now_iso() -> str:
274
+ now = datetime.now(timezone.utc)
275
+ return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
@@ -0,0 +1,93 @@
1
+ """Django integration for Whisperr.
2
+
3
+ Add to settings.py::
4
+
5
+ WHISPERR_API_KEY = os.environ["WHISPERR_API_KEY"]
6
+ MIDDLEWARE = [
7
+ # ... after your authentication middleware ...
8
+ "whisperr.django.WhisperrMiddleware",
9
+ ]
10
+
11
+ Then in a view::
12
+
13
+ request.whisperr.track("plan_upgraded", {"plan": "pro"})
14
+
15
+ For events with no request (Celery tasks, webhooks, management commands), use a
16
+ client directly with the user id from your domain data.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from importlib import import_module
22
+ from typing import Any, Callable, Dict, Optional, Sequence
23
+
24
+ from .client import Whisperr
25
+
26
+ _client: Optional[Whisperr] = None
27
+
28
+
29
+ def get_client() -> Whisperr:
30
+ """The process-wide Whisperr client, built from Django settings on first use."""
31
+ global _client
32
+ if _client is None:
33
+ from django.conf import settings
34
+
35
+ _client = Whisperr(
36
+ api_key=getattr(settings, "WHISPERR_API_KEY", ""),
37
+ base_url=getattr(settings, "WHISPERR_BASE_URL", "https://api.whisperr.net"),
38
+ disabled=getattr(settings, "WHISPERR_DISABLED", False),
39
+ debug=getattr(settings, "WHISPERR_DEBUG", False),
40
+ )
41
+ return _client
42
+
43
+
44
+ class _RequestWhisperr:
45
+ """Per-request helper bound to the request's resolved user."""
46
+
47
+ def __init__(self, client: Whisperr, user_id: Optional[str]) -> None:
48
+ self.client = client
49
+ self.user_id = user_id
50
+
51
+ def track(
52
+ self,
53
+ event_type: str,
54
+ properties: Optional[Dict[str, Any]] = None,
55
+ context: Optional[Dict[str, Any]] = None,
56
+ ) -> None:
57
+ if not self.user_id:
58
+ return
59
+ self.client.track(self.user_id, event_type, properties, context)
60
+
61
+ def identify(self, **kwargs: Any) -> None:
62
+ if not self.user_id:
63
+ return
64
+ self.client.identify(self.user_id, **kwargs)
65
+
66
+
67
+ def _default_resolve_user(request: Any) -> Optional[str]:
68
+ user = getattr(request, "user", None)
69
+ if user is not None and getattr(user, "is_authenticated", False):
70
+ return str(user.pk)
71
+ return None
72
+
73
+
74
+ def _import_callable(dotted: str) -> Callable[[Any], Optional[str]]:
75
+ module_path, _, attr = dotted.rpartition(".")
76
+ module = import_module(module_path)
77
+ return getattr(module, attr)
78
+
79
+
80
+ class WhisperrMiddleware:
81
+ """Attaches ``request.whisperr`` bound to the request's user."""
82
+
83
+ def __init__(self, get_response: Callable[[Any], Any]) -> None:
84
+ self.get_response = get_response
85
+ self.client = get_client()
86
+ from django.conf import settings
87
+
88
+ resolver = getattr(settings, "WHISPERR_RESOLVE_USER", None)
89
+ self.resolve_user = _import_callable(resolver) if resolver else _default_resolve_user
90
+
91
+ def __call__(self, request: Any) -> Any:
92
+ request.whisperr = _RequestWhisperr(self.client, self.resolve_user(request))
93
+ return self.get_response(request)
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class WhisperrError:
9
+ """Passed to the ``on_error`` callback for delivery/drop observability."""
10
+
11
+ type: str # "auth" | "dropped" | "retry_exhausted"
12
+ message: str
13
+ status: Optional[int] = None
File without changes
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.request
6
+ from typing import Any, Callable, Dict, List
7
+
8
+ # Delivery outcome of one request, mirroring the @whisperr/node SDK:
9
+ # "ok" — delivered
10
+ # "retry" — transient (429, 5xx, network/timeout)
11
+ # "auth" — key rejected (401/403); stop and surface
12
+ # "drop" — other 4xx (malformed); discard to avoid an infinite retry loop
13
+ SendResult = str
14
+
15
+
16
+ class Transport:
17
+ """HTTP transport for the Whisperr ingestion API (stdlib urllib, zero deps)."""
18
+
19
+ def __init__(
20
+ self,
21
+ base_url: str,
22
+ api_key: str,
23
+ timeout: float,
24
+ warn: Callable[[str], None],
25
+ ) -> None:
26
+ self._base = base_url.rstrip("/")
27
+ self._key = api_key
28
+ self._timeout = timeout
29
+ self._warn = warn
30
+
31
+ def send_batch(self, events: List[Dict[str, Any]]) -> SendResult:
32
+ payload = {
33
+ "events": [
34
+ {
35
+ "external_user_id": e["external_user_id"],
36
+ "event_type": e["event_type"],
37
+ "occurred_at": e["occurred_at"],
38
+ "properties": e.get("properties") or {},
39
+ # $message_id is an idempotency key for backend dedup, nested
40
+ # in the free-form context so the strict ingestion accepts it.
41
+ "context": {**(e.get("context") or {}), "$message_id": e["message_id"]},
42
+ }
43
+ for e in events
44
+ ]
45
+ }
46
+ if not payload["events"]:
47
+ return "ok"
48
+ return self._post("/v1/events/batch", payload)
49
+
50
+ def send_identify(self, op: Dict[str, Any]) -> SendResult:
51
+ body: Dict[str, Any] = {"external_user_id": op["external_user_id"]}
52
+ if op.get("traits"):
53
+ body["traits"] = op["traits"]
54
+ if op.get("preferred_channel"):
55
+ body["preferred_channel"] = op["preferred_channel"]
56
+ channels = op.get("channels")
57
+ if channels:
58
+ body["channels"] = [
59
+ {
60
+ "channel": c["type"],
61
+ "address": c["address"],
62
+ "opted_in": c.get("opted_in", True),
63
+ **({"verified": c["verified"]} if "verified" in c else {}),
64
+ }
65
+ for c in channels
66
+ ]
67
+ return self._post("/v1/identify", body)
68
+
69
+ def _post(self, path: str, body: Any) -> SendResult:
70
+ data = json.dumps(body).encode("utf-8")
71
+ req = urllib.request.Request(
72
+ self._base + path,
73
+ data=data,
74
+ method="POST",
75
+ headers={"Content-Type": "application/json", "X-API-Key": self._key},
76
+ )
77
+ try:
78
+ with urllib.request.urlopen(req, timeout=self._timeout):
79
+ return "ok" # urlopen raises on >= 400, so reaching here is 2xx/3xx
80
+ except urllib.error.HTTPError as e:
81
+ if e.code in (401, 403):
82
+ self._warn(f"auth rejected ({e.code}) — check your Whisperr API key")
83
+ return "auth"
84
+ if e.code == 429 or e.code >= 500:
85
+ return "retry"
86
+ self._warn(f"request to {path} dropped ({e.code})")
87
+ return "drop"
88
+ except Exception:
89
+ # Network error / timeout — retry later.
90
+ return "retry"
@@ -0,0 +1,232 @@
1
+ import json
2
+ import urllib.error
3
+ import urllib.request
4
+
5
+ import pytest
6
+
7
+ from whisperr import Whisperr, WhisperrError
8
+ from whisperr.transport import Transport
9
+
10
+
11
+ class FakeTransport:
12
+ """Records ops at the client boundary (before wire serialization)."""
13
+
14
+ def __init__(self, result="ok"):
15
+ self.result = result
16
+ self.batches = []
17
+ self.identifies = []
18
+
19
+ def send_batch(self, events):
20
+ self.batches.append([dict(e) for e in events])
21
+ return self.result
22
+
23
+ def send_identify(self, op):
24
+ self.identifies.append(dict(op))
25
+ return self.result
26
+
27
+
28
+ def make_client(transport, **kwargs):
29
+ return Whisperr(
30
+ api_key="wrk_test",
31
+ transport=transport,
32
+ flush_interval=0.05,
33
+ **kwargs,
34
+ )
35
+
36
+
37
+ # ---- client behavior ----
38
+
39
+ def test_track_delivers_events_with_explicit_user_and_message_id():
40
+ t = FakeTransport()
41
+ w = make_client(t)
42
+ try:
43
+ w.track("user_8842", "payment_failed", {"amount_cents": 4900})
44
+ w.track("user_8842", "subscription_cancelled")
45
+ w.flush()
46
+ finally:
47
+ w.shutdown()
48
+
49
+ # Batching is opportunistic, so events may arrive in one or several batches —
50
+ # what matters is that all of them are delivered, correctly shaped.
51
+ events = [e for batch in t.batches for e in batch]
52
+ assert len(events) == 2
53
+ by_type = {e["event_type"]: e for e in events}
54
+ assert by_type["payment_failed"]["external_user_id"] == "user_8842"
55
+ assert by_type["payment_failed"]["properties"] == {"amount_cents": 4900}
56
+ assert by_type["payment_failed"]["occurred_at"].endswith("Z")
57
+ assert len({e["message_id"] for e in events}) == 2
58
+
59
+
60
+ def test_bulk_delivery():
61
+ # All queued events are delivered exactly once (across however many batches).
62
+ t = FakeTransport()
63
+ w = Whisperr(api_key="wrk_test", transport=t, flush_interval=10.0)
64
+ try:
65
+ for i in range(50):
66
+ w.track("user_1", f"event_{i}")
67
+ w.flush()
68
+ finally:
69
+ w.shutdown()
70
+ delivered = [e for batch in t.batches for e in batch]
71
+ assert len(delivered) == 50
72
+ assert len({e["event_type"] for e in delivered}) == 50 # no dupes, none lost
73
+
74
+
75
+ def test_identify_carries_traits_and_channels():
76
+ t = FakeTransport()
77
+ w = make_client(t)
78
+ try:
79
+ w.identify("user_8842", traits={"plan": "pro"}, email="a@b.com", preferred_channel="email")
80
+ w.flush()
81
+ finally:
82
+ w.shutdown()
83
+
84
+ assert len(t.identifies) == 1
85
+ op = t.identifies[0]
86
+ assert op["external_user_id"] == "user_8842"
87
+ assert op["traits"] == {"plan": "pro"}
88
+ assert op["preferred_channel"] == "email"
89
+ assert op["channels"] == [{"type": "email", "address": "a@b.com", "opted_in": True}]
90
+
91
+
92
+ def test_track_requires_user_and_event_type():
93
+ t = FakeTransport()
94
+ w = make_client(t)
95
+ try:
96
+ w.track("", "payment_failed")
97
+ w.track("user_1", "")
98
+ w.flush()
99
+ finally:
100
+ w.shutdown()
101
+ assert t.batches == []
102
+
103
+
104
+ def test_auth_failure_emits_and_stops():
105
+ t = FakeTransport(result="auth")
106
+ errors = []
107
+ w = make_client(t, on_error=errors.append)
108
+ try:
109
+ w.track("user_1", "feature_used")
110
+ w.flush()
111
+ finally:
112
+ w.shutdown()
113
+ assert any(e.type == "auth" for e in errors)
114
+
115
+
116
+ def test_drop_on_4xx():
117
+ t = FakeTransport(result="drop")
118
+ errors = []
119
+ w = make_client(t, on_error=errors.append)
120
+ try:
121
+ w.track("user_1", "feature_used")
122
+ w.flush()
123
+ finally:
124
+ w.shutdown()
125
+ assert any(e.type == "dropped" for e in errors)
126
+
127
+
128
+ def test_retry_exhausted_is_bounded():
129
+ t = FakeTransport(result="retry")
130
+ errors = []
131
+ w = make_client(t, on_error=errors.append, max_retries=0)
132
+ try:
133
+ w.track("user_1", "feature_used")
134
+ w.flush()
135
+ finally:
136
+ w.shutdown()
137
+ assert any(e.type == "retry_exhausted" for e in errors)
138
+
139
+
140
+ def test_failed_events_are_retained_and_retried():
141
+ # A delivery that fails on auth must keep the events buffered so a later
142
+ # flush retries the SAME events — they must not be silently dropped.
143
+ t = FakeTransport(result="auth")
144
+ # High flush_interval so only our explicit flush() calls drive delivery.
145
+ w = Whisperr(api_key="wrk_test", transport=t, flush_interval=3600)
146
+ try:
147
+ w.track("user_1", "feature_used")
148
+ w.flush() # auth → event retained, not delivered
149
+ attempts_before = len(t.batches)
150
+
151
+ t.result = "ok"
152
+ w.flush() # retries the retained event, now succeeds
153
+ attempts_after = len(t.batches)
154
+ finally:
155
+ w.shutdown()
156
+
157
+ # The event was re-sent after the failure was fixed (retained), not dropped.
158
+ assert attempts_after == attempts_before + 1
159
+ assert [e["event_type"] for e in t.batches[-1]] == ["feature_used"]
160
+
161
+
162
+ def test_disabled_is_noop():
163
+ t = FakeTransport()
164
+ w = Whisperr(api_key="wrk_test", transport=t, disabled=True)
165
+ assert w.ready is False
166
+ w.track("user_1", "feature_used")
167
+ w.flush()
168
+ w.shutdown()
169
+ assert t.batches == [] and t.identifies == []
170
+
171
+
172
+ def test_missing_api_key_raises():
173
+ with pytest.raises(ValueError):
174
+ Whisperr(api_key="")
175
+
176
+
177
+ # ---- transport wire mapping ----
178
+
179
+ def _capture_urlopen(captured):
180
+ class FakeResp:
181
+ def __enter__(self):
182
+ return self
183
+
184
+ def __exit__(self, *a):
185
+ return False
186
+
187
+ def fake(req, timeout=None):
188
+ captured["url"] = req.full_url
189
+ captured["headers"] = {k.lower(): v for k, v in req.header_items()}
190
+ captured["body"] = json.loads(req.data.decode())
191
+ return FakeResp()
192
+
193
+ return fake
194
+
195
+
196
+ def test_transport_batch_wire_shape(monkeypatch):
197
+ captured = {}
198
+ monkeypatch.setattr(urllib.request, "urlopen", _capture_urlopen(captured))
199
+ t = Transport("https://api.whisperr.net/", "wrk_test", 10, lambda m: None)
200
+ result = t.send_batch(
201
+ [{"external_user_id": "u1", "event_type": "x", "occurred_at": "2026-01-01T00:00:00.000Z",
202
+ "properties": {"a": 1}, "context": {"k": "v"}, "message_id": "mid1"}]
203
+ )
204
+ assert result == "ok"
205
+ assert captured["url"] == "https://api.whisperr.net/v1/events/batch"
206
+ assert captured["headers"]["x-api-key"] == "wrk_test"
207
+ ev = captured["body"]["events"][0]
208
+ assert ev["external_user_id"] == "u1"
209
+ assert ev["context"] == {"k": "v", "$message_id": "mid1"}
210
+
211
+
212
+ def test_transport_identify_channel_mapping(monkeypatch):
213
+ captured = {}
214
+ monkeypatch.setattr(urllib.request, "urlopen", _capture_urlopen(captured))
215
+ t = Transport("https://api.whisperr.net", "wrk_test", 10, lambda m: None)
216
+ t.send_identify(
217
+ {"external_user_id": "u1", "traits": {"plan": "pro"},
218
+ "channels": [{"type": "email", "address": "a@b.com", "opted_in": True}]}
219
+ )
220
+ assert captured["url"] == "https://api.whisperr.net/v1/identify"
221
+ assert captured["body"]["channels"] == [{"channel": "email", "address": "a@b.com", "opted_in": True}]
222
+
223
+
224
+ @pytest.mark.parametrize("code,expected", [(401, "auth"), (403, "auth"), (429, "retry"), (500, "retry"), (400, "drop"), (422, "drop")])
225
+ def test_transport_status_classification(monkeypatch, code, expected):
226
+ def raiser(req, timeout=None):
227
+ raise urllib.error.HTTPError(req.full_url, code, "err", {}, None)
228
+
229
+ monkeypatch.setattr(urllib.request, "urlopen", raiser)
230
+ t = Transport("https://api.whisperr.net", "wrk_test", 10, lambda m: None)
231
+ assert t.send_batch([{"external_user_id": "u1", "event_type": "x",
232
+ "occurred_at": "2026-01-01T00:00:00.000Z", "message_id": "m"}]) == expected
@@ -0,0 +1,86 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import urllib.request
5
+
6
+ from whisperr import Whisperr
7
+
8
+ SPEC_URL = "https://raw.githubusercontent.com/WhisperrAI/whisperr-spec/main/conformance/wire.json"
9
+ RFC3339_Z = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$")
10
+
11
+
12
+ def load_spec():
13
+ local = os.environ.get("WHISPERR_SPEC_PATH")
14
+ if local:
15
+ with open(local) as f:
16
+ return json.load(f)
17
+ with urllib.request.urlopen(SPEC_URL, timeout=15) as r:
18
+ return json.loads(r.read())
19
+
20
+
21
+ def apply_case(client, case):
22
+ s = case["scenario"]
23
+ if case["op"] == "track":
24
+ client.track(s["externalUserId"], s["eventType"], s.get("properties"))
25
+ return
26
+ channels = None
27
+ if s.get("channels"):
28
+ channels = []
29
+ for ch in s["channels"]:
30
+ c = {"type": ch["type"], "address": ch["address"], "opted_in": ch.get("optedIn", True)}
31
+ if "verified" in ch:
32
+ c["verified"] = ch["verified"]
33
+ channels.append(c)
34
+ client.identify(
35
+ s["externalUserId"],
36
+ traits=s.get("traits"),
37
+ email=s.get("email"),
38
+ phone=s.get("phone"),
39
+ push_token=s.get("pushToken"),
40
+ preferred_channel=s.get("preferredChannel"),
41
+ channels=channels,
42
+ )
43
+
44
+
45
+ def test_wire_conformance(monkeypatch):
46
+ # Load the spec first, before we patch urlopen to capture SDK requests.
47
+ spec = load_spec()
48
+ assert spec["cases"], "spec has no cases"
49
+
50
+ captured = []
51
+
52
+ class FakeResp:
53
+ def __enter__(self):
54
+ return self
55
+
56
+ def __exit__(self, *a):
57
+ return False
58
+
59
+ def fake_urlopen(req, timeout=None):
60
+ captured.append({"url": req.full_url, "body": json.loads(req.data.decode())})
61
+ return FakeResp()
62
+
63
+ monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
64
+ client = Whisperr(api_key="wrk_test", flush_interval=0.05)
65
+
66
+ for case in spec["cases"]:
67
+ captured.clear()
68
+ apply_case(client, case)
69
+ client.flush()
70
+
71
+ call = next((c for c in captured if c["url"].endswith(case["endpoint"])), None)
72
+ assert call is not None, f"{case['name']}: expected POST {case['endpoint']}"
73
+
74
+ if case["op"] == "track":
75
+ event = call["body"]["events"][0]
76
+ for k, v in case.get("expectedEvent", {}).items():
77
+ assert event[k] == v, f"{case['name']}.{k}"
78
+ for key in case.get("contextMustContain", []):
79
+ assert key in event["context"], f"{case['name']} context.{key}"
80
+ if case.get("occurredAtRfc3339Z"):
81
+ assert RFC3339_Z.match(event["occurred_at"]), event["occurred_at"]
82
+ else:
83
+ for k, v in case.get("expectedBody", {}).items():
84
+ assert call["body"][k] == v, f"{case['name']}.{k}"
85
+
86
+ client.shutdown()