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