azfive-capture 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,69 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .venv/
6
+ venv/
7
+ *.egg
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .mypy_cache/
11
+ .coverage
12
+ htmlcov/
13
+
14
+ # Node
15
+ node_modules/
16
+ dist/
17
+
18
+ # Environment
19
+ .env
20
+ .env.local
21
+ .env.*.local
22
+
23
+ # IDE
24
+ .vscode/
25
+ .idea/
26
+ *.swp
27
+ *.swo
28
+
29
+ # OS
30
+ .DS_Store
31
+ Thumbs.db
32
+
33
+ # Data
34
+ *.parquet
35
+ *.csv
36
+ *.db
37
+
38
+ # Rust
39
+ **/target/
40
+
41
+ # AI coding-tool state (local per-developer config/session data)
42
+ .claude/
43
+ .agents/
44
+ .codex/
45
+
46
+ # Impeccable (cache/session data — design.json is derived from DESIGN.md)
47
+ .impeccable/
48
+ frontend/src/.impeccable/
49
+
50
+ # Playwright
51
+ playwright-report/
52
+ test-results/
53
+
54
+ # DB backups
55
+ azfive-backup-*.sql
56
+
57
+ # Built frontend (produced by the Docker build's `npm run build`). Never commit
58
+ # it: stale committed chunks get served alongside fresh ones and can pin an old
59
+ # bundle that lacks the X-AzFive-Branch header, silently routing branch writes to main.
60
+ backend/static/
61
+
62
+ # SDK build outputs (sdk-ios SwiftPM, Flutter, Android/Gradle)
63
+ sdk-ios/.build/
64
+ sdk-flutter/build/
65
+ sdk-flutter/.dart_tool/
66
+ sdk-android/.gradle/
67
+ sdk-android/**/build/
68
+ sdk-jvm/.gradle/
69
+ sdk-jvm/build/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AzFive
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.5
2
+ Name: azfive-capture
3
+ Version: 0.1.0
4
+ Summary: Capture product-analytics events into AzFive from Python servers — events, identity, feature flags & experiments.
5
+ Author: AzFive
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Python: >=3.10
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # azfive-capture (Python)
20
+
21
+ AzFive product analytics for Python servers — events, identity, super
22
+ properties, and feature flags/experiments. Zero dependencies (stdlib only).
23
+ See `sdk-spec/SPEC.md` in the AzFive repo for the cross-SDK contract.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install azfive-capture
29
+ ```
30
+
31
+ Use a **secret** API key with the `events:write` scope (AzFive → Settings → API
32
+ Keys). Secret keys are also valid on `/v1/decide`, so server-side flag
33
+ evaluation needs no extra setup.
34
+
35
+ ## Quickstart
36
+
37
+ ```python
38
+ from azfive_capture import Client
39
+
40
+ azfive = Client("azfive_…", host="https://analytics.example.com", project="backend")
41
+
42
+ azfive.capture("invoice_paid", {"amount": 99}, distinct_id="user-42")
43
+ azfive.people.set({"plan": "pro"}, distinct_id="user-42")
44
+
45
+ # experiments / feature flags — get_flag() reports the exposure automatically,
46
+ # deduped per (distinct_id, flag, value)
47
+ variant = azfive.get_flag("user-42", "exp-checkout") # 'control' | 'test' | False | None
48
+ if azfive.is_flag_enabled("user-42", "new-onboarding"):
49
+ ...
50
+
51
+ azfive.flush() # also flushed by the background thread and at exit
52
+ ```
53
+
54
+ Batching: events queue in memory (batch 10 / 5 s / max 1000, oldest dropped)
55
+ and are delivered by a daemon thread; failed batches retry on the next tick.
56
+ `close()` (also registered via `atexit`) drains the queue.
57
+
58
+ ## Tests
59
+
60
+ ```bash
61
+ pip install -e '.[dev]' && pytest # golden vectors from ../sdk-spec/vectors
62
+ ```
@@ -0,0 +1,44 @@
1
+ # azfive-capture (Python)
2
+
3
+ AzFive product analytics for Python servers — events, identity, super
4
+ properties, and feature flags/experiments. Zero dependencies (stdlib only).
5
+ See `sdk-spec/SPEC.md` in the AzFive repo for the cross-SDK contract.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install azfive-capture
11
+ ```
12
+
13
+ Use a **secret** API key with the `events:write` scope (AzFive → Settings → API
14
+ Keys). Secret keys are also valid on `/v1/decide`, so server-side flag
15
+ evaluation needs no extra setup.
16
+
17
+ ## Quickstart
18
+
19
+ ```python
20
+ from azfive_capture import Client
21
+
22
+ azfive = Client("azfive_…", host="https://analytics.example.com", project="backend")
23
+
24
+ azfive.capture("invoice_paid", {"amount": 99}, distinct_id="user-42")
25
+ azfive.people.set({"plan": "pro"}, distinct_id="user-42")
26
+
27
+ # experiments / feature flags — get_flag() reports the exposure automatically,
28
+ # deduped per (distinct_id, flag, value)
29
+ variant = azfive.get_flag("user-42", "exp-checkout") # 'control' | 'test' | False | None
30
+ if azfive.is_flag_enabled("user-42", "new-onboarding"):
31
+ ...
32
+
33
+ azfive.flush() # also flushed by the background thread and at exit
34
+ ```
35
+
36
+ Batching: events queue in memory (batch 10 / 5 s / max 1000, oldest dropped)
37
+ and are delivered by a daemon thread; failed batches retry on the next tick.
38
+ `close()` (also registered via `atexit`) drains the queue.
39
+
40
+ ## Tests
41
+
42
+ ```bash
43
+ pip install -e '.[dev]' && pytest # golden vectors from ../sdk-spec/vectors
44
+ ```
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "azfive-capture"
7
+ version = "0.1.0"
8
+ description = "Capture product-analytics events into AzFive from Python servers — events, identity, feature flags & experiments."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "AzFive" }]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["pytest>=8"]
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/azfive_capture"]
28
+
29
+ [tool.pytest.ini_options]
30
+ testpaths = ["tests"]
@@ -0,0 +1,11 @@
1
+ """azfive-capture — AzFive product analytics for Python servers.
2
+
3
+ Events, identity, super properties and feature flags/experiments against a
4
+ AzFive deployment. See ``sdk-spec/SPEC.md`` in the AzFive repo for the
5
+ cross-SDK behavioral contract.
6
+ """
7
+
8
+ from .client import LIB_NAME, LIB_VERSION, Client, create_client
9
+ from .flags import FlagValue
10
+
11
+ __all__ = ["Client", "create_client", "FlagValue", "LIB_NAME", "LIB_VERSION"]
@@ -0,0 +1,324 @@
1
+ """AzFive capture client for Python servers.
2
+
3
+ Port of ``@azfive/capture`` (see ``sdk-spec/SPEC.md``): same event envelope,
4
+ merge order, batching semantics and flag exposure rules, adapted to a server
5
+ runtime — in-memory identity, no session, background flush thread, and flag
6
+ evaluation per ``distinct_id`` through ``/v1/decide`` with a secret key.
7
+
8
+ from azfive_capture import Client
9
+
10
+ azfive = Client("azfive_…", host="https://analytics.example.com")
11
+ azfive.capture("invoice_paid", {"amount": 99}, distinct_id="user-42")
12
+ variant = azfive.get_flag("user-42", "exp-checkout")
13
+ azfive.flush()
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import atexit
19
+ import platform
20
+ import threading
21
+ from datetime import datetime, timezone
22
+ from typing import Any
23
+
24
+ from . import transport
25
+ from .flags import FlagsManager, FlagValue
26
+
27
+ LIB_VERSION = "0.1.0"
28
+ LIB_NAME = "azfive-python"
29
+
30
+ DEFAULT_BATCH_SIZE = 10
31
+ DEFAULT_FLUSH_INTERVAL_S = 5.0
32
+ DEFAULT_MAX_QUEUE_SIZE = 1000
33
+
34
+
35
+ def _now_iso() -> str:
36
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
37
+
38
+
39
+ def _static_props() -> dict:
40
+ return {
41
+ "$os": platform.system() or "Unknown",
42
+ "$os_version": platform.release() or "",
43
+ "$device_type": "Server",
44
+ }
45
+
46
+
47
+ def merge_properties(
48
+ static: dict, session: dict, super_props: dict, event: dict, reserved: dict
49
+ ) -> dict:
50
+ """The spec'd merge order: static < session < super < event < reserved.
51
+ (Server SDKs have no dynamic layer; session is empty for them.)"""
52
+ return {**static, **session, **super_props, **event, **reserved}
53
+
54
+
55
+ class People:
56
+ """``people.set`` / ``people.set_once`` — person-property events."""
57
+
58
+ def __init__(self, client: "Client") -> None:
59
+ self._client = client
60
+
61
+ def set(self, properties: dict, distinct_id: str | None = None) -> None:
62
+ self._client._track("az.set", {}, set_props=properties, distinct_id=distinct_id)
63
+
64
+ def set_once(self, properties: dict, distinct_id: str | None = None) -> None:
65
+ self._client._track("az.set_once", {}, set_once_props=properties, distinct_id=distinct_id)
66
+
67
+
68
+ class Client:
69
+ def __init__(
70
+ self,
71
+ api_key: str,
72
+ *,
73
+ host: str,
74
+ project: str = "default",
75
+ batch_size: int = DEFAULT_BATCH_SIZE,
76
+ flush_interval_s: float = DEFAULT_FLUSH_INTERVAL_S,
77
+ max_queue_size: int = DEFAULT_MAX_QUEUE_SIZE,
78
+ gzip: bool = False,
79
+ opt_out: bool = False,
80
+ disable_decide: bool = False,
81
+ extra_static_props: dict | None = None,
82
+ start_flush_thread: bool = True,
83
+ ) -> None:
84
+ if not api_key:
85
+ raise ValueError("api_key is required")
86
+ if not host:
87
+ raise ValueError("host is required")
88
+ self._api_key = api_key
89
+ self._host = host.rstrip("/")
90
+ self._project = project
91
+ self._batch_size = batch_size
92
+ self._flush_interval_s = flush_interval_s
93
+ self._max_queue_size = max_queue_size
94
+ self._gzip = gzip
95
+ self._opted_out = opt_out
96
+
97
+ self._distinct_id = transport.uuid4()
98
+ self._device_id = transport.uuid4()
99
+ self._identified = False
100
+ self._super_props: dict = {}
101
+ self._static = {**_static_props(), **(extra_static_props or {})}
102
+
103
+ self._queue: list[dict] = []
104
+ self._lock = threading.Lock()
105
+ self._closed = False
106
+
107
+ self.people = People(self)
108
+ self._flags: FlagsManager | None = None
109
+ if not disable_decide:
110
+ self._flags = FlagsManager(
111
+ host=self._host,
112
+ api_key=api_key,
113
+ project=project,
114
+ capture=self._capture_for_flags,
115
+ )
116
+
117
+ self._flush_event = threading.Event()
118
+ self._thread: threading.Thread | None = None
119
+ if start_flush_thread:
120
+ self._thread = threading.Thread(
121
+ target=self._flush_loop, name="azfive-capture-flush", daemon=True
122
+ )
123
+ self._thread.start()
124
+ atexit.register(self.close)
125
+
126
+ # ── Identity ──────────────────────────────────────────────────────────────
127
+
128
+ def get_distinct_id(self) -> str:
129
+ return self._distinct_id
130
+
131
+ def get_session_id(self) -> str:
132
+ return "" # servers have no session
133
+
134
+ def identify(
135
+ self, distinct_id: str, set: dict | None = None, set_once: dict | None = None
136
+ ) -> None:
137
+ did = str(distinct_id)
138
+ if self._identified and self._distinct_id == did:
139
+ if set or set_once:
140
+ self._track("az.set", {}, set_props=set, set_once_props=set_once)
141
+ return
142
+ prev = self._distinct_id
143
+ self._distinct_id = did
144
+ self._identified = True
145
+ self._track(
146
+ "az.identify",
147
+ {"$anon_distinct_id": prev},
148
+ set_props=set,
149
+ set_once_props=set_once,
150
+ distinct_id=did,
151
+ )
152
+ if self._flags:
153
+ self._flags.reload_flags(did)
154
+
155
+ def alias(self, alias: str) -> None:
156
+ self._track("az.alias", {"alias": str(alias)})
157
+
158
+ def reset(self, reset_device_id: bool = False) -> None:
159
+ self._identified = False
160
+ self._distinct_id = transport.uuid4()
161
+ self._super_props = {}
162
+ if reset_device_id:
163
+ self._device_id = transport.uuid4()
164
+ # opt-out is intentionally preserved across reset()
165
+
166
+ # ── Super properties ──────────────────────────────────────────────────────
167
+
168
+ def register(self, props: dict) -> None:
169
+ self._super_props = {**self._super_props, **props}
170
+
171
+ def register_once(self, props: dict) -> None:
172
+ self._super_props = {**props, **self._super_props} # existing values win
173
+
174
+ def unregister(self, key: str) -> None:
175
+ self._super_props.pop(key, None)
176
+
177
+ # ── Opt-out ───────────────────────────────────────────────────────────────
178
+
179
+ def opt_in_capturing(self) -> None:
180
+ self._opted_out = False
181
+
182
+ def opt_out_capturing(self) -> None:
183
+ self._opted_out = True
184
+
185
+ def has_opted_out_capturing(self) -> bool:
186
+ return self._opted_out
187
+
188
+ # ── Capture ───────────────────────────────────────────────────────────────
189
+
190
+ def capture(
191
+ self, event: str, properties: dict | None = None, *, distinct_id: str | None = None
192
+ ) -> None:
193
+ self._track(event, properties or {}, distinct_id=distinct_id)
194
+
195
+ def _capture_for_flags(
196
+ self, event: str, properties: dict, *, distinct_id: str
197
+ ) -> None:
198
+ self._track(event, properties, distinct_id=distinct_id)
199
+
200
+ def _track(
201
+ self,
202
+ event: str,
203
+ properties: dict,
204
+ *,
205
+ set_props: dict | None = None,
206
+ set_once_props: dict | None = None,
207
+ distinct_id: str | None = None,
208
+ ) -> None:
209
+ if self._opted_out:
210
+ return
211
+ props = dict(properties)
212
+ did = distinct_id or self._distinct_id
213
+ if props.get("distinct_id") is not None:
214
+ did = str(props.pop("distinct_id"))
215
+ evt: dict = {
216
+ "event": event,
217
+ "distinct_id": did,
218
+ "timestamp": _now_iso(),
219
+ "uuid": transport.uuid4(),
220
+ "properties": merge_properties(
221
+ self._static,
222
+ {},
223
+ self._super_props,
224
+ props,
225
+ {"$lib": LIB_NAME, "$lib_version": LIB_VERSION, "$device_id": self._device_id},
226
+ ),
227
+ }
228
+ if set_props:
229
+ evt["$set"] = set_props
230
+ if set_once_props:
231
+ evt["$set_once"] = set_once_props
232
+ self._enqueue(evt)
233
+
234
+ def _enqueue(self, evt: dict) -> None:
235
+ with self._lock:
236
+ self._queue.append(evt)
237
+ if len(self._queue) > self._max_queue_size:
238
+ del self._queue[: len(self._queue) - self._max_queue_size]
239
+ trigger = len(self._queue) >= self._batch_size
240
+ if trigger:
241
+ self._request_flush()
242
+
243
+ def _request_flush(self) -> None:
244
+ # With the background thread running, hand off (capture() never blocks
245
+ # on network I/O); without it (tests, single-shot scripts) flush inline.
246
+ if self._thread is not None:
247
+ self._flush_event.set()
248
+ else:
249
+ self.flush()
250
+
251
+ # ── Feature flags ─────────────────────────────────────────────────────────
252
+
253
+ def get_flag(
254
+ self, distinct_id: str, key: str, person_properties: dict | None = None
255
+ ) -> FlagValue | None:
256
+ if self._flags is None:
257
+ return None
258
+ return self._flags.get_flag(distinct_id, key, person_properties)
259
+
260
+ def is_flag_enabled(
261
+ self, distinct_id: str, key: str, person_properties: dict | None = None
262
+ ) -> bool:
263
+ if self._flags is None:
264
+ return False
265
+ return self._flags.is_flag_enabled(distinct_id, key, person_properties)
266
+
267
+ def get_flag_payload(
268
+ self, distinct_id: str, key: str, person_properties: dict | None = None
269
+ ) -> Any:
270
+ if self._flags is None:
271
+ return None
272
+ return self._flags.get_flag_payload(distinct_id, key, person_properties)
273
+
274
+ def reload_flags(self, distinct_id: str) -> None:
275
+ if self._flags is not None:
276
+ self._flags.reload_flags(distinct_id)
277
+
278
+ # ── Delivery ──────────────────────────────────────────────────────────────
279
+
280
+ def _send(self, batch: list[dict]) -> bool:
281
+ return transport.send_batch(
282
+ batch,
283
+ host=self._host,
284
+ api_key=self._api_key,
285
+ project=self._project,
286
+ gzip=self._gzip,
287
+ )
288
+
289
+ def flush(self) -> None:
290
+ with self._lock:
291
+ if not self._queue:
292
+ return
293
+ batch = self._queue
294
+ self._queue = []
295
+ if not self._send(batch):
296
+ with self._lock:
297
+ # Re-queue at the FRONT, bounded keeping the newest events.
298
+ self._queue = (batch + self._queue)[-self._max_queue_size:]
299
+
300
+ def _flush_loop(self) -> None:
301
+ while not self._closed:
302
+ self._flush_event.wait(timeout=self._flush_interval_s)
303
+ self._flush_event.clear()
304
+ if self._closed:
305
+ return
306
+ try:
307
+ self.flush()
308
+ except Exception: # never kill the flush thread
309
+ pass
310
+
311
+ def close(self) -> None:
312
+ """Flush pending events and stop the background thread. Idempotent."""
313
+ if self._closed:
314
+ return
315
+ self._closed = True
316
+ self._flush_event.set()
317
+ try:
318
+ self.flush()
319
+ except Exception:
320
+ pass
321
+
322
+
323
+ def create_client(api_key: str, **config: Any) -> Client:
324
+ return Client(api_key, **config)
@@ -0,0 +1,132 @@
1
+ """Server-side feature flags: /v1/decide per distinct_id with a TTL cache and
2
+ automatic, deduped ``az.flag_called`` exposure events.
3
+
4
+ Servers have no session, so exposures dedup on ``(distinct_id, flag, value)``
5
+ in a bounded LRU — the experiment pipeline only counts a person's FIRST
6
+ exposure anyway, so the LRU merely caps event noise, not correctness.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ import time
13
+ from collections import OrderedDict
14
+ from typing import Any, Callable
15
+
16
+ from . import transport
17
+
18
+ FlagValue = bool | str
19
+
20
+ DECIDE_CACHE_TTL_S = 60.0
21
+ DECIDE_CACHE_MAX = 10_000
22
+ EXPOSURE_LRU_MAX = 50_000
23
+
24
+
25
+ class _LRU:
26
+ """Tiny thread-safe bounded LRU used for decide caching + exposure dedup."""
27
+
28
+ def __init__(self, max_entries: int) -> None:
29
+ self._data: OrderedDict[str, Any] = OrderedDict()
30
+ self._max = max_entries
31
+ self._lock = threading.Lock()
32
+
33
+ def get(self, key: str) -> Any | None:
34
+ with self._lock:
35
+ if key not in self._data:
36
+ return None
37
+ self._data.move_to_end(key)
38
+ return self._data[key]
39
+
40
+ def put(self, key: str, value: Any) -> None:
41
+ with self._lock:
42
+ self._data[key] = value
43
+ self._data.move_to_end(key)
44
+ while len(self._data) > self._max:
45
+ self._data.popitem(last=False)
46
+
47
+ def contains(self, key: str) -> bool:
48
+ return self.get(key) is not None
49
+
50
+ def remove(self, key: str) -> None:
51
+ with self._lock:
52
+ self._data.pop(key, None)
53
+
54
+
55
+ class FlagsManager:
56
+ def __init__(
57
+ self,
58
+ *,
59
+ host: str,
60
+ api_key: str,
61
+ project: str,
62
+ capture: Callable[..., None],
63
+ cache_ttl_s: float = DECIDE_CACHE_TTL_S,
64
+ now: Callable[[], float] = time.monotonic,
65
+ ) -> None:
66
+ self._host = host
67
+ self._api_key = api_key
68
+ self._project = project
69
+ self._capture = capture
70
+ self._ttl = cache_ttl_s
71
+ self._now = now
72
+ self._decide_cache = _LRU(DECIDE_CACHE_MAX)
73
+ self._exposures = _LRU(EXPOSURE_LRU_MAX)
74
+
75
+ # ── decide ────────────────────────────────────────────────────────────────
76
+
77
+ def _decide(self, distinct_id: str, person_properties: dict | None) -> dict:
78
+ # person_properties bypass the cache only when they change the request —
79
+ # cache key folds them in so overlays don't leak between calls.
80
+ cache_key = distinct_id if not person_properties else (
81
+ distinct_id + "|" + repr(sorted(person_properties.items()))
82
+ )
83
+ cached = self._decide_cache.get(cache_key)
84
+ if cached is not None and self._now() - cached["at"] < self._ttl:
85
+ return cached["doc"]
86
+ payload: dict = {"distinct_id": distinct_id, "project": self._project}
87
+ if person_properties:
88
+ payload["person_properties"] = person_properties
89
+ doc = transport.post_json(f"{self._host}/api/v1/decide", self._api_key, payload)
90
+ if doc is None:
91
+ # Network failure: serve stale if we have it, else empty. Never raise.
92
+ return cached["doc"] if cached is not None else {"flags": {}, "flagPayloads": {}}
93
+ self._decide_cache.put(cache_key, {"at": self._now(), "doc": doc})
94
+ return doc
95
+
96
+ def reload_flags(self, distinct_id: str) -> None:
97
+ """Bust one distinct_id's cached decide response."""
98
+ self._decide_cache.remove(distinct_id)
99
+
100
+ # ── getters ───────────────────────────────────────────────────────────────
101
+
102
+ def get_flag(
103
+ self, distinct_id: str, key: str, person_properties: dict | None = None
104
+ ) -> FlagValue | None:
105
+ doc = self._decide(distinct_id, person_properties)
106
+ value = (doc.get("flags") or {}).get(key)
107
+ self._report_exposure(distinct_id, key, value)
108
+ return value
109
+
110
+ def is_flag_enabled(
111
+ self, distinct_id: str, key: str, person_properties: dict | None = None
112
+ ) -> bool:
113
+ return bool(self.get_flag(distinct_id, key, person_properties))
114
+
115
+ def get_flag_payload(
116
+ self, distinct_id: str, key: str, person_properties: dict | None = None
117
+ ) -> Any:
118
+ # No exposure — payload reads follow a get_flag/is_flag_enabled call.
119
+ doc = self._decide(distinct_id, person_properties)
120
+ return (doc.get("flagPayloads") or {}).get(key)
121
+
122
+ def _report_exposure(self, distinct_id: str, key: str, value: FlagValue | None) -> None:
123
+ reported = False if value is None else value
124
+ dedupe = f"{distinct_id}:{key}:{reported!r}"
125
+ if self._exposures.contains(dedupe):
126
+ return
127
+ self._exposures.put(dedupe, True)
128
+ self._capture(
129
+ "az.flag_called",
130
+ {"$feature_flag": key, "$feature_flag_response": reported},
131
+ distinct_id=distinct_id,
132
+ )
@@ -0,0 +1,78 @@
1
+ """HTTP transport: batch POST with optional gzip, stdlib-only.
2
+
3
+ Endpoint selection mirrors the reference SDK: public tokens (``azfive_pub_…``)
4
+ use the browser-safe ingest path, anything else the server path. Delivery is
5
+ best-effort — the client re-queues failed batches; this module never raises.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import gzip as _gzip
11
+ import json
12
+ import logging
13
+ import urllib.error
14
+ import urllib.request
15
+ import uuid
16
+
17
+ logger = logging.getLogger("azfive_capture")
18
+
19
+ _TIMEOUT_S = 10.0
20
+
21
+
22
+ def uuid4() -> str:
23
+ return str(uuid.uuid4())
24
+
25
+
26
+ def ingest_path(api_key: str) -> str:
27
+ return "/api/v1/events/ingest" if api_key.startswith("azfive_pub_") else "/api/v1/events"
28
+
29
+
30
+ def send_batch(
31
+ events: list[dict],
32
+ *,
33
+ host: str,
34
+ api_key: str,
35
+ project: str,
36
+ gzip: bool = False,
37
+ ) -> bool:
38
+ """POST one batch. Returns True when the server accepted the request."""
39
+ body = json.dumps({"events": events, "project": project}).encode()
40
+ headers = {
41
+ "Authorization": f"Bearer {api_key}",
42
+ "Content-Type": "application/json",
43
+ }
44
+ if gzip:
45
+ body = _gzip.compress(body)
46
+ headers["Content-Encoding"] = "gzip"
47
+ req = urllib.request.Request(
48
+ f"{host}{ingest_path(api_key)}", data=body, headers=headers, method="POST"
49
+ )
50
+ try:
51
+ with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as res:
52
+ return 200 <= res.status < 300
53
+ except urllib.error.HTTPError as exc:
54
+ logger.debug("azfive batch rejected: %s", exc.code)
55
+ return False
56
+ except Exception as exc: # network errors — silent, caller re-queues
57
+ logger.debug("azfive batch failed: %s", exc)
58
+ return False
59
+
60
+
61
+ def post_json(url: str, api_key: str, payload: dict) -> dict | None:
62
+ """POST JSON, return the decoded JSON response or None on any failure."""
63
+ body = json.dumps(payload).encode()
64
+ req = urllib.request.Request(
65
+ url,
66
+ data=body,
67
+ headers={
68
+ "Authorization": f"Bearer {api_key}",
69
+ "Content-Type": "application/json",
70
+ },
71
+ method="POST",
72
+ )
73
+ try:
74
+ with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as res:
75
+ return json.loads(res.read().decode())
76
+ except Exception as exc:
77
+ logger.debug("azfive decide failed: %s", exc)
78
+ return None
@@ -0,0 +1,104 @@
1
+ import json
2
+ import sys
3
+ import threading
4
+ from http.server import BaseHTTPRequestHandler, HTTPServer
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
10
+
11
+ VECTORS_DIR = Path(__file__).parent.parent.parent / "sdk-spec" / "vectors"
12
+
13
+
14
+ def load_vectors(name: str) -> dict:
15
+ return json.loads((VECTORS_DIR / f"{name}.json").read_text())
16
+
17
+
18
+ class MockAzFive:
19
+ """Tiny scriptable AzFive stub: records (path, headers, body) and replays
20
+ canned responses. ``fail_next`` forces a 500 on the next events POST."""
21
+
22
+ def __init__(self) -> None:
23
+ self.requests: list[dict] = []
24
+ self.decide_response: dict = load_vectors("decide-fixtures")["response_empty"]
25
+ self.fail_next = False
26
+
27
+ outer = self
28
+
29
+ class Handler(BaseHTTPRequestHandler):
30
+ def do_POST(self): # noqa: N802
31
+ length = int(self.headers.get("Content-Length", 0))
32
+ raw = self.rfile.read(length)
33
+ if self.headers.get("Content-Encoding") == "gzip":
34
+ import gzip
35
+ raw = gzip.decompress(raw)
36
+ body = json.loads(raw) if raw else {}
37
+ outer.requests.append({
38
+ "path": self.path,
39
+ "headers": dict(self.headers),
40
+ "body": body,
41
+ })
42
+ if self.path.endswith("/decide"):
43
+ payload = outer.decide_response
44
+ elif outer.fail_next:
45
+ outer.fail_next = False
46
+ self.send_response(500)
47
+ self.end_headers()
48
+ return
49
+ else:
50
+ payload = {"accepted": len(body.get("events", [])), "rejected": 0}
51
+ data = json.dumps(payload).encode()
52
+ self.send_response(200)
53
+ self.send_header("Content-Type", "application/json")
54
+ self.send_header("Content-Length", str(len(data)))
55
+ self.end_headers()
56
+ self.wfile.write(data)
57
+
58
+ def log_message(self, *a): # silence
59
+ pass
60
+
61
+ self._server = HTTPServer(("127.0.0.1", 0), Handler)
62
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
63
+ self._thread.start()
64
+
65
+ @property
66
+ def host(self) -> str:
67
+ return f"http://127.0.0.1:{self._server.server_port}"
68
+
69
+ def events_batches(self) -> list[list[str]]:
70
+ return [
71
+ [e["event"] for e in r["body"].get("events", [])]
72
+ for r in self.requests
73
+ if "/events" in r["path"]
74
+ ]
75
+
76
+ def close(self) -> None:
77
+ self._server.shutdown()
78
+
79
+
80
+ @pytest.fixture
81
+ def mock_server():
82
+ server = MockAzFive()
83
+ yield server
84
+ server.close()
85
+
86
+
87
+ @pytest.fixture
88
+ def frozen_client(monkeypatch):
89
+ """A client with frozen clock/uuid/identity per the envelope vectors."""
90
+ from azfive_capture import client as client_mod
91
+ from azfive_capture import transport as transport_mod
92
+
93
+ envelope = load_vectors("event-envelope")
94
+ monkeypatch.setattr(client_mod, "_now_iso", lambda: envelope["frozen_timestamp"])
95
+ monkeypatch.setattr(transport_mod, "uuid4", lambda: "u-1")
96
+ monkeypatch.setattr(client_mod.transport, "uuid4", lambda: "u-1")
97
+
98
+ c = client_mod.Client(
99
+ "azfive_test_key", host="http://unused.local",
100
+ disable_decide=True, start_flush_thread=False,
101
+ )
102
+ c._distinct_id = "anon-1"
103
+ c._device_id = "dev-1"
104
+ return c
@@ -0,0 +1,98 @@
1
+ """Batching semantics — golden vectors + real HTTP against the mock server."""
2
+
3
+ from conftest import load_vectors
4
+
5
+ from azfive_capture.client import Client
6
+
7
+
8
+ class ScriptedTransport:
9
+ """Records batches; returns scripted ok/fail per flush call."""
10
+
11
+ def __init__(self) -> None:
12
+ self.batches: list[list[str]] = []
13
+ self.results: list[bool] = []
14
+
15
+ def send(self, batch: list[dict]) -> bool:
16
+ self.batches.append([e["event"] for e in batch])
17
+ return self.results.pop(0) if self.results else True
18
+
19
+
20
+ def _make_client(config: dict) -> tuple[Client, ScriptedTransport]:
21
+ c = Client(
22
+ "azfive_test", host="http://unused.local",
23
+ batch_size=config.get("batch_size", 10),
24
+ max_queue_size=config.get("max_queue_size", 1000),
25
+ disable_decide=True, start_flush_thread=False,
26
+ )
27
+ scripted = ScriptedTransport()
28
+ c._send = scripted.send # type: ignore[method-assign]
29
+ return c, scripted
30
+
31
+
32
+ def test_batching_vectors():
33
+ vectors = load_vectors("batching")
34
+ for case in vectors["cases"]:
35
+ client, transport = _make_client(case.get("config", {}))
36
+ for step in case["actions"]:
37
+ if step.startswith("capture "):
38
+ client.capture(step.split(" ", 1)[1])
39
+ elif step == "flush":
40
+ client.flush()
41
+ elif step == "flush(ok)":
42
+ transport.results.append(True)
43
+ client.flush()
44
+ elif step == "flush(fail)":
45
+ transport.results.append(False)
46
+ client.flush()
47
+ elif step == "opt_out":
48
+ client.opt_out_capturing()
49
+ elif step == "opt_in":
50
+ client.opt_in_capturing()
51
+ else:
52
+ raise AssertionError(f"unknown step {step}")
53
+ assert transport.batches == case["expected_batches"], case["name"]
54
+
55
+
56
+ def test_real_http_delivery(mock_server):
57
+ client = Client(
58
+ "azfive_secret_abc", host=mock_server.host,
59
+ batch_size=2, disable_decide=True, start_flush_thread=False,
60
+ )
61
+ client.capture("e1")
62
+ client.capture("e2") # triggers flush at batch_size
63
+ assert mock_server.events_batches() == [["e1", "e2"]]
64
+ req = mock_server.requests[0]
65
+ assert req["path"] == "/api/v1/events" # secret key → server path
66
+ assert req["headers"]["Authorization"] == "Bearer azfive_secret_abc"
67
+ assert req["body"]["project"] == "default"
68
+
69
+
70
+ def test_public_token_routes_to_ingest_path(mock_server):
71
+ client = Client(
72
+ "azfive_pub_abc", host=mock_server.host,
73
+ batch_size=1, disable_decide=True, start_flush_thread=False,
74
+ )
75
+ client.capture("e1")
76
+ assert mock_server.requests[0]["path"] == "/api/v1/events/ingest"
77
+
78
+
79
+ def test_gzip_delivery(mock_server):
80
+ client = Client(
81
+ "azfive_secret_abc", host=mock_server.host, gzip=True,
82
+ batch_size=1, disable_decide=True, start_flush_thread=False,
83
+ )
84
+ client.capture("zipped")
85
+ req = mock_server.requests[0]
86
+ assert req["headers"].get("Content-Encoding") == "gzip"
87
+ assert req["body"]["events"][0]["event"] == "zipped" # decompressed OK
88
+
89
+
90
+ def test_close_flushes(mock_server):
91
+ client = Client(
92
+ "azfive_secret_abc", host=mock_server.host,
93
+ batch_size=100, disable_decide=True, start_flush_thread=False,
94
+ )
95
+ client.capture("tail")
96
+ client.close()
97
+ assert mock_server.events_batches() == [["tail"]]
98
+ client.close() # idempotent
@@ -0,0 +1,104 @@
1
+ """Event envelope + property merge — golden vectors from sdk-spec/vectors/."""
2
+
3
+ from conftest import load_vectors
4
+
5
+ from azfive_capture.client import LIB_NAME, LIB_VERSION, merge_properties
6
+
7
+
8
+ def _run_action(client, action):
9
+ method = action["method"]
10
+ if method == "capture":
11
+ client.capture(action["event"], action.get("properties") or {})
12
+ elif method == "identify":
13
+ client.identify(action["distinct_id"], set=action.get("set"), set_once=action.get("set_once"))
14
+ elif method == "alias":
15
+ client.alias(action["alias"])
16
+ elif method == "people.set":
17
+ client.people.set(action["properties"])
18
+ elif method == "people.set_once":
19
+ client.people.set_once(action["properties"])
20
+ elif method == "az.flag_called":
21
+ client.capture(
22
+ "az.flag_called",
23
+ {"$feature_flag": action["flag"], "$feature_flag_response": action["response"]},
24
+ )
25
+ else:
26
+ raise AssertionError(f"unknown action {method}")
27
+
28
+
29
+ def test_event_envelope_vectors(frozen_client):
30
+ vectors = load_vectors("event-envelope")
31
+ ignore = set(vectors["ignore_property_keys"])
32
+ for case in vectors["cases"]:
33
+ # Each case assumes a fresh anonymous client.
34
+ frozen_client._queue.clear()
35
+ frozen_client._distinct_id = "anon-1"
36
+ frozen_client._device_id = "dev-1"
37
+ frozen_client._identified = False
38
+ frozen_client._super_props = {}
39
+ for setup in case.get("setup", []):
40
+ _run_action(frozen_client, setup)
41
+ frozen_client._queue.clear() # setup events aren't asserted
42
+ _run_action(frozen_client, case["action"])
43
+ assert len(frozen_client._queue) == 1, case["name"]
44
+ actual = dict(frozen_client._queue[0])
45
+ expected = case["expected"]
46
+ actual["properties"] = {
47
+ k: v for k, v in actual["properties"].items()
48
+ if k not in ignore or k in expected["properties"]
49
+ }
50
+ assert actual == expected, case["name"]
51
+
52
+
53
+ def test_lib_stamps_present(frozen_client):
54
+ frozen_client.capture("e")
55
+ props = frozen_client._queue[0]["properties"]
56
+ assert props["$lib"] == LIB_NAME == "azfive-python"
57
+ assert props["$lib_version"] == LIB_VERSION
58
+ assert props["$device_type"] == "Server"
59
+
60
+
61
+ def test_property_merge_vectors(frozen_client):
62
+ vectors = load_vectors("property-merge")
63
+ for case in vectors["cases"]:
64
+ if "register_sequence" in case:
65
+ frozen_client._super_props = {}
66
+ for step in case["register_sequence"]:
67
+ if step["method"] == "register":
68
+ frozen_client.register(step["props"])
69
+ elif step["method"] == "register_once":
70
+ frozen_client.register_once(step["props"])
71
+ elif step["method"] == "unregister":
72
+ frozen_client.unregister(step["key"])
73
+ assert frozen_client._super_props == case["expected_super"], case["name"]
74
+ elif "expected_reserved" in case:
75
+ frozen_client._super_props = dict(case.get("super") or {})
76
+ frozen_client._queue.clear()
77
+ frozen_client.capture("e", dict(case.get("event") or {}))
78
+ props = frozen_client._queue[0]["properties"]
79
+ assert props["$lib"] == LIB_NAME, case["name"]
80
+ assert props["$lib_version"] == LIB_VERSION, case["name"]
81
+ assert props["$device_id"] == "dev-1", case["name"]
82
+ frozen_client._super_props = {}
83
+ else:
84
+ merged = merge_properties(
85
+ case.get("static") or {}, case.get("session") or {},
86
+ case.get("super") or {}, case.get("event") or {}, {},
87
+ )
88
+ assert merged == case["expected"], case["name"]
89
+
90
+
91
+ def test_reset_preserves_optout_and_device_id(frozen_client):
92
+ frozen_client.register({"team": "core"})
93
+ frozen_client.identify("ava@example.com")
94
+ frozen_client.opt_out_capturing()
95
+ device = frozen_client._device_id
96
+
97
+ frozen_client.reset()
98
+ assert frozen_client.get_distinct_id() != "ava@example.com"
99
+ assert frozen_client._super_props == {}
100
+ assert frozen_client.has_opted_out_capturing() is True
101
+ assert frozen_client._device_id == device
102
+
103
+ frozen_client.reset(reset_device_id=True)
104
+ assert frozen_client._device_id != device or frozen_client._device_id == "u-1"
@@ -0,0 +1,135 @@
1
+ """Flags: decide client, TTL cache, exposure dedup — golden vectors."""
2
+
3
+ from conftest import load_vectors
4
+
5
+ from azfive_capture.flags import FlagsManager
6
+
7
+
8
+ class FakeClock:
9
+ def __init__(self) -> None:
10
+ self.t = 0.0
11
+
12
+ def __call__(self) -> float:
13
+ return self.t
14
+
15
+
16
+ def _manager(flags_doc: dict, captured: list, clock: FakeClock | None = None):
17
+ state = {"doc": {"flags": dict(flags_doc), "flagPayloads": {}}, "calls": 0}
18
+
19
+ def capture(event, properties, *, distinct_id):
20
+ captured.append({"event": event, "properties": properties, "distinct_id": distinct_id})
21
+
22
+ mgr = FlagsManager(
23
+ host="http://unused.local", api_key="k", project="default",
24
+ capture=capture, now=clock or FakeClock(),
25
+ )
26
+
27
+ def fake_decide(url, api_key, payload):
28
+ state["calls"] += 1
29
+ return state["doc"]
30
+
31
+ import azfive_capture.flags as flags_mod
32
+ original = flags_mod.transport.post_json
33
+ flags_mod.transport.post_json = fake_decide # patched for the test process
34
+ mgr._restore = lambda: setattr(flags_mod.transport, "post_json", original) # type: ignore[attr-defined]
35
+ return mgr, state
36
+
37
+
38
+ def test_exposure_dedup_vectors():
39
+ vectors = load_vectors("exposure-dedup")
40
+ for case in vectors["cases"]:
41
+ captured: list = []
42
+ mgr, state = _manager(vectors["flags"], captured)
43
+ try:
44
+ distinct_id = "user-1"
45
+ for call in case["calls"]:
46
+ method = call["method"]
47
+ if method == "getFlag":
48
+ result = mgr.get_flag(distinct_id, call["key"])
49
+ if "expected_return" in case and case["expected_return"] is None:
50
+ assert result is None, case["name"]
51
+ elif method == "isFlagEnabled":
52
+ result = mgr.is_flag_enabled(distinct_id, call["key"])
53
+ if "expect" in call:
54
+ assert result is call["expect"], f"{case['name']}: {call['key']}"
55
+ elif method == "getFlagPayload":
56
+ mgr.get_flag_payload(distinct_id, call["key"])
57
+ elif method == "_update_flag":
58
+ state["doc"]["flags"][call["key"]] = call["value"]
59
+ mgr.reload_flags(distinct_id)
60
+ elif method == "_new_scope":
61
+ distinct_id = "user-2" # server dedup scope = distinct_id
62
+ else:
63
+ raise AssertionError(method)
64
+ if "expected_exposures" in case:
65
+ actual = [
66
+ {"$feature_flag": c["properties"]["$feature_flag"],
67
+ "$feature_flag_response": c["properties"]["$feature_flag_response"]}
68
+ for c in captured
69
+ ]
70
+ assert actual == case["expected_exposures"], case["name"]
71
+ finally:
72
+ mgr._restore() # type: ignore[attr-defined]
73
+
74
+
75
+ def test_decide_ttl_cache():
76
+ clock = FakeClock()
77
+ captured: list = []
78
+ mgr, state = _manager({"f": True}, captured, clock)
79
+ try:
80
+ mgr.get_flag("u1", "f")
81
+ mgr.get_flag("u1", "f")
82
+ assert state["calls"] == 1 # cached
83
+ clock.t += 61
84
+ mgr.get_flag("u1", "f")
85
+ assert state["calls"] == 2 # TTL expired
86
+ mgr.reload_flags("u1")
87
+ mgr.get_flag("u1", "f")
88
+ assert state["calls"] == 3 # busted
89
+ mgr.get_flag("u2", "f")
90
+ assert state["calls"] == 4 # per-distinct_id cache
91
+ finally:
92
+ mgr._restore() # type: ignore[attr-defined]
93
+
94
+
95
+ def test_decide_request_shape(mock_server):
96
+ from azfive_capture import Client
97
+
98
+ fixtures = load_vectors("decide-fixtures")
99
+ mock_server.decide_response = fixtures["response_full"]
100
+ client = Client("azfive_secret_abc", host=mock_server.host, start_flush_thread=False)
101
+
102
+ assert client.get_flag("anon-1", "experiment") == "variant-b"
103
+ assert client.get_flag_payload("anon-1", "experiment") == {"cta": "Try it"}
104
+ assert client.is_flag_enabled("anon-1", "boolean-off") is False
105
+
106
+ decide_reqs = [r for r in mock_server.requests if r["path"].endswith("/decide")]
107
+ assert decide_reqs[0]["body"] == fixtures["request"]["body"]
108
+ assert decide_reqs[0]["headers"]["Authorization"] == "Bearer azfive_secret_abc"
109
+
110
+ # exposures were captured for getFlag/isFlagEnabled but not payload
111
+ client.flush()
112
+ exposure_events = [
113
+ e for r in mock_server.requests if "/events" in r["path"]
114
+ for e in r["body"]["events"] if e["event"] == "az.flag_called"
115
+ ]
116
+ responses = {(e["properties"]["$feature_flag"], e["properties"]["$feature_flag_response"])
117
+ for e in exposure_events}
118
+ assert responses == {("experiment", "variant-b"), ("boolean-off", False)}
119
+
120
+
121
+ def test_decide_failure_returns_stale_or_none():
122
+ captured: list = []
123
+ mgr, state = _manager({"f": True}, captured)
124
+ try:
125
+ assert mgr.get_flag("u1", "f") is True
126
+ state["doc"] = None # network failure → post_json returns None
127
+
128
+ def failing(url, api_key, payload):
129
+ return None
130
+ import azfive_capture.flags as flags_mod
131
+ flags_mod.transport.post_json = failing
132
+ mgr._decide_cache.remove("u1")
133
+ assert mgr.get_flag("u1", "f") is None # no stale entry → empty doc
134
+ finally:
135
+ mgr._restore() # type: ignore[attr-defined]