qurvo-python 0.1.0__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.
- qurvo/__init__.py +27 -0
- qurvo/_queue.py +263 -0
- qurvo/_transport.py +105 -0
- qurvo/_types.py +140 -0
- qurvo/client.py +262 -0
- qurvo/py.typed +0 -0
- qurvo_python-0.1.0.dist-info/METADATA +109 -0
- qurvo_python-0.1.0.dist-info/RECORD +9 -0
- qurvo_python-0.1.0.dist-info/WHEEL +4 -0
qurvo/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Qurvo analytics SDK for Python."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from qurvo._types import (
|
|
6
|
+
BatchEnvelope,
|
|
7
|
+
EventContext,
|
|
8
|
+
EventPayload,
|
|
9
|
+
NonRetryableError,
|
|
10
|
+
QuotaExceededError,
|
|
11
|
+
SdkConfig,
|
|
12
|
+
)
|
|
13
|
+
from qurvo._transport import HttpTransport
|
|
14
|
+
from qurvo._queue import EventQueue
|
|
15
|
+
from qurvo.client import Qurvo
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"BatchEnvelope",
|
|
19
|
+
"EventContext",
|
|
20
|
+
"EventPayload",
|
|
21
|
+
"EventQueue",
|
|
22
|
+
"HttpTransport",
|
|
23
|
+
"NonRetryableError",
|
|
24
|
+
"QuotaExceededError",
|
|
25
|
+
"Qurvo",
|
|
26
|
+
"SdkConfig",
|
|
27
|
+
]
|
qurvo/_queue.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Event queue with background flush thread, batching and exponential backoff.
|
|
2
|
+
|
|
3
|
+
Mirrors ``@qurvo/sdk-core/src/queue.ts``. Uses only stdlib
|
|
4
|
+
(``threading``, ``collections.deque``) -- no third-party dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import collections
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from qurvo._transport import HttpTransport
|
|
16
|
+
from qurvo._types import (
|
|
17
|
+
LogFn,
|
|
18
|
+
NonRetryableError,
|
|
19
|
+
QuotaExceededError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EventQueue:
|
|
24
|
+
"""Thread-safe event queue with automatic batched flushing.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
transport:
|
|
29
|
+
HTTP transport used to send batches.
|
|
30
|
+
endpoint:
|
|
31
|
+
Target URL (e.g. ``https://ingest.qurvo.io/v1/batch``).
|
|
32
|
+
api_key:
|
|
33
|
+
Project API key passed as ``x-api-key`` header.
|
|
34
|
+
flush_interval:
|
|
35
|
+
Seconds between automatic flushes (default 5.0).
|
|
36
|
+
flush_size:
|
|
37
|
+
Max events per batch (default 20).
|
|
38
|
+
max_queue_size:
|
|
39
|
+
When exceeded, oldest events are dropped (default 1000).
|
|
40
|
+
timeout:
|
|
41
|
+
HTTP timeout in seconds for each flush (default 30.0).
|
|
42
|
+
logger:
|
|
43
|
+
Optional logging callback.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
transport: HttpTransport,
|
|
49
|
+
endpoint: str,
|
|
50
|
+
api_key: str,
|
|
51
|
+
flush_interval: float = 5.0,
|
|
52
|
+
flush_size: int = 20,
|
|
53
|
+
max_queue_size: int = 1000,
|
|
54
|
+
timeout: float = 30.0,
|
|
55
|
+
logger: Optional[LogFn] = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._transport = transport
|
|
58
|
+
self._endpoint = endpoint
|
|
59
|
+
self._api_key = api_key
|
|
60
|
+
self._flush_interval = flush_interval
|
|
61
|
+
self._flush_size = flush_size
|
|
62
|
+
self._max_queue_size = max_queue_size
|
|
63
|
+
self._timeout = timeout
|
|
64
|
+
self._logger = logger
|
|
65
|
+
|
|
66
|
+
self._queue: collections.deque[Dict[str, Any]] = collections.deque()
|
|
67
|
+
self._lock = threading.Lock()
|
|
68
|
+
self._flushing = False
|
|
69
|
+
self._failure_count = 0
|
|
70
|
+
self._retry_after = 0.0
|
|
71
|
+
self._max_backoff = 30.0
|
|
72
|
+
|
|
73
|
+
self._stop_event = threading.Event()
|
|
74
|
+
self._flush_now_event = threading.Event()
|
|
75
|
+
self._timer_thread: Optional[threading.Thread] = None
|
|
76
|
+
self._stopped_permanently = False
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
# Public API
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def enqueue(self, payload: Dict[str, Any]) -> None:
|
|
83
|
+
"""Add an event to the queue.
|
|
84
|
+
|
|
85
|
+
If the queue is at capacity, the oldest event is dropped.
|
|
86
|
+
If the queue reaches ``flush_size``, an immediate flush is triggered.
|
|
87
|
+
"""
|
|
88
|
+
with self._lock:
|
|
89
|
+
if len(self._queue) >= self._max_queue_size:
|
|
90
|
+
self._queue.popleft()
|
|
91
|
+
if self._logger:
|
|
92
|
+
self._logger(
|
|
93
|
+
f"queue full ({self._max_queue_size}), oldest event dropped"
|
|
94
|
+
)
|
|
95
|
+
self._queue.append(payload)
|
|
96
|
+
should_flush = len(self._queue) >= self._flush_size
|
|
97
|
+
|
|
98
|
+
if should_flush:
|
|
99
|
+
self._flush_now_event.set()
|
|
100
|
+
self.flush()
|
|
101
|
+
|
|
102
|
+
def start(self) -> None:
|
|
103
|
+
"""Start the background flush timer thread."""
|
|
104
|
+
if self._timer_thread is not None and self._timer_thread.is_alive():
|
|
105
|
+
return
|
|
106
|
+
if self._stopped_permanently:
|
|
107
|
+
return
|
|
108
|
+
self._stop_event.clear()
|
|
109
|
+
self._timer_thread = threading.Thread(
|
|
110
|
+
target=self._flush_loop, daemon=True, name="qurvo-flush"
|
|
111
|
+
)
|
|
112
|
+
self._timer_thread.start()
|
|
113
|
+
|
|
114
|
+
def stop(self) -> None:
|
|
115
|
+
"""Stop the background flush timer thread (does NOT flush remaining)."""
|
|
116
|
+
self._stop_event.set()
|
|
117
|
+
self._flush_now_event.set() # wake up sleeping thread
|
|
118
|
+
if self._timer_thread is not None:
|
|
119
|
+
self._timer_thread.join(timeout=5.0)
|
|
120
|
+
self._timer_thread = None
|
|
121
|
+
|
|
122
|
+
def flush(self) -> None:
|
|
123
|
+
"""Flush up to ``flush_size`` events synchronously.
|
|
124
|
+
|
|
125
|
+
Thread-safe: only one flush can run at a time (``_flushing`` guard).
|
|
126
|
+
The lock is held only for queue mutation, never during the HTTP call.
|
|
127
|
+
"""
|
|
128
|
+
if self._flushing:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
with self._lock:
|
|
132
|
+
if len(self._queue) == 0:
|
|
133
|
+
return
|
|
134
|
+
if time.monotonic() < self._retry_after:
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
self._flushing = True
|
|
138
|
+
try:
|
|
139
|
+
self._do_flush()
|
|
140
|
+
finally:
|
|
141
|
+
self._flushing = False
|
|
142
|
+
|
|
143
|
+
def flush_all(self) -> None:
|
|
144
|
+
"""Flush the entire queue in batches.
|
|
145
|
+
|
|
146
|
+
Resets backoff state before flushing. Stops if the queue is not
|
|
147
|
+
shrinking (circuit breaker, mirrors ``queue.ts:121-124``).
|
|
148
|
+
"""
|
|
149
|
+
self._retry_after = 0.0
|
|
150
|
+
self._failure_count = 0
|
|
151
|
+
while True:
|
|
152
|
+
with self._lock:
|
|
153
|
+
size_before = len(self._queue)
|
|
154
|
+
if size_before == 0:
|
|
155
|
+
break
|
|
156
|
+
self.flush()
|
|
157
|
+
with self._lock:
|
|
158
|
+
size_after = len(self._queue)
|
|
159
|
+
if size_after >= size_before:
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
def shutdown(self, timeout: float = 30.0) -> None:
|
|
163
|
+
"""Stop the timer and flush all remaining events.
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
timeout:
|
|
168
|
+
Maximum seconds to wait for the timer thread to join.
|
|
169
|
+
"""
|
|
170
|
+
self.stop()
|
|
171
|
+
if self.size == 0:
|
|
172
|
+
return
|
|
173
|
+
self.flush_all()
|
|
174
|
+
if self._timer_thread is not None:
|
|
175
|
+
self._timer_thread.join(timeout=timeout)
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def size(self) -> int:
|
|
179
|
+
"""Number of events in the queue (does not include in-flight)."""
|
|
180
|
+
with self._lock:
|
|
181
|
+
return len(self._queue)
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
# Private helpers
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def _flush_loop(self) -> None:
|
|
188
|
+
"""Background loop: sleep for ``flush_interval``, then flush."""
|
|
189
|
+
while not self._stop_event.is_set():
|
|
190
|
+
self._flush_now_event.clear()
|
|
191
|
+
# Wait for either the interval to elapse or an explicit trigger
|
|
192
|
+
self._stop_event.wait(timeout=self._flush_interval)
|
|
193
|
+
if self._stop_event.is_set():
|
|
194
|
+
break
|
|
195
|
+
self.flush()
|
|
196
|
+
|
|
197
|
+
def _do_flush(self) -> None:
|
|
198
|
+
"""Execute a single flush cycle (extract batch, send, handle result)."""
|
|
199
|
+
with self._lock:
|
|
200
|
+
if len(self._queue) == 0:
|
|
201
|
+
return
|
|
202
|
+
batch: List[Dict[str, Any]] = []
|
|
203
|
+
for _ in range(min(self._flush_size, len(self._queue))):
|
|
204
|
+
batch.append(self._queue.popleft())
|
|
205
|
+
|
|
206
|
+
envelope = {
|
|
207
|
+
"events": batch,
|
|
208
|
+
"sent_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
|
|
209
|
+
+ "Z",
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
ok = self._transport.send(
|
|
214
|
+
self._endpoint,
|
|
215
|
+
self._api_key,
|
|
216
|
+
envelope,
|
|
217
|
+
timeout=self._timeout,
|
|
218
|
+
)
|
|
219
|
+
if ok:
|
|
220
|
+
self._failure_count = 0
|
|
221
|
+
self._retry_after = 0.0
|
|
222
|
+
else:
|
|
223
|
+
# Transport returned False — re-queue and backoff
|
|
224
|
+
with self._lock:
|
|
225
|
+
self._queue.extendleft(reversed(batch))
|
|
226
|
+
self._schedule_backoff()
|
|
227
|
+
if self._logger:
|
|
228
|
+
backoff = min(
|
|
229
|
+
1.0 * 2 ** (self._failure_count - 1), self._max_backoff
|
|
230
|
+
)
|
|
231
|
+
self._logger(
|
|
232
|
+
f"flush failed, {len(batch)} events re-queued, "
|
|
233
|
+
f"retry in {backoff:.0f}s"
|
|
234
|
+
)
|
|
235
|
+
except QuotaExceededError:
|
|
236
|
+
with self._lock:
|
|
237
|
+
self._queue.clear()
|
|
238
|
+
self._stopped_permanently = True
|
|
239
|
+
self.stop()
|
|
240
|
+
if self._logger:
|
|
241
|
+
self._logger("quota exceeded, events dropped and queue stopped")
|
|
242
|
+
except NonRetryableError as exc:
|
|
243
|
+
# Drop the batch — retrying won't help
|
|
244
|
+
if self._logger:
|
|
245
|
+
self._logger(
|
|
246
|
+
f"non-retryable error ({exc.status_code}), "
|
|
247
|
+
f"{len(batch)} events dropped"
|
|
248
|
+
)
|
|
249
|
+
except Exception:
|
|
250
|
+
# 5xx / network error — re-queue and backoff
|
|
251
|
+
with self._lock:
|
|
252
|
+
self._queue.extendleft(reversed(batch))
|
|
253
|
+
self._schedule_backoff()
|
|
254
|
+
if self._logger:
|
|
255
|
+
self._logger(
|
|
256
|
+
f"flush error, {len(batch)} events re-queued"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def _schedule_backoff(self) -> None:
|
|
260
|
+
"""Increment failure count and compute next retry-after timestamp."""
|
|
261
|
+
self._failure_count += 1
|
|
262
|
+
backoff = min(1.0 * 2 ** (self._failure_count - 1), self._max_backoff)
|
|
263
|
+
self._retry_after = time.monotonic() + backoff
|
qurvo/_transport.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""HTTP transport for the Qurvo Python SDK.
|
|
2
|
+
|
|
3
|
+
Mirrors ``@qurvo/sdk-core/src/fetch-transport.ts``. Uses only stdlib
|
|
4
|
+
(``urllib.request`` + ``gzip``) — no third-party dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import gzip
|
|
10
|
+
import json
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
from qurvo._types import NonRetryableError, QuotaExceededError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HttpTransport:
|
|
19
|
+
"""Sends event batches to the Qurvo ingest endpoint via HTTP POST.
|
|
20
|
+
|
|
21
|
+
Compresses the JSON body with gzip by default. Status-code handling:
|
|
22
|
+
|
|
23
|
+
* **202** — success, return ``True``
|
|
24
|
+
* **200 + quota_limited** — raise :class:`QuotaExceededError`
|
|
25
|
+
* **4xx** — raise :class:`NonRetryableError` (bad data, drop the batch)
|
|
26
|
+
* **5xx / network error** — raise generic ``Exception`` (retryable)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, *, compress: bool = True) -> None:
|
|
30
|
+
self._compress = compress
|
|
31
|
+
|
|
32
|
+
def send(
|
|
33
|
+
self,
|
|
34
|
+
endpoint: str,
|
|
35
|
+
api_key: str,
|
|
36
|
+
payload: Any,
|
|
37
|
+
timeout: Optional[float] = None,
|
|
38
|
+
) -> bool:
|
|
39
|
+
"""Send *payload* (a JSON-serialisable object) to *endpoint*.
|
|
40
|
+
|
|
41
|
+
Returns ``True`` on success (HTTP 202).
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
QuotaExceededError: project exceeded monthly event quota.
|
|
45
|
+
NonRetryableError: 4xx — bad request, should not be retried.
|
|
46
|
+
Exception: 5xx or network failure — caller should retry.
|
|
47
|
+
"""
|
|
48
|
+
json_bytes = json.dumps(payload).encode("utf-8")
|
|
49
|
+
|
|
50
|
+
headers = {
|
|
51
|
+
"x-api-key": api_key,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if self._compress:
|
|
55
|
+
body = gzip.compress(json_bytes)
|
|
56
|
+
headers["Content-Type"] = "text/plain"
|
|
57
|
+
headers["Content-Encoding"] = "gzip"
|
|
58
|
+
else:
|
|
59
|
+
body = json_bytes
|
|
60
|
+
headers["Content-Type"] = "application/json"
|
|
61
|
+
|
|
62
|
+
request = urllib.request.Request(
|
|
63
|
+
endpoint,
|
|
64
|
+
data=body,
|
|
65
|
+
headers=headers,
|
|
66
|
+
method="POST",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
kwargs: dict[str, Any] = {}
|
|
71
|
+
if timeout is not None:
|
|
72
|
+
kwargs["timeout"] = timeout
|
|
73
|
+
with urllib.request.urlopen(request, **kwargs) as response:
|
|
74
|
+
status = response.status
|
|
75
|
+
response_body = response.read().decode("utf-8", errors="replace")
|
|
76
|
+
except urllib.error.HTTPError as exc:
|
|
77
|
+
status = exc.code
|
|
78
|
+
response_body = exc.read().decode("utf-8", errors="replace")
|
|
79
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
80
|
+
raise Exception(f"Network error: {exc}") from exc # noqa: TRY002
|
|
81
|
+
|
|
82
|
+
return self._handle_status(status, response_body)
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _handle_status(status: int, body: str) -> bool:
|
|
86
|
+
"""Map HTTP status to return value or exception."""
|
|
87
|
+
if status == 202:
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
if status == 200:
|
|
91
|
+
# Ingest returns 200 + { quota_limited: true } when quota exceeded
|
|
92
|
+
try:
|
|
93
|
+
parsed = json.loads(body)
|
|
94
|
+
if isinstance(parsed, dict) and parsed.get("quota_limited"):
|
|
95
|
+
raise QuotaExceededError()
|
|
96
|
+
except (json.JSONDecodeError, TypeError):
|
|
97
|
+
pass
|
|
98
|
+
# 200 without quota_limited is still a success
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
if 400 <= status < 500:
|
|
102
|
+
raise NonRetryableError(status, f"HTTP {status}: {body}")
|
|
103
|
+
|
|
104
|
+
# 5xx — retryable server error
|
|
105
|
+
raise Exception(f"HTTP {status}: {body}") # noqa: TRY002
|
qurvo/_types.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Data types and exceptions for the Qurvo Python SDK.
|
|
2
|
+
|
|
3
|
+
Mirrors ``@qurvo/sdk-core/src/types.ts``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
LogFn = Callable[[str], None]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class EventContext:
|
|
17
|
+
"""Contextual metadata attached to each event.
|
|
18
|
+
|
|
19
|
+
All fields are optional — the SDK or caller populates whichever are
|
|
20
|
+
available. Mirrors the TypeScript ``EventContext`` interface.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
session_id: Optional[str] = None
|
|
24
|
+
url: Optional[str] = None
|
|
25
|
+
referrer: Optional[str] = None
|
|
26
|
+
page_title: Optional[str] = None
|
|
27
|
+
page_path: Optional[str] = None
|
|
28
|
+
device_type: Optional[str] = None
|
|
29
|
+
browser: Optional[str] = None
|
|
30
|
+
browser_version: Optional[str] = None
|
|
31
|
+
os: Optional[str] = None
|
|
32
|
+
os_version: Optional[str] = None
|
|
33
|
+
screen_width: Optional[int] = None
|
|
34
|
+
screen_height: Optional[int] = None
|
|
35
|
+
language: Optional[str] = None
|
|
36
|
+
timezone: Optional[str] = None
|
|
37
|
+
sdk_name: Optional[str] = None
|
|
38
|
+
sdk_version: Optional[str] = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class EventPayload:
|
|
43
|
+
"""A single analytics event to be sent to the ingest endpoint.
|
|
44
|
+
|
|
45
|
+
Mirrors the TypeScript ``EventPayload`` interface and the Zod
|
|
46
|
+
``TrackEventSchema`` used by ``apps/ingest``.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
event: str
|
|
50
|
+
distinct_id: str
|
|
51
|
+
anonymous_id: Optional[str] = None
|
|
52
|
+
properties: Optional[Dict[str, Any]] = None
|
|
53
|
+
user_properties: Optional[Dict[str, Any]] = None
|
|
54
|
+
context: Optional[EventContext] = None
|
|
55
|
+
timestamp: Optional[str] = None
|
|
56
|
+
event_id: Optional[str] = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class BatchEnvelope:
|
|
61
|
+
"""Wrapper sent to ``POST /v1/batch``.
|
|
62
|
+
|
|
63
|
+
Matches the Zod ``BatchWrapperSchema`` on the ingest side.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
events: List[EventPayload] = field(default_factory=list)
|
|
67
|
+
sent_at: Optional[str] = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class SdkConfig:
|
|
72
|
+
"""Configuration for the Qurvo SDK client."""
|
|
73
|
+
|
|
74
|
+
api_key: str
|
|
75
|
+
endpoint: str = "https://ingest.qurvo.io/v1/batch"
|
|
76
|
+
flush_interval: float = 5.0
|
|
77
|
+
flush_size: int = 20
|
|
78
|
+
max_queue_size: int = 1000
|
|
79
|
+
timeout: float = 10.0
|
|
80
|
+
logger: Optional[LogFn] = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Exceptions
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class QuotaExceededError(Exception):
|
|
89
|
+
"""Raised when the project has exceeded its monthly event quota.
|
|
90
|
+
|
|
91
|
+
The ingest API returns ``200 { quota_limited: true }`` in this case
|
|
92
|
+
(PostHog pattern — prevents SDK retries).
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self) -> None:
|
|
96
|
+
super().__init__("Monthly event limit exceeded")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class NonRetryableError(Exception):
|
|
100
|
+
"""Raised on 4xx responses — the batch is invalid and retrying won't help.
|
|
101
|
+
|
|
102
|
+
Carries the HTTP status code for diagnostics.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, status_code: int, message: str = "") -> None:
|
|
106
|
+
self.status_code = status_code
|
|
107
|
+
super().__init__(message or f"HTTP {status_code}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# Serialization helpers
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def payload_to_dict(payload: EventPayload) -> Dict[str, Any]:
|
|
116
|
+
"""Convert an ``EventPayload`` to a JSON-serialisable dict.
|
|
117
|
+
|
|
118
|
+
Filters out ``None`` values because the ingest Zod schemas use
|
|
119
|
+
``.optional()`` which accepts *missing* keys but not explicit ``null``.
|
|
120
|
+
"""
|
|
121
|
+
return _strip_none(asdict(payload))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def envelope_to_dict(envelope: BatchEnvelope) -> Dict[str, Any]:
|
|
125
|
+
"""Convert a ``BatchEnvelope`` to a JSON-serialisable dict."""
|
|
126
|
+
result: Dict[str, Any] = {
|
|
127
|
+
"events": [payload_to_dict(e) for e in envelope.events],
|
|
128
|
+
}
|
|
129
|
+
if envelope.sent_at is not None:
|
|
130
|
+
result["sent_at"] = envelope.sent_at
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _strip_none(d: Any) -> Any:
|
|
135
|
+
"""Recursively remove keys whose value is ``None``."""
|
|
136
|
+
if isinstance(d, dict):
|
|
137
|
+
return {k: _strip_none(v) for k, v in d.items() if v is not None}
|
|
138
|
+
if isinstance(d, list):
|
|
139
|
+
return [_strip_none(item) for item in d]
|
|
140
|
+
return d
|
qurvo/client.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Public-facing Qurvo client class.
|
|
2
|
+
|
|
3
|
+
Mirrors ``@qurvo/sdk-node/src/index.ts``. Assembles types + transport +
|
|
4
|
+
queue into a convenient high-level API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Any, Callable, Dict, Optional
|
|
12
|
+
|
|
13
|
+
from qurvo._queue import EventQueue
|
|
14
|
+
from qurvo._transport import HttpTransport
|
|
15
|
+
from qurvo._types import EventPayload, LogFn, SdkConfig, payload_to_dict
|
|
16
|
+
|
|
17
|
+
SDK_NAME = "qurvo-python"
|
|
18
|
+
|
|
19
|
+
DEFAULT_ENDPOINT = "https://ingest.qurvo.pro"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_sdk_version() -> str:
|
|
23
|
+
"""Return the installed package version, falling back to ``"0.0.0"``."""
|
|
24
|
+
try:
|
|
25
|
+
from importlib.metadata import version
|
|
26
|
+
|
|
27
|
+
return version("qurvo-python")
|
|
28
|
+
except Exception: # noqa: BLE001
|
|
29
|
+
return "0.0.0"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Qurvo:
|
|
33
|
+
"""Qurvo analytics client for Python.
|
|
34
|
+
|
|
35
|
+
Usage::
|
|
36
|
+
|
|
37
|
+
from qurvo import Qurvo
|
|
38
|
+
|
|
39
|
+
qurvo = Qurvo(api_key="qk_...")
|
|
40
|
+
qurvo.track("user-123", "purchase", {"amount": 99.99})
|
|
41
|
+
qurvo.identify("user-123", {"name": "John", "plan": "pro"})
|
|
42
|
+
qurvo.shutdown()
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
api_key:
|
|
47
|
+
Project API key (starts with ``qk_``).
|
|
48
|
+
endpoint:
|
|
49
|
+
Ingest endpoint URL (default ``https://ingest.qurvo.pro``).
|
|
50
|
+
flush_interval:
|
|
51
|
+
Seconds between automatic flushes (default 5.0).
|
|
52
|
+
flush_size:
|
|
53
|
+
Max events per batch (default 20).
|
|
54
|
+
max_queue_size:
|
|
55
|
+
When exceeded, oldest events are dropped (default 1000).
|
|
56
|
+
timeout:
|
|
57
|
+
HTTP timeout in seconds for each flush (default 30.0).
|
|
58
|
+
logger:
|
|
59
|
+
Optional logging callback ``(message: str) -> None``.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
api_key: str,
|
|
65
|
+
endpoint: str = DEFAULT_ENDPOINT,
|
|
66
|
+
flush_interval: float = 5.0,
|
|
67
|
+
flush_size: int = 20,
|
|
68
|
+
max_queue_size: int = 1000,
|
|
69
|
+
timeout: float = 30.0,
|
|
70
|
+
logger: Optional[Callable[[str], None]] = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
self._api_key = api_key
|
|
73
|
+
self._endpoint = endpoint
|
|
74
|
+
self._logger = logger
|
|
75
|
+
self._sdk_version = _get_sdk_version()
|
|
76
|
+
|
|
77
|
+
transport = HttpTransport()
|
|
78
|
+
self._queue = EventQueue(
|
|
79
|
+
transport=transport,
|
|
80
|
+
endpoint=f"{endpoint.rstrip('/')}/v1/batch",
|
|
81
|
+
api_key=api_key,
|
|
82
|
+
flush_interval=flush_interval,
|
|
83
|
+
flush_size=flush_size,
|
|
84
|
+
max_queue_size=max_queue_size,
|
|
85
|
+
timeout=timeout,
|
|
86
|
+
logger=logger,
|
|
87
|
+
)
|
|
88
|
+
self._queue.start()
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
# Public methods
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def track(
|
|
95
|
+
self,
|
|
96
|
+
distinct_id: str,
|
|
97
|
+
event: str,
|
|
98
|
+
properties: Optional[Dict[str, Any]] = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Track a custom event.
|
|
101
|
+
|
|
102
|
+
Parameters
|
|
103
|
+
----------
|
|
104
|
+
distinct_id:
|
|
105
|
+
Unique identifier for the user.
|
|
106
|
+
event:
|
|
107
|
+
Event name (e.g. ``"purchase"``, ``"page_view"``).
|
|
108
|
+
properties:
|
|
109
|
+
Optional event properties dict.
|
|
110
|
+
"""
|
|
111
|
+
payload = self._build_payload(
|
|
112
|
+
event=event,
|
|
113
|
+
distinct_id=distinct_id,
|
|
114
|
+
properties=properties,
|
|
115
|
+
)
|
|
116
|
+
self._enqueue(payload)
|
|
117
|
+
|
|
118
|
+
def identify(
|
|
119
|
+
self,
|
|
120
|
+
distinct_id: str,
|
|
121
|
+
user_properties: Dict[str, Any],
|
|
122
|
+
anonymous_id: Optional[str] = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Identify a user and set their properties.
|
|
125
|
+
|
|
126
|
+
Parameters
|
|
127
|
+
----------
|
|
128
|
+
distinct_id:
|
|
129
|
+
Unique identifier for the user.
|
|
130
|
+
user_properties:
|
|
131
|
+
Properties to set on the user profile.
|
|
132
|
+
anonymous_id:
|
|
133
|
+
Optional anonymous ID to merge with the user.
|
|
134
|
+
"""
|
|
135
|
+
payload = self._build_payload(
|
|
136
|
+
event="$identify",
|
|
137
|
+
distinct_id=distinct_id,
|
|
138
|
+
user_properties=user_properties,
|
|
139
|
+
anonymous_id=anonymous_id,
|
|
140
|
+
)
|
|
141
|
+
self._enqueue(payload)
|
|
142
|
+
|
|
143
|
+
def set(
|
|
144
|
+
self,
|
|
145
|
+
distinct_id: str,
|
|
146
|
+
properties: Dict[str, Any],
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Set user properties (overwrites existing values).
|
|
149
|
+
|
|
150
|
+
Uses the ``$set`` envelope pattern matching ``@qurvo/sdk-node``.
|
|
151
|
+
|
|
152
|
+
Parameters
|
|
153
|
+
----------
|
|
154
|
+
distinct_id:
|
|
155
|
+
Unique identifier for the user.
|
|
156
|
+
properties:
|
|
157
|
+
Properties to set on the user profile.
|
|
158
|
+
"""
|
|
159
|
+
payload = self._build_payload(
|
|
160
|
+
event="$set",
|
|
161
|
+
distinct_id=distinct_id,
|
|
162
|
+
user_properties={"$set": properties},
|
|
163
|
+
)
|
|
164
|
+
self._enqueue(payload)
|
|
165
|
+
|
|
166
|
+
def set_once(
|
|
167
|
+
self,
|
|
168
|
+
distinct_id: str,
|
|
169
|
+
properties: Dict[str, Any],
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Set user properties only if they are not already set.
|
|
172
|
+
|
|
173
|
+
Uses the ``$set_once`` envelope pattern matching ``@qurvo/sdk-node``.
|
|
174
|
+
|
|
175
|
+
Parameters
|
|
176
|
+
----------
|
|
177
|
+
distinct_id:
|
|
178
|
+
Unique identifier for the user.
|
|
179
|
+
properties:
|
|
180
|
+
Properties to set on the user profile (only if not already set).
|
|
181
|
+
"""
|
|
182
|
+
payload = self._build_payload(
|
|
183
|
+
event="$set_once",
|
|
184
|
+
distinct_id=distinct_id,
|
|
185
|
+
user_properties={"$set_once": properties},
|
|
186
|
+
)
|
|
187
|
+
self._enqueue(payload)
|
|
188
|
+
|
|
189
|
+
def screen(
|
|
190
|
+
self,
|
|
191
|
+
distinct_id: str,
|
|
192
|
+
screen_name: str,
|
|
193
|
+
properties: Optional[Dict[str, Any]] = None,
|
|
194
|
+
) -> None:
|
|
195
|
+
"""Track a screen view event.
|
|
196
|
+
|
|
197
|
+
Parameters
|
|
198
|
+
----------
|
|
199
|
+
distinct_id:
|
|
200
|
+
Unique identifier for the user.
|
|
201
|
+
screen_name:
|
|
202
|
+
Name of the screen being viewed.
|
|
203
|
+
properties:
|
|
204
|
+
Optional additional properties.
|
|
205
|
+
"""
|
|
206
|
+
merged_properties: Dict[str, Any] = {"$screen_name": screen_name}
|
|
207
|
+
if properties:
|
|
208
|
+
merged_properties.update(properties)
|
|
209
|
+
|
|
210
|
+
payload = self._build_payload(
|
|
211
|
+
event="$screen",
|
|
212
|
+
distinct_id=distinct_id,
|
|
213
|
+
properties=merged_properties,
|
|
214
|
+
)
|
|
215
|
+
self._enqueue(payload)
|
|
216
|
+
|
|
217
|
+
def shutdown(self, timeout: float = 30.0) -> None:
|
|
218
|
+
"""Gracefully flush remaining events and stop the background thread.
|
|
219
|
+
|
|
220
|
+
Parameters
|
|
221
|
+
----------
|
|
222
|
+
timeout:
|
|
223
|
+
Maximum seconds to wait for the flush thread to finish.
|
|
224
|
+
"""
|
|
225
|
+
self._queue.shutdown(timeout=timeout)
|
|
226
|
+
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
# Private helpers
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
def _build_payload(
|
|
232
|
+
self,
|
|
233
|
+
*,
|
|
234
|
+
event: str,
|
|
235
|
+
distinct_id: str,
|
|
236
|
+
properties: Optional[Dict[str, Any]] = None,
|
|
237
|
+
user_properties: Optional[Dict[str, Any]] = None,
|
|
238
|
+
anonymous_id: Optional[str] = None,
|
|
239
|
+
) -> EventPayload:
|
|
240
|
+
"""Build an ``EventPayload`` with SDK context, timestamp, and event_id."""
|
|
241
|
+
return EventPayload(
|
|
242
|
+
event=event,
|
|
243
|
+
distinct_id=str(distinct_id),
|
|
244
|
+
anonymous_id=anonymous_id,
|
|
245
|
+
properties=properties,
|
|
246
|
+
user_properties=user_properties,
|
|
247
|
+
context=None,
|
|
248
|
+
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
|
|
249
|
+
+ "Z",
|
|
250
|
+
event_id=str(uuid.uuid4()),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def _enqueue(self, payload: EventPayload) -> None:
|
|
254
|
+
"""Convert payload to dict and enqueue for batched sending."""
|
|
255
|
+
from qurvo._types import EventContext
|
|
256
|
+
|
|
257
|
+
# Attach SDK context
|
|
258
|
+
payload.context = EventContext(
|
|
259
|
+
sdk_name=SDK_NAME,
|
|
260
|
+
sdk_version=self._sdk_version,
|
|
261
|
+
)
|
|
262
|
+
self._queue.enqueue(payload_to_dict(payload))
|
qurvo/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qurvo-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Qurvo analytics SDK for Python — zero-dependency event tracking
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Classifier: Development Status :: 3 - Alpha
|
|
7
|
+
Classifier: Intended Audience :: Developers
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# qurvo-python
|
|
23
|
+
|
|
24
|
+
Python SDK for [Qurvo](https://qurvo.pro) analytics. Zero runtime dependencies -- uses only the Python standard library.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install qurvo-python
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from qurvo import Qurvo
|
|
36
|
+
|
|
37
|
+
qurvo = Qurvo(api_key="qk_...")
|
|
38
|
+
|
|
39
|
+
# Track a custom event
|
|
40
|
+
qurvo.track("user-123", "purchase", {"amount": 99.99, "currency": "USD"})
|
|
41
|
+
|
|
42
|
+
# Identify a user
|
|
43
|
+
qurvo.identify("user-123", {"name": "John", "plan": "pro"})
|
|
44
|
+
|
|
45
|
+
# Set user properties (overwrites existing)
|
|
46
|
+
qurvo.set("user-123", {"plan": "enterprise"})
|
|
47
|
+
|
|
48
|
+
# Set user properties only if not already set
|
|
49
|
+
qurvo.set_once("user-123", {"first_seen": "2026-01-01"})
|
|
50
|
+
|
|
51
|
+
# Track a screen view
|
|
52
|
+
qurvo.screen("user-123", "HomeScreen", {"tab": "overview"})
|
|
53
|
+
|
|
54
|
+
# Gracefully flush and shut down
|
|
55
|
+
qurvo.shutdown()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
qurvo = Qurvo(
|
|
62
|
+
api_key="qk_...", # Required
|
|
63
|
+
endpoint="https://ingest.qurvo.pro", # Default
|
|
64
|
+
flush_interval=5.0, # Seconds between flushes
|
|
65
|
+
flush_size=20, # Max events per batch
|
|
66
|
+
max_queue_size=1000, # Max queued events
|
|
67
|
+
timeout=30.0, # HTTP timeout in seconds
|
|
68
|
+
logger=lambda msg: print(f"[qurvo] {msg}"), # Optional debug logger
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## API Reference
|
|
73
|
+
|
|
74
|
+
### `Qurvo(api_key, **kwargs)`
|
|
75
|
+
|
|
76
|
+
Create a new client instance. Starts a background thread that periodically flushes queued events to the ingest endpoint.
|
|
77
|
+
|
|
78
|
+
### `.track(distinct_id, event, properties=None)`
|
|
79
|
+
|
|
80
|
+
Track a custom event.
|
|
81
|
+
|
|
82
|
+
### `.identify(distinct_id, user_properties, anonymous_id=None)`
|
|
83
|
+
|
|
84
|
+
Identify a user and set their properties. Optionally merge an anonymous ID.
|
|
85
|
+
|
|
86
|
+
### `.set(distinct_id, properties)`
|
|
87
|
+
|
|
88
|
+
Set user properties (overwrites existing values). Uses the `$set` envelope pattern.
|
|
89
|
+
|
|
90
|
+
### `.set_once(distinct_id, properties)`
|
|
91
|
+
|
|
92
|
+
Set user properties only if they are not already set. Uses the `$set_once` envelope pattern.
|
|
93
|
+
|
|
94
|
+
### `.screen(distinct_id, screen_name, properties=None)`
|
|
95
|
+
|
|
96
|
+
Track a screen view event. The `screen_name` is added as `$screen_name` in properties.
|
|
97
|
+
|
|
98
|
+
### `.shutdown(timeout=30.0)`
|
|
99
|
+
|
|
100
|
+
Gracefully flush all remaining events and stop the background thread. Call this before your application exits.
|
|
101
|
+
|
|
102
|
+
## Requirements
|
|
103
|
+
|
|
104
|
+
- Python >= 3.9
|
|
105
|
+
- No runtime dependencies
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
qurvo/__init__.py,sha256=GPhglBhdX5J-R2IKEPhTRTUk1Csao1g4W2k5tHF5Lpo,527
|
|
2
|
+
qurvo/_queue.py,sha256=43fPDrdg9gMiCW3sVOX_75ibTlqcnI5oZOlglegj_Bs,8912
|
|
3
|
+
qurvo/_transport.py,sha256=q_qDmNRXVHBJ4vNoq51pkA-uu45R0rHlO2TtiHidH5I,3498
|
|
4
|
+
qurvo/_types.py,sha256=VfbdKlT4hEu5dJHxVGoNH4C_KuC5gUXLELqRVNWbFsk,4117
|
|
5
|
+
qurvo/client.py,sha256=2G95X6w6XqUZynHpumrvj1bIZ052e1Dq32DnwbIzM-o,7664
|
|
6
|
+
qurvo/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
qurvo_python-0.1.0.dist-info/METADATA,sha256=P02dboDRABiUk6coVHlvTqPlt9dKu4IbeZ6roHbq5Rc,3117
|
|
8
|
+
qurvo_python-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
9
|
+
qurvo_python-0.1.0.dist-info/RECORD,,
|