getfluxly 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.
getfluxly/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """GetFluxly Python SDK.
2
+
3
+ Server-side events, identify, and alias against the GetFluxly API.
4
+ Sync `Client` and `AsyncClient` share the same surface so application
5
+ code can pick whichever fits its runtime.
6
+
7
+ Defaults match the Node SDK so an event flowing through any GetFluxly
8
+ SDK shows the same batching / retry / idempotency posture.
9
+ """
10
+
11
+ from ._version import SDK_VERSION
12
+ from .async_client import AsyncClient
13
+ from .client import Client
14
+ from .errors import GFluxError
15
+ from .identity import AliasInput, IdentifyInput, TrackInput
16
+
17
+ __all__ = [
18
+ "AsyncClient",
19
+ "AliasInput",
20
+ "Client",
21
+ "GFluxError",
22
+ "IdentifyInput",
23
+ "SDK_VERSION",
24
+ "TrackInput",
25
+ ]
getfluxly/_http.py ADDED
@@ -0,0 +1,226 @@
1
+ """Thin httpx wrapper with retry, jitter, and Retry-After.
2
+
3
+ Mirrors the Node SDK's retry posture: 408 / 425 / 429 / 5xx retried
4
+ with exponential backoff and +/- 25% jitter, ``Retry-After`` honored
5
+ when present. Each request carries the SDK identity header so server
6
+ logs can split traffic by SDK family + version.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import random
13
+ import sys
14
+ import uuid
15
+ from typing import Any
16
+
17
+ import httpx
18
+
19
+ from ._version import SDK_VERSION
20
+ from .errors import GFluxError
21
+
22
+ DEFAULT_API_HOST = "https://api.getfluxly.com"
23
+ SDK_LIBRARY = f"gflux-python/{SDK_VERSION}"
24
+ RETRY_STATUSES = {408, 425, 429}
25
+
26
+
27
+ def is_browser_runtime() -> bool:
28
+ """Return True if we're running inside a browser-like Python runtime.
29
+
30
+ Used by ``Client.__init__`` to refuse the construction if a server
31
+ token is being initialized in Pyodide / Emscripten. Server keys
32
+ must never reach client bundles.
33
+ """
34
+ return sys.platform == "emscripten" or "pyodide" in sys.modules
35
+
36
+
37
+ def generate_idempotency_key() -> str:
38
+ return str(uuid.uuid4())
39
+
40
+
41
+ def _retry_after_ms(retry_after_header: str | None, attempt: int) -> int:
42
+ if retry_after_header:
43
+ try:
44
+ return int(float(retry_after_header) * 1000)
45
+ except (TypeError, ValueError):
46
+ pass
47
+ base_ms = 200 * (2**attempt)
48
+ jitter = base_ms * 0.25
49
+ return int(base_ms + random.uniform(-jitter, jitter))
50
+
51
+
52
+ def _should_retry(status: int) -> bool:
53
+ return status in RETRY_STATUSES or 500 <= status < 600
54
+
55
+
56
+ def parse_response(response: httpx.Response) -> dict[str, Any]:
57
+ text = response.text or ""
58
+ if not text:
59
+ return {}
60
+ try:
61
+ parsed = json.loads(text)
62
+ except json.JSONDecodeError as exc:
63
+ if response.is_success:
64
+ raise GFluxError(
65
+ "gflux response body was not valid JSON",
66
+ code="invalid_response",
67
+ retryable=True,
68
+ status=response.status_code,
69
+ details={"snippet": text[:200]},
70
+ ) from exc
71
+ return {"_raw": text[:200]}
72
+ return parsed if isinstance(parsed, dict) else {"_raw": parsed}
73
+
74
+
75
+ def http_error(response: httpx.Response, parsed: dict[str, Any]) -> GFluxError:
76
+ error_block = parsed.get("error")
77
+ if isinstance(error_block, dict):
78
+ code = error_block.get("code") or "server_error"
79
+ message = error_block.get("message") or response.reason_phrase
80
+ elif isinstance(error_block, str):
81
+ code = error_block
82
+ message = parsed.get("detail") or response.reason_phrase
83
+ else:
84
+ code = "server_error"
85
+ message = response.reason_phrase or "request failed"
86
+ retry_after = response.headers.get("Retry-After")
87
+ return GFluxError(
88
+ message,
89
+ code=code,
90
+ retryable=_should_retry(response.status_code),
91
+ retry_after_ms=_retry_after_ms(retry_after, 0) if retry_after else None,
92
+ status=response.status_code,
93
+ details=parsed,
94
+ )
95
+
96
+
97
+ def serialize(payload: Any) -> bytes:
98
+ return json.dumps(payload, separators=(",", ":"), default=str).encode("utf-8")
99
+
100
+
101
+ class HttpClient:
102
+ """Sync httpx wrapper. Used by ``Client``."""
103
+
104
+ def __init__(
105
+ self,
106
+ *,
107
+ token: str,
108
+ api_host: str,
109
+ timeout: float,
110
+ max_retries: int,
111
+ ) -> None:
112
+ self.token = token
113
+ self.api_host = api_host.rstrip("/")
114
+ self.timeout = timeout
115
+ self.max_retries = max_retries
116
+ self._client = httpx.Client(timeout=timeout)
117
+
118
+ def post(
119
+ self,
120
+ url: str,
121
+ body: Any,
122
+ *,
123
+ idempotency_key: str | None = None,
124
+ request_id: str | None = None,
125
+ ) -> dict[str, Any]:
126
+ headers = {
127
+ "Content-Type": "application/json",
128
+ "Authorization": f"Bearer {self.token}",
129
+ "X-GFlux-SDK": SDK_LIBRARY,
130
+ }
131
+ if idempotency_key:
132
+ headers["X-Idempotency-Key"] = idempotency_key
133
+ if request_id:
134
+ headers["X-Request-Id"] = request_id
135
+
136
+ payload = serialize(body)
137
+
138
+ last_error: Exception | None = None
139
+ for attempt in range(self.max_retries + 1):
140
+ try:
141
+ response = self._client.post(url, content=payload, headers=headers)
142
+ except httpx.HTTPError as exc:
143
+ last_error = exc
144
+ if attempt < self.max_retries:
145
+ continue
146
+ raise GFluxError(
147
+ f"network error after {attempt + 1} attempts: {exc}",
148
+ code="transport_error",
149
+ retryable=True,
150
+ ) from exc
151
+
152
+ parsed = parse_response(response)
153
+ if response.is_success:
154
+ return parsed
155
+ if _should_retry(response.status_code) and attempt < self.max_retries:
156
+ continue
157
+ raise http_error(response, parsed)
158
+
159
+ # Unreachable; the loop above either returns or raises.
160
+ assert last_error is not None
161
+ raise GFluxError("postJson exited without a result", code="internal_error")
162
+
163
+ def close(self) -> None:
164
+ self._client.close()
165
+
166
+
167
+ class AsyncHttpClient:
168
+ """Async httpx wrapper. Used by ``AsyncClient``."""
169
+
170
+ def __init__(
171
+ self,
172
+ *,
173
+ token: str,
174
+ api_host: str,
175
+ timeout: float,
176
+ max_retries: int,
177
+ ) -> None:
178
+ self.token = token
179
+ self.api_host = api_host.rstrip("/")
180
+ self.timeout = timeout
181
+ self.max_retries = max_retries
182
+ self._client = httpx.AsyncClient(timeout=timeout)
183
+
184
+ async def post(
185
+ self,
186
+ url: str,
187
+ body: Any,
188
+ *,
189
+ idempotency_key: str | None = None,
190
+ request_id: str | None = None,
191
+ ) -> dict[str, Any]:
192
+ headers = {
193
+ "Content-Type": "application/json",
194
+ "Authorization": f"Bearer {self.token}",
195
+ "X-GFlux-SDK": SDK_LIBRARY,
196
+ }
197
+ if idempotency_key:
198
+ headers["X-Idempotency-Key"] = idempotency_key
199
+ if request_id:
200
+ headers["X-Request-Id"] = request_id
201
+
202
+ payload = serialize(body)
203
+
204
+ for attempt in range(self.max_retries + 1):
205
+ try:
206
+ response = await self._client.post(url, content=payload, headers=headers)
207
+ except httpx.HTTPError as exc:
208
+ if attempt < self.max_retries:
209
+ continue
210
+ raise GFluxError(
211
+ f"network error after {attempt + 1} attempts: {exc}",
212
+ code="transport_error",
213
+ retryable=True,
214
+ ) from exc
215
+
216
+ parsed = parse_response(response)
217
+ if response.is_success:
218
+ return parsed
219
+ if _should_retry(response.status_code) and attempt < self.max_retries:
220
+ continue
221
+ raise http_error(response, parsed)
222
+
223
+ raise GFluxError("postJson exited without a result", code="internal_error")
224
+
225
+ async def aclose(self) -> None:
226
+ await self._client.aclose()
getfluxly/_version.py ADDED
@@ -0,0 +1,5 @@
1
+ """Auto-generated by scripts/sync_version.py. Do not edit by hand,
2
+ run ``python scripts/sync_version.py`` to regenerate from pyproject.toml.
3
+ """
4
+
5
+ SDK_VERSION = "0.1.0"
@@ -0,0 +1,217 @@
1
+ """Async ``AsyncClient`` for asyncio runtimes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ from ._http import (
9
+ DEFAULT_API_HOST,
10
+ AsyncHttpClient,
11
+ generate_idempotency_key,
12
+ is_browser_runtime,
13
+ )
14
+ from .batch import (
15
+ EventEnvelope,
16
+ EventQueue,
17
+ FlushResult,
18
+ combine,
19
+ empty_flush_result,
20
+ )
21
+ from .errors import GFluxError
22
+ from .identity import (
23
+ AliasInput,
24
+ require_alias_source,
25
+ require_one_id,
26
+ )
27
+
28
+
29
+ class AsyncClient:
30
+ """Asynchronous GetFluxly client.
31
+
32
+ Use as an ``async with`` context manager — the manager calls
33
+ ``aclose()`` on exit which flushes any buffered events.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ token: str,
40
+ api_host: str = DEFAULT_API_HOST,
41
+ flush_at: int = 20,
42
+ flush_interval: float = 5.0,
43
+ max_retries: int = 2,
44
+ timeout: float = 5.0,
45
+ max_queue_size: int = 1000,
46
+ ) -> None:
47
+ if not token:
48
+ raise GFluxError("token is required", code="validation_error")
49
+ if token.startswith("gflux_secret_") and is_browser_runtime():
50
+ raise GFluxError(
51
+ "server token detected in a browser-like Python runtime; "
52
+ "use a publishable key or move this code server-side",
53
+ code="server_key_in_browser",
54
+ )
55
+
56
+ self._token = token
57
+ self._api_host = api_host.rstrip("/")
58
+ self._flush_at = flush_at
59
+ self._flush_interval = flush_interval
60
+
61
+ self._queue = EventQueue(max_size=max_queue_size)
62
+ self._http = AsyncHttpClient(
63
+ token=token,
64
+ api_host=self._api_host,
65
+ timeout=timeout,
66
+ max_retries=max_retries,
67
+ )
68
+ self._lock = asyncio.Lock()
69
+ self._closed = False
70
+
71
+ # ------------------------------------------------------------------ public
72
+
73
+ async def __aenter__(self) -> AsyncClient:
74
+ return self
75
+
76
+ async def __aexit__(self, *_: object) -> None:
77
+ await self.aclose()
78
+
79
+ async def track(
80
+ self,
81
+ event: str,
82
+ *,
83
+ anonymous_id: str | None = None,
84
+ external_id: str | None = None,
85
+ user_id: str | None = None,
86
+ properties: dict[str, Any] | None = None,
87
+ timestamp: str | None = None,
88
+ context: dict[str, Any] | None = None,
89
+ ) -> FlushResult | None:
90
+ require_one_id(
91
+ anonymous_id=anonymous_id,
92
+ external_id=external_id,
93
+ user_id=user_id,
94
+ )
95
+ payload: dict[str, Any] = {"event": event}
96
+ if anonymous_id:
97
+ payload["anonymous_id"] = anonymous_id
98
+ if external_id:
99
+ payload["external_id"] = external_id
100
+ if user_id:
101
+ payload["user_id"] = user_id
102
+ if properties is not None:
103
+ payload["properties"] = properties
104
+ if timestamp is not None:
105
+ payload["timestamp"] = timestamp
106
+ if context is not None:
107
+ payload["context"] = context
108
+ return await self._enqueue(payload)
109
+
110
+ async def identify(
111
+ self,
112
+ *,
113
+ anonymous_id: str | None = None,
114
+ external_id: str | None = None,
115
+ user_id: str | None = None,
116
+ traits: dict[str, Any] | None = None,
117
+ ) -> FlushResult | None:
118
+ require_one_id(
119
+ anonymous_id=anonymous_id,
120
+ external_id=external_id,
121
+ user_id=user_id,
122
+ )
123
+ payload: dict[str, Any] = {"event": "$identify"}
124
+ if anonymous_id:
125
+ payload["anonymous_id"] = anonymous_id
126
+ if external_id:
127
+ payload["external_id"] = external_id
128
+ if user_id:
129
+ payload["user_id"] = user_id
130
+ if traits is not None:
131
+ payload["traits"] = traits
132
+ return await self._enqueue(payload)
133
+
134
+ async def alias(
135
+ self,
136
+ *,
137
+ user_id: str,
138
+ anonymous_id: str | None = None,
139
+ previous_id: str | None = None,
140
+ request_id: str | None = None,
141
+ ) -> dict[str, Any]:
142
+ require_alias_source(
143
+ AliasInput(
144
+ user_id=user_id,
145
+ anonymous_id=anonymous_id,
146
+ previous_id=previous_id,
147
+ )
148
+ )
149
+ body: dict[str, Any] = {"user_id": user_id}
150
+ if anonymous_id:
151
+ body["anonymous_id"] = anonymous_id
152
+ if previous_id:
153
+ body["previous_id"] = previous_id
154
+ response = await self._http.post(
155
+ f"{self._api_host}/v1/identify/alias",
156
+ body,
157
+ idempotency_key=generate_idempotency_key(),
158
+ request_id=request_id,
159
+ )
160
+ alias_obj = response.get("alias")
161
+ if not alias_obj:
162
+ raise GFluxError(
163
+ "alias response did not include alias data",
164
+ code="invalid_response",
165
+ retryable=True,
166
+ details=response,
167
+ )
168
+ return alias_obj if isinstance(alias_obj, dict) else {"_raw": alias_obj}
169
+
170
+ async def flush(self) -> FlushResult:
171
+ result = empty_flush_result()
172
+ while True:
173
+ async with self._lock:
174
+ batch = self._queue.drain(self._flush_at)
175
+ if not batch:
176
+ break
177
+ try:
178
+ response = await self._http.post(
179
+ f"{self._api_host}/v1/events/batch",
180
+ {"events": [e.payload for e in batch]},
181
+ idempotency_key=generate_idempotency_key(),
182
+ )
183
+ except GFluxError as exc:
184
+ if exc.retryable:
185
+ async with self._lock:
186
+ self._queue.requeue_front(batch)
187
+ raise
188
+ result = combine(
189
+ result,
190
+ FlushResult(
191
+ accepted=int(response.get("accepted", len(batch))),
192
+ rejected=int(response.get("rejected", 0)),
193
+ batches=1,
194
+ errors=list(response.get("errors", [])),
195
+ ),
196
+ )
197
+ return result
198
+
199
+ async def aclose(self) -> FlushResult:
200
+ async with self._lock:
201
+ if self._closed:
202
+ return empty_flush_result()
203
+ self._closed = True
204
+ try:
205
+ return await self.flush()
206
+ finally:
207
+ await self._http.aclose()
208
+
209
+ # ----------------------------------------------------------------- private
210
+
211
+ async def _enqueue(self, payload: dict[str, Any]) -> FlushResult | None:
212
+ async with self._lock:
213
+ self._queue.enqueue(EventEnvelope(payload=payload))
214
+ should_flush = len(self._queue) >= self._flush_at
215
+ if should_flush:
216
+ return await self.flush()
217
+ return None
getfluxly/batch.py ADDED
@@ -0,0 +1,77 @@
1
+ """Shared event-queue / flush logic. Used by both Client and AsyncClient."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from .errors import GFluxError
10
+
11
+
12
+ @dataclass
13
+ class FlushResult:
14
+ accepted: int
15
+ rejected: int
16
+ batches: int
17
+ errors: list[dict[str, Any]] = field(default_factory=list)
18
+
19
+
20
+ @dataclass
21
+ class EventEnvelope:
22
+ """A single event ready for the wire.
23
+
24
+ Stays as a plain dict-style payload so the HTTP layer can JSON-
25
+ encode without further translation.
26
+ """
27
+
28
+ payload: dict[str, Any]
29
+
30
+
31
+ class EventQueue:
32
+ """A bounded queue of events awaiting flush.
33
+
34
+ ``max_size`` is a hard cap. Enqueueing beyond it raises
35
+ ``queue_overflow`` so the caller can apply back-pressure rather
36
+ than silently dropping events.
37
+ """
38
+
39
+ def __init__(self, max_size: int) -> None:
40
+ self._dq: deque[EventEnvelope] = deque()
41
+ self._max_size = max_size
42
+
43
+ def __len__(self) -> int:
44
+ return len(self._dq)
45
+
46
+ def enqueue(self, env: EventEnvelope) -> None:
47
+ if len(self._dq) >= self._max_size:
48
+ raise GFluxError(
49
+ f"event queue is full ({self._max_size}); flush before enqueueing more",
50
+ code="queue_overflow",
51
+ retryable=False,
52
+ details={"max_size": self._max_size},
53
+ )
54
+ self._dq.append(env)
55
+
56
+ def drain(self, max_events: int) -> list[EventEnvelope]:
57
+ out: list[EventEnvelope] = []
58
+ for _ in range(min(max_events, len(self._dq))):
59
+ out.append(self._dq.popleft())
60
+ return out
61
+
62
+ def requeue_front(self, envs: list[EventEnvelope]) -> None:
63
+ for env in reversed(envs):
64
+ self._dq.appendleft(env)
65
+
66
+
67
+ def empty_flush_result() -> FlushResult:
68
+ return FlushResult(accepted=0, rejected=0, batches=0, errors=[])
69
+
70
+
71
+ def combine(a: FlushResult, b: FlushResult) -> FlushResult:
72
+ return FlushResult(
73
+ accepted=a.accepted + b.accepted,
74
+ rejected=a.rejected + b.rejected,
75
+ batches=a.batches + b.batches,
76
+ errors=a.errors + b.errors,
77
+ )
getfluxly/client.py ADDED
@@ -0,0 +1,225 @@
1
+ """Sync ``Client`` for server-side use."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import contextlib
7
+ import threading
8
+ from typing import Any
9
+
10
+ from ._http import (
11
+ DEFAULT_API_HOST,
12
+ HttpClient,
13
+ generate_idempotency_key,
14
+ is_browser_runtime,
15
+ )
16
+ from .batch import (
17
+ EventEnvelope,
18
+ EventQueue,
19
+ FlushResult,
20
+ combine,
21
+ empty_flush_result,
22
+ )
23
+ from .errors import GFluxError
24
+ from .identity import (
25
+ AliasInput,
26
+ require_alias_source,
27
+ require_one_id,
28
+ )
29
+
30
+
31
+ class Client:
32
+ """Synchronous GetFluxly client.
33
+
34
+ Construction is cheap. The HTTP client is lazy — the first ``track``
35
+ or ``identify`` call wires up the queue; ``flush`` and ``shutdown``
36
+ drive the network round-trips.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ *,
42
+ token: str,
43
+ api_host: str = DEFAULT_API_HOST,
44
+ flush_at: int = 20,
45
+ flush_interval: float = 5.0,
46
+ max_retries: int = 2,
47
+ timeout: float = 5.0,
48
+ max_queue_size: int = 1000,
49
+ ) -> None:
50
+ if not token:
51
+ raise GFluxError("token is required", code="validation_error")
52
+ if token.startswith("gflux_secret_") and is_browser_runtime():
53
+ raise GFluxError(
54
+ "server token detected in a browser-like Python runtime; "
55
+ "use a publishable key or move this code server-side",
56
+ code="server_key_in_browser",
57
+ )
58
+
59
+ self._token = token
60
+ self._api_host = api_host.rstrip("/")
61
+ self._flush_at = flush_at
62
+ self._flush_interval = flush_interval
63
+ self._max_queue_size = max_queue_size
64
+
65
+ self._queue = EventQueue(max_size=max_queue_size)
66
+ self._http = HttpClient(
67
+ token=token,
68
+ api_host=self._api_host,
69
+ timeout=timeout,
70
+ max_retries=max_retries,
71
+ )
72
+ self._lock = threading.Lock()
73
+ self._shutdown = False
74
+
75
+ atexit.register(self._safe_shutdown)
76
+
77
+ # ------------------------------------------------------------------ public
78
+
79
+ def track(
80
+ self,
81
+ event: str,
82
+ *,
83
+ anonymous_id: str | None = None,
84
+ external_id: str | None = None,
85
+ user_id: str | None = None,
86
+ properties: dict[str, Any] | None = None,
87
+ timestamp: str | None = None,
88
+ context: dict[str, Any] | None = None,
89
+ ) -> FlushResult | None:
90
+ require_one_id(
91
+ anonymous_id=anonymous_id,
92
+ external_id=external_id,
93
+ user_id=user_id,
94
+ )
95
+ payload: dict[str, Any] = {"event": event}
96
+ if anonymous_id:
97
+ payload["anonymous_id"] = anonymous_id
98
+ if external_id:
99
+ payload["external_id"] = external_id
100
+ if user_id:
101
+ payload["user_id"] = user_id
102
+ if properties is not None:
103
+ payload["properties"] = properties
104
+ if timestamp is not None:
105
+ payload["timestamp"] = timestamp
106
+ if context is not None:
107
+ payload["context"] = context
108
+ return self._enqueue(payload)
109
+
110
+ def identify(
111
+ self,
112
+ *,
113
+ anonymous_id: str | None = None,
114
+ external_id: str | None = None,
115
+ user_id: str | None = None,
116
+ traits: dict[str, Any] | None = None,
117
+ ) -> FlushResult | None:
118
+ require_one_id(
119
+ anonymous_id=anonymous_id,
120
+ external_id=external_id,
121
+ user_id=user_id,
122
+ )
123
+ payload: dict[str, Any] = {"event": "$identify"}
124
+ if anonymous_id:
125
+ payload["anonymous_id"] = anonymous_id
126
+ if external_id:
127
+ payload["external_id"] = external_id
128
+ if user_id:
129
+ payload["user_id"] = user_id
130
+ if traits is not None:
131
+ payload["traits"] = traits
132
+ return self._enqueue(payload)
133
+
134
+ def alias(
135
+ self,
136
+ *,
137
+ user_id: str,
138
+ anonymous_id: str | None = None,
139
+ previous_id: str | None = None,
140
+ request_id: str | None = None,
141
+ ) -> dict[str, Any]:
142
+ require_alias_source(
143
+ AliasInput(
144
+ user_id=user_id,
145
+ anonymous_id=anonymous_id,
146
+ previous_id=previous_id,
147
+ )
148
+ )
149
+ body: dict[str, Any] = {"user_id": user_id}
150
+ if anonymous_id:
151
+ body["anonymous_id"] = anonymous_id
152
+ if previous_id:
153
+ body["previous_id"] = previous_id
154
+ url = f"{self._api_host}/v1/identify/alias"
155
+ response = self._http.post(
156
+ url,
157
+ body,
158
+ idempotency_key=generate_idempotency_key(),
159
+ request_id=request_id,
160
+ )
161
+ alias_obj = response.get("alias")
162
+ if not alias_obj:
163
+ raise GFluxError(
164
+ "alias response did not include alias data",
165
+ code="invalid_response",
166
+ retryable=True,
167
+ details=response,
168
+ )
169
+ return alias_obj if isinstance(alias_obj, dict) else {"_raw": alias_obj}
170
+
171
+ def flush(self) -> FlushResult:
172
+ result = empty_flush_result()
173
+ while True:
174
+ with self._lock:
175
+ batch = self._queue.drain(self._flush_at)
176
+ if not batch:
177
+ break
178
+ try:
179
+ response = self._http.post(
180
+ f"{self._api_host}/v1/events/batch",
181
+ {"events": [e.payload for e in batch]},
182
+ idempotency_key=generate_idempotency_key(),
183
+ )
184
+ except GFluxError as exc:
185
+ if exc.retryable:
186
+ # Requeue so a later flush retries. Same key would
187
+ # collide; let the next flush mint a new one.
188
+ with self._lock:
189
+ self._queue.requeue_front(batch)
190
+ raise
191
+ result = combine(
192
+ result,
193
+ FlushResult(
194
+ accepted=int(response.get("accepted", len(batch))),
195
+ rejected=int(response.get("rejected", 0)),
196
+ batches=1,
197
+ errors=list(response.get("errors", [])),
198
+ ),
199
+ )
200
+ return result
201
+
202
+ def shutdown(self) -> FlushResult:
203
+ with self._lock:
204
+ if self._shutdown:
205
+ return empty_flush_result()
206
+ self._shutdown = True
207
+ try:
208
+ return self.flush()
209
+ finally:
210
+ self._http.close()
211
+
212
+ # ----------------------------------------------------------------- private
213
+
214
+ def _enqueue(self, payload: dict[str, Any]) -> FlushResult | None:
215
+ with self._lock:
216
+ self._queue.enqueue(EventEnvelope(payload=payload))
217
+ should_flush = len(self._queue) >= self._flush_at
218
+ if should_flush:
219
+ return self.flush()
220
+ return None
221
+
222
+ def _safe_shutdown(self) -> None:
223
+ # atexit: never raise on interpreter shutdown
224
+ with contextlib.suppress(Exception):
225
+ self.shutdown()
getfluxly/errors.py ADDED
@@ -0,0 +1,45 @@
1
+ """Typed error surface for the SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class GFluxError(Exception):
9
+ """Every error raised by the SDK is a ``GFluxError``.
10
+
11
+ The ``code`` attribute is one of the strings from
12
+ docs/sdk-standards/error-taxonomy.md and is stable across SDK
13
+ versions. ``retryable`` indicates whether the SDK already attempted
14
+ a retry, so callers know "wait and retry yourself" vs "fix the
15
+ payload".
16
+ """
17
+
18
+ code: str
19
+ retryable: bool
20
+ retry_after_ms: int | None
21
+ status: int | None
22
+ details: dict[str, Any]
23
+
24
+ def __init__(
25
+ self,
26
+ message: str,
27
+ *,
28
+ code: str,
29
+ retryable: bool = False,
30
+ retry_after_ms: int | None = None,
31
+ status: int | None = None,
32
+ details: dict[str, Any] | None = None,
33
+ ) -> None:
34
+ super().__init__(message)
35
+ self.code = code
36
+ self.retryable = retryable
37
+ self.retry_after_ms = retry_after_ms
38
+ self.status = status
39
+ self.details = details or {}
40
+
41
+ def __repr__(self) -> str:
42
+ return (
43
+ f"GFluxError(code={self.code!r}, retryable={self.retryable}, "
44
+ f"status={self.status}, message={super().__str__()!r})"
45
+ )
getfluxly/identity.py ADDED
@@ -0,0 +1,64 @@
1
+ """Input dataclasses for ``track``, ``identify``, and ``alias``.
2
+
3
+ Field names mirror the wire shape: ``external_id`` (snake_case),
4
+ ``anonymous_id``, etc. These are the same names the public REST API
5
+ accepts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+ from .errors import GFluxError
14
+
15
+
16
+ @dataclass
17
+ class TrackInput:
18
+ event: str
19
+ anonymous_id: str | None = None
20
+ external_id: str | None = None
21
+ user_id: str | None = None
22
+ properties: dict[str, Any] | None = None
23
+ timestamp: str | None = None
24
+ context: dict[str, Any] | None = None
25
+
26
+
27
+ @dataclass
28
+ class IdentifyInput:
29
+ anonymous_id: str | None = None
30
+ external_id: str | None = None
31
+ user_id: str | None = None
32
+ traits: dict[str, Any] = field(default_factory=dict)
33
+
34
+
35
+ @dataclass
36
+ class AliasInput:
37
+ user_id: str
38
+ anonymous_id: str | None = None
39
+ previous_id: str | None = None
40
+
41
+
42
+ def require_one_id(
43
+ *, anonymous_id: str | None, external_id: str | None, user_id: str | None
44
+ ) -> None:
45
+ """Mirror the API's identity rule: at least one identifier must be set."""
46
+
47
+ if not any([anonymous_id, external_id, user_id]):
48
+ raise GFluxError(
49
+ "track / identify requires at least one of anonymous_id, external_id, user_id",
50
+ code="validation_error",
51
+ )
52
+
53
+
54
+ def require_alias_source(input: AliasInput) -> None:
55
+ if not input.user_id:
56
+ raise GFluxError(
57
+ "alias requires user_id",
58
+ code="validation_error",
59
+ )
60
+ if not (input.anonymous_id or input.previous_id):
61
+ raise GFluxError(
62
+ "alias requires anonymous_id or previous_id",
63
+ code="validation_error",
64
+ )
getfluxly/py.typed ADDED
File without changes
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: getfluxly
3
+ Version: 0.1.0
4
+ Summary: GetFluxly Python SDK — server-side events, identify, alias, with batching, retries, and idempotency.
5
+ Project-URL: Homepage, https://getfluxly.com
6
+ Project-URL: Documentation, https://docs.getfluxly.com/sdks/python
7
+ Project-URL: Repository, https://github.com/dineshmiriyala/getfluxly
8
+ Project-URL: Issues, https://github.com/dineshmiriyala/getfluxly/issues
9
+ Author-email: GetFluxly <hello@getfluxly.com>
10
+ License: MIT License
11
+
12
+ Copyright (c) 2026 GetFluxly
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: analytics,cdp,events,getfluxly,telemetry
33
+ Classifier: Development Status :: 4 - Beta
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Operating System :: OS Independent
37
+ Classifier: Programming Language :: Python :: 3
38
+ Classifier: Programming Language :: Python :: 3.10
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
42
+ Classifier: Typing :: Typed
43
+ Requires-Python: >=3.10
44
+ Requires-Dist: httpx>=0.27
45
+ Provides-Extra: dev
46
+ Requires-Dist: build>=1.2; extra == 'dev'
47
+ Requires-Dist: mypy>=1.10; extra == 'dev'
48
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
49
+ Requires-Dist: pytest>=8.0; extra == 'dev'
50
+ Requires-Dist: respx>=0.21; extra == 'dev'
51
+ Requires-Dist: ruff>=0.6; extra == 'dev'
52
+ Requires-Dist: twine>=5.1; extra == 'dev'
53
+ Description-Content-Type: text/markdown
54
+
55
+ # getfluxly (Python)
56
+
57
+ Server-side Python SDK for [GetFluxly](https://getfluxly.com). Same surface as the Node SDK so observability across SDKs lives in one mental model.
58
+
59
+ ```bash
60
+ pip install getfluxly
61
+ ```
62
+
63
+ ## Quick start (sync)
64
+
65
+ ```python
66
+ from getfluxly import Client
67
+
68
+ client = Client(token="gflux_secret_yourtoken")
69
+
70
+ client.track("subscription_started",
71
+ external_id="user_42",
72
+ properties={"plan": "pro"})
73
+
74
+ client.identify(external_id="user_42",
75
+ traits={"email": "x@y.com", "plan": "pro"})
76
+
77
+ client.alias(user_id="user_42", anonymous_id="anon_a8f3c2")
78
+
79
+ client.flush()
80
+ client.shutdown() # also runs from atexit
81
+ ```
82
+
83
+ ## Quick start (async)
84
+
85
+ ```python
86
+ from getfluxly import AsyncClient
87
+
88
+ async with AsyncClient(token="gflux_secret_yourtoken") as ac:
89
+ await ac.track("subscription_started",
90
+ external_id="user_42",
91
+ properties={"plan": "pro"})
92
+ ```
93
+
94
+ ## Defaults
95
+
96
+ | Option | Default | Notes |
97
+ | --- | --- | --- |
98
+ | `flush_at` | 20 | Events queued before forced flush |
99
+ | `flush_interval` | 5.0 | Periodic flush cadence, seconds |
100
+ | `max_retries` | 2 | Per failed batch |
101
+ | `timeout` | 5.0 | Per HTTP request, seconds |
102
+ | `max_queue_size` | 1000 | Hard cap, raises `queue_overflow` |
103
+
104
+ Retries: 408, 425, 429, and 5xx with exponential backoff and +/- 25% jitter. `Retry-After` is honored. Each batch carries a unique `X-Idempotency-Key`.
105
+
106
+ ## Errors
107
+
108
+ ```python
109
+ from getfluxly import GFluxError
110
+
111
+ try:
112
+ client.track("invoice_paid", external_id="user_42")
113
+ except GFluxError as e:
114
+ if e.code == "queue_overflow":
115
+ ... # back-pressure
116
+ elif e.retryable:
117
+ ... # SDK already retried max_retries times
118
+ ```
119
+
120
+ Full code table at `docs.getfluxly.com/snippets/error-table`.
121
+
122
+ ## Server keys never in browser
123
+
124
+ `Client(token="gflux_secret_...")` refuses to construct if it detects a Pyodide / Emscripten runtime, so a server-side script that accidentally ships to the browser fails loudly instead of leaking the key into client traffic.
125
+
126
+ ## License
127
+
128
+ MIT, see [LICENSE](./LICENSE).
@@ -0,0 +1,13 @@
1
+ getfluxly/__init__.py,sha256=16VyakA7kxmljAoEJE5vs4A9cscnaoZDOQzzXR5LrkA,672
2
+ getfluxly/_http.py,sha256=FISKpVevkTbbDPu1HR2mSgSzR6giCXN-NC9I5T8Yi7A,7056
3
+ getfluxly/_version.py,sha256=W2Wi-UdqVaBT0xoDCIZ26-8FKOi4ZfMJnyJol9wV_wQ,168
4
+ getfluxly/async_client.py,sha256=pi78o_ogYYiBX9XLJX2BEBeCqu30432-Pk4kQhVpmJc,6759
5
+ getfluxly/batch.py,sha256=1cHZwlxZjIpRhpGEPnu4Jw1cCWkFwDrP8arGFMyyBjU,2144
6
+ getfluxly/client.py,sha256=DA5NhRCYs_A9CeY2A6tg45yCapLi8KM7GbnlAsQX3F8,6997
7
+ getfluxly/errors.py,sha256=m87zB-2fCd6SfaYQD9BSbNVZpyr_6ryaTYLYpkxaPxo,1252
8
+ getfluxly/identity.py,sha256=xLMtQETzzSwUu7FPdx8BD3p_2o22Ai7lkw8SDTPgF9I,1712
9
+ getfluxly/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ getfluxly-0.1.0.dist-info/METADATA,sha256=AHsasxRBKMjTkY7eJVeCoOTHvuy4auboSDnGy7CSlbE,4737
11
+ getfluxly-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
12
+ getfluxly-0.1.0.dist-info/licenses/LICENSE,sha256=rs1dIry-61CPe1YlW-f3Nsyj_GKBmJ_8nOP5FmPW9PQ,1066
13
+ getfluxly-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GetFluxly
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.