qurvo-python 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,31 @@
1
+ node_modules/
2
+ dist/
3
+ dist-server/
4
+ .turbo/
5
+ *.tsbuildinfo
6
+ .env
7
+ .env.local
8
+ .DS_Store
9
+ .playwright-mcp/
10
+ *.png
11
+
12
+ # Kubernetes credentials
13
+ *-config.yaml
14
+ kubeconfig*
15
+ config.yaml
16
+ .idea
17
+ values.local-secrets.yaml
18
+
19
+ .last-deploy
20
+ .claude/worktrees/*
21
+ .claude/state/execution-state.json
22
+ .claude/state/operations.log
23
+ .claude/state/worktree-base-branch
24
+ .claude/results/
25
+ .claude/issues/
26
+ .mcp.json
27
+
28
+ apps/web/storybook-static/
29
+
30
+ # Temporary Storybook stories (not committed)
31
+ apps/web/src/**/*.tmp.stories.tsx
@@ -0,0 +1,63 @@
1
+ # CLAUDE.md -- qurvo-python
2
+
3
+ Python SDK for Qurvo analytics. Mirrors the architecture of the TypeScript SDK (`@qurvo/sdk-core` + `@qurvo/sdk-node`).
4
+
5
+ ## Structure
6
+
7
+ ```
8
+ packages/qurvo-python/
9
+ ├── pyproject.toml # hatchling build, zero runtime deps, python>=3.9
10
+ ├── CLAUDE.md
11
+ ├── README.md # User-facing docs with usage examples
12
+ ├── src/
13
+ │ └── qurvo/
14
+ │ ├── __init__.py # Public API re-exports
15
+ │ ├── _types.py # Dataclasses (EventPayload, EventContext, etc.) + exceptions
16
+ │ ├── _transport.py # HttpTransport -- urllib + gzip, status code mapping
17
+ │ ├── _queue.py # EventQueue -- threaded batching, backoff, flush loop
18
+ │ └── client.py # Qurvo class -- public API, mirrors sdk-node/src/index.ts
19
+ └── tests/
20
+ ├── conftest.py
21
+ ├── test_types.py
22
+ ├── test_transport.py
23
+ ├── test_queue.py
24
+ └── test_client.py
25
+ ```
26
+
27
+ ## Key Design Decisions
28
+
29
+ - **Zero runtime dependencies** -- only stdlib (`urllib.request`, `gzip`, `json`, `dataclasses`, `threading`, `uuid`)
30
+ - **`from __future__ import annotations`** everywhere for Python 3.9 compat with modern type hints
31
+ - **Dataclasses with `asdict()` + None-filtering** for JSON serialization -- ingest expects absent fields, not null
32
+ - **Private modules** (`_types.py`, `_transport.py`, `_queue.py`) -- public API via `__init__.py` re-exports
33
+ - **`Qurvo` client class** (`client.py`) is the primary public interface -- mirrors `@qurvo/sdk-node`
34
+ - **Status code mapping** mirrors TypeScript `FetchTransport`:
35
+ - 202 -> success
36
+ - 200 + `quota_limited` body -> `QuotaExceededError`
37
+ - 4xx -> `NonRetryableError` (SDK should drop, not retry)
38
+ - 5xx / network error -> generic exception (retryable)
39
+ - **`$set` / `$set_once` envelope pattern** -- `user_properties: {"$set": {...}}` matches sdk-node behavior
40
+ - **`sdk_name` / `sdk_version`** automatically added to `context` on every event
41
+ - **`event_id`** via `uuid4()` for deduplication on retry
42
+
43
+ ## Commands
44
+
45
+ ```bash
46
+ # Install in dev mode
47
+ cd packages/qurvo-python && pip install -e ".[dev]"
48
+
49
+ # Run tests
50
+ cd packages/qurvo-python && pytest -v
51
+
52
+ # Run tests with coverage
53
+ cd packages/qurvo-python && pytest --cov=qurvo --cov-report=term-missing
54
+
55
+ # Publish to PyPI
56
+ cd packages/qurvo-python && python -m build && twine upload dist/*
57
+ ```
58
+
59
+ ## Related Packages
60
+
61
+ - `@qurvo/sdk-core` -- TypeScript equivalent (types.ts, fetch-transport.ts, queue.ts)
62
+ - `@qurvo/sdk-node` -- TypeScript Node.js client (index.ts -- direct API mirror)
63
+ - `apps/ingest` -- Event ingestion endpoint (POST /v1/batch)
@@ -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,88 @@
1
+ # qurvo-python
2
+
3
+ Python SDK for [Qurvo](https://qurvo.pro) analytics. Zero runtime dependencies -- uses only the Python standard library.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install qurvo-python
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ from qurvo import Qurvo
15
+
16
+ qurvo = Qurvo(api_key="qk_...")
17
+
18
+ # Track a custom event
19
+ qurvo.track("user-123", "purchase", {"amount": 99.99, "currency": "USD"})
20
+
21
+ # Identify a user
22
+ qurvo.identify("user-123", {"name": "John", "plan": "pro"})
23
+
24
+ # Set user properties (overwrites existing)
25
+ qurvo.set("user-123", {"plan": "enterprise"})
26
+
27
+ # Set user properties only if not already set
28
+ qurvo.set_once("user-123", {"first_seen": "2026-01-01"})
29
+
30
+ # Track a screen view
31
+ qurvo.screen("user-123", "HomeScreen", {"tab": "overview"})
32
+
33
+ # Gracefully flush and shut down
34
+ qurvo.shutdown()
35
+ ```
36
+
37
+ ## Configuration
38
+
39
+ ```python
40
+ qurvo = Qurvo(
41
+ api_key="qk_...", # Required
42
+ endpoint="https://ingest.qurvo.pro", # Default
43
+ flush_interval=5.0, # Seconds between flushes
44
+ flush_size=20, # Max events per batch
45
+ max_queue_size=1000, # Max queued events
46
+ timeout=30.0, # HTTP timeout in seconds
47
+ logger=lambda msg: print(f"[qurvo] {msg}"), # Optional debug logger
48
+ )
49
+ ```
50
+
51
+ ## API Reference
52
+
53
+ ### `Qurvo(api_key, **kwargs)`
54
+
55
+ Create a new client instance. Starts a background thread that periodically flushes queued events to the ingest endpoint.
56
+
57
+ ### `.track(distinct_id, event, properties=None)`
58
+
59
+ Track a custom event.
60
+
61
+ ### `.identify(distinct_id, user_properties, anonymous_id=None)`
62
+
63
+ Identify a user and set their properties. Optionally merge an anonymous ID.
64
+
65
+ ### `.set(distinct_id, properties)`
66
+
67
+ Set user properties (overwrites existing values). Uses the `$set` envelope pattern.
68
+
69
+ ### `.set_once(distinct_id, properties)`
70
+
71
+ Set user properties only if they are not already set. Uses the `$set_once` envelope pattern.
72
+
73
+ ### `.screen(distinct_id, screen_name, properties=None)`
74
+
75
+ Track a screen view event. The `screen_name` is added as `$screen_name` in properties.
76
+
77
+ ### `.shutdown(timeout=30.0)`
78
+
79
+ Gracefully flush all remaining events and stop the background thread. Call this before your application exits.
80
+
81
+ ## Requirements
82
+
83
+ - Python >= 3.9
84
+ - No runtime dependencies
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "qurvo-python"
3
+ version = "0.1.0"
4
+ description = "Qurvo analytics SDK for Python — zero-dependency event tracking"
5
+ requires-python = ">=3.9"
6
+ dependencies = []
7
+ license = "MIT"
8
+ readme = "README.md"
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Intended Audience :: Developers",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.9",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Typing :: Typed",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ dev = ["pytest>=8", "pytest-cov"]
24
+
25
+ [build-system]
26
+ requires = ["hatchling"]
27
+ build-backend = "hatchling.build"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/qurvo"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
@@ -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
+ ]
@@ -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