toggletest 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.
- toggletest/__init__.py +54 -0
- toggletest/client.py +520 -0
- toggletest/event_buffer.py +244 -0
- toggletest/rules_store.py +257 -0
- toggletest/sse.py +217 -0
- toggletest/types.py +156 -0
- toggletest/wasm_engine.py +673 -0
- toggletest-0.1.0.dist-info/METADATA +12 -0
- toggletest-0.1.0.dist-info/RECORD +10 -0
- toggletest-0.1.0.dist-info/WHEEL +4 -0
toggletest/sse.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sse.py - Simplified SSE connection for version change notifications
|
|
3
|
+
|
|
4
|
+
Architecture overview:
|
|
5
|
+
In the old SDK, the SSE stream carried the full flag/test data (init events,
|
|
6
|
+
individual flag_updated/flag_deleted events, etc). The client had to maintain
|
|
7
|
+
an in-memory store that mirrored the server state.
|
|
8
|
+
|
|
9
|
+
In the new WASM-based architecture, evaluation is done locally using a
|
|
10
|
+
compiled rules JSON blob. The SSE stream is now drastically simpler:
|
|
11
|
+
- It only sends ``rules_updated`` events containing a version hash.
|
|
12
|
+
- When the SDK receives a version hash that differs from its cached ETag,
|
|
13
|
+
it re-fetches the rules blob via the RulesStore.
|
|
14
|
+
|
|
15
|
+
This simplification means the SSE stream is just a lightweight notification
|
|
16
|
+
channel, not a data transport. If SSE disconnects, the SDK continues to
|
|
17
|
+
evaluate using the last-fetched rules (it is never in a "no data" state
|
|
18
|
+
after initial startup).
|
|
19
|
+
|
|
20
|
+
Reconnection:
|
|
21
|
+
Uses exponential backoff (1s -> 2s -> 4s -> ... -> 30s max) on connection
|
|
22
|
+
failures, consistent with the TypeScript SDK.
|
|
23
|
+
|
|
24
|
+
Threading:
|
|
25
|
+
The SSE loop runs on a dedicated daemon thread so it does not block the
|
|
26
|
+
calling thread. The ``close()`` method signals the thread to stop and
|
|
27
|
+
waits briefly for it to exit.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
import threading
|
|
35
|
+
import time
|
|
36
|
+
from typing import Callable, Dict, Optional
|
|
37
|
+
|
|
38
|
+
import httpx
|
|
39
|
+
from httpx_sse import connect_sse
|
|
40
|
+
|
|
41
|
+
from .rules_store import RulesStore
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger("toggletest")
|
|
44
|
+
|
|
45
|
+
# Maximum time between reconnection attempts.
|
|
46
|
+
MAX_BACKOFF = 30.0
|
|
47
|
+
|
|
48
|
+
# Starting backoff duration after first failure.
|
|
49
|
+
INITIAL_BACKOFF = 1.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SseConnection:
|
|
53
|
+
"""SSE client that listens for ``rules_updated`` events.
|
|
54
|
+
|
|
55
|
+
When a version notification arrives, it tells the RulesStore to check
|
|
56
|
+
for new rules. All other event types are ignored.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
base_url: str,
|
|
63
|
+
api_key: str,
|
|
64
|
+
environment: Optional[str] = None,
|
|
65
|
+
rules_store: RulesStore,
|
|
66
|
+
on_error: Optional[Callable[[Exception], None]] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Initialize the SSE connection (does not connect yet).
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
base_url: Base URL of the ToggleTest API.
|
|
72
|
+
api_key: SDK API key sent as x-api-key header.
|
|
73
|
+
environment: Optional environment identifier.
|
|
74
|
+
rules_store: The RulesStore to notify when rules change.
|
|
75
|
+
on_error: Optional callback for non-fatal connection errors.
|
|
76
|
+
"""
|
|
77
|
+
self._base_url = base_url
|
|
78
|
+
self._api_key = api_key
|
|
79
|
+
self._environment = environment
|
|
80
|
+
self._rules_store = rules_store
|
|
81
|
+
self._on_error = on_error
|
|
82
|
+
|
|
83
|
+
# Whether the connection loop should keep running.
|
|
84
|
+
self._running = False
|
|
85
|
+
|
|
86
|
+
# The background thread running the SSE loop.
|
|
87
|
+
self._thread: Optional[threading.Thread] = None
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
# Header helpers
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def _build_headers(self) -> Dict[str, str]:
|
|
94
|
+
"""Build headers for the SSE connection request.
|
|
95
|
+
|
|
96
|
+
Uses x-api-key (not Bearer token) to authenticate.
|
|
97
|
+
"""
|
|
98
|
+
headers: Dict[str, str] = {
|
|
99
|
+
"x-api-key": self._api_key,
|
|
100
|
+
"Accept": "text/event-stream",
|
|
101
|
+
}
|
|
102
|
+
if self._environment:
|
|
103
|
+
headers["x-environment"] = self._environment
|
|
104
|
+
return headers
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
# Lifecycle
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def connect(self) -> None:
|
|
111
|
+
"""Start the SSE connection loop on a background daemon thread.
|
|
112
|
+
|
|
113
|
+
Returns immediately. The thread automatically reconnects on failure
|
|
114
|
+
with exponential backoff.
|
|
115
|
+
"""
|
|
116
|
+
self._running = True
|
|
117
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
118
|
+
self._thread.start()
|
|
119
|
+
|
|
120
|
+
def close(self) -> None:
|
|
121
|
+
"""Close the SSE connection and stop the reconnection loop.
|
|
122
|
+
|
|
123
|
+
Safe to call multiple times. Blocks up to 5 seconds waiting for
|
|
124
|
+
the background thread to exit.
|
|
125
|
+
"""
|
|
126
|
+
self._running = False
|
|
127
|
+
if self._thread is not None:
|
|
128
|
+
self._thread.join(timeout=5.0)
|
|
129
|
+
self._thread = None
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
# Internal: connection loop with reconnection
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def _run(self) -> None:
|
|
136
|
+
"""Main reconnection loop. Runs on the background thread.
|
|
137
|
+
|
|
138
|
+
Keeps trying to connect as long as ``_running`` is True.
|
|
139
|
+
On successful connection, reads events until the stream ends or errors.
|
|
140
|
+
On failure, waits with exponential backoff before retrying.
|
|
141
|
+
"""
|
|
142
|
+
backoff = INITIAL_BACKOFF
|
|
143
|
+
|
|
144
|
+
while self._running:
|
|
145
|
+
try:
|
|
146
|
+
# httpx_sse provides a context manager that opens an SSE
|
|
147
|
+
# connection and yields an event source iterator.
|
|
148
|
+
with httpx.Client(timeout=httpx.Timeout(connect=10.0, read=120.0, write=10.0, pool=10.0)) as client:
|
|
149
|
+
with connect_sse(
|
|
150
|
+
client,
|
|
151
|
+
"GET",
|
|
152
|
+
f"{self._base_url}/sdk/stream",
|
|
153
|
+
headers=self._build_headers(),
|
|
154
|
+
) as event_source:
|
|
155
|
+
# Reset backoff on successful connection.
|
|
156
|
+
backoff = INITIAL_BACKOFF
|
|
157
|
+
|
|
158
|
+
for sse in event_source.iter_sse():
|
|
159
|
+
if not self._running:
|
|
160
|
+
return
|
|
161
|
+
self._handle_event(sse.event, sse.data)
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
# If we were intentionally closed, exit cleanly.
|
|
164
|
+
if not self._running:
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
# Report the error via callback (non-fatal).
|
|
168
|
+
if self._on_error:
|
|
169
|
+
self._on_error(exc)
|
|
170
|
+
logger.debug("SSE connection error: %s", exc)
|
|
171
|
+
|
|
172
|
+
# Wait before reconnecting with exponential backoff.
|
|
173
|
+
time.sleep(backoff)
|
|
174
|
+
backoff = min(backoff * 2, MAX_BACKOFF)
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
# Internal: event handling
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def _handle_event(self, event_type: str, data: str) -> None:
|
|
181
|
+
"""Handle a parsed SSE event.
|
|
182
|
+
|
|
183
|
+
In the new architecture, the only event type we care about is
|
|
184
|
+
``rules_updated``, which carries a version hash. We forward it
|
|
185
|
+
to the RulesStore, which will re-fetch if the version has changed.
|
|
186
|
+
|
|
187
|
+
All other event types (heartbeats, future event types) are silently
|
|
188
|
+
ignored.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
event_type: The SSE event name (e.g. "rules_updated").
|
|
192
|
+
data: The raw SSE data payload (JSON string).
|
|
193
|
+
"""
|
|
194
|
+
if event_type != "rules_updated":
|
|
195
|
+
# Ignore unknown event types.
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
parsed = json.loads(data)
|
|
200
|
+
except json.JSONDecodeError:
|
|
201
|
+
# Ignore malformed JSON in SSE data. This is non-fatal.
|
|
202
|
+
logger.debug("Malformed SSE data for rules_updated event: %s", data)
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
version = parsed.get("version")
|
|
206
|
+
if not version:
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
# Tell the RulesStore to re-fetch if the version changed.
|
|
211
|
+
self._rules_store.on_version_notification(version)
|
|
212
|
+
except Exception as exc:
|
|
213
|
+
# Re-fetch failure is non-fatal. The SDK continues evaluating
|
|
214
|
+
# with the last-known rules.
|
|
215
|
+
if self._on_error:
|
|
216
|
+
self._on_error(exc)
|
|
217
|
+
logger.debug("Failed to re-fetch rules after SSE notification: %s", exc)
|
toggletest/types.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
types.py - Type definitions for the ToggleTest WASM-based SDK
|
|
3
|
+
|
|
4
|
+
This SDK evaluates feature flags and A/B tests locally using a WASM engine.
|
|
5
|
+
The server provides a compiled rules JSON blob and a WASM evaluator binary.
|
|
6
|
+
All flag/test evaluation happens client-side with zero network latency.
|
|
7
|
+
|
|
8
|
+
Mirrors the TypeScript SDK's type definitions so both SDKs share the same
|
|
9
|
+
data contract with the server and WASM evaluator.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Callable, Dict, Literal, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Client configuration
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ToggleTestConfig:
|
|
23
|
+
"""Configuration for the ToggleTest client.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
api_key: SDK API key, typically prefixed with "tt_".
|
|
27
|
+
Sent as the x-api-key header on all requests.
|
|
28
|
+
base_url: Base URL of the ToggleTest API
|
|
29
|
+
(e.g. "http://localhost:3001").
|
|
30
|
+
environment: Optional environment identifier (e.g. "production").
|
|
31
|
+
Sent as x-environment header so the server scopes
|
|
32
|
+
rules to the correct environment.
|
|
33
|
+
on_ready: Called once the WASM engine is loaded and rules are fetched.
|
|
34
|
+
on_error: Called when a non-fatal error occurs (SSE disconnect, etc).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
api_key: str
|
|
38
|
+
base_url: str
|
|
39
|
+
environment: Optional[str] = None
|
|
40
|
+
on_ready: Optional[Callable[[], None]] = None
|
|
41
|
+
on_error: Optional[Callable[[Exception], None]] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# WASM evaluation context
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class EvalContext:
|
|
51
|
+
"""Context passed into every evaluation call.
|
|
52
|
+
|
|
53
|
+
The WASM engine uses this to match targeting rules and compute
|
|
54
|
+
bucket assignments.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
user_id: Unique identifier for the end-user being evaluated.
|
|
58
|
+
attributes: Arbitrary user/request attributes used in targeting rules.
|
|
59
|
+
Example: {"plan": "pro", "country": "US", "beta": True}
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
user_id: str
|
|
63
|
+
attributes: Optional[Dict[str, Any]] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# WASM evaluation results
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class FlagResult:
|
|
73
|
+
"""Result of evaluating a single feature flag.
|
|
74
|
+
|
|
75
|
+
Attributes:
|
|
76
|
+
value: The evaluated value of the flag (bool, str, number, or dict).
|
|
77
|
+
reason: Human-readable reason for the result
|
|
78
|
+
(e.g. "rule_match", "default", "disabled", "not_found").
|
|
79
|
+
rule_id: The ID of the rule that matched, if any.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
value: Any
|
|
83
|
+
reason: str
|
|
84
|
+
rule_id: Optional[str] = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class TestResult:
|
|
89
|
+
"""Result of evaluating a single A/B test assignment.
|
|
90
|
+
|
|
91
|
+
Attributes:
|
|
92
|
+
variant: The variant the user was assigned to
|
|
93
|
+
(e.g. "control", "variant_a").
|
|
94
|
+
bucket: The deterministic bucket value (0-9999) computed from
|
|
95
|
+
user_id + test key. Ensures consistent assignment
|
|
96
|
+
without server-side state.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
variant: str
|
|
100
|
+
bucket: int
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class EvalResults:
|
|
105
|
+
"""Full evaluation result returned by the WASM engine.
|
|
106
|
+
|
|
107
|
+
Contains every flag and every test evaluated against the provided
|
|
108
|
+
context.
|
|
109
|
+
|
|
110
|
+
Attributes:
|
|
111
|
+
flags: Map of flag key -> evaluation result.
|
|
112
|
+
tests: Map of test key -> variant assignment.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
flags: Dict[str, FlagResult]
|
|
116
|
+
tests: Dict[str, TestResult]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Listener type alias
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
# Called whenever rules are updated. Receives the new version hash (ETag).
|
|
124
|
+
RulesUpdateListener = Callable[[str], None]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Event tracking types
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class SdkEvent:
|
|
134
|
+
"""An analytics event sent to the ToggleTest backend in batches.
|
|
135
|
+
|
|
136
|
+
Includes an evaluation_reason field so the backend knows *why* the
|
|
137
|
+
event occurred (e.g. which rule matched, or which variant was assigned).
|
|
138
|
+
|
|
139
|
+
Attributes:
|
|
140
|
+
event_type: The kind of event being tracked.
|
|
141
|
+
end_user_id: The end-user this event is about.
|
|
142
|
+
flag_key: The flag key relevant to this event, if any.
|
|
143
|
+
test_key: The A/B test key relevant to this event, if any.
|
|
144
|
+
variant_name: The variant name for assignment/conversion events.
|
|
145
|
+
evaluation_reason: The reason the evaluation produced this result.
|
|
146
|
+
Comes directly from the WASM engine.
|
|
147
|
+
metadata: Arbitrary metadata attached to the event.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
event_type: Literal["flag_evaluation", "variant_assignment", "conversion"]
|
|
151
|
+
end_user_id: str
|
|
152
|
+
flag_key: Optional[str] = None
|
|
153
|
+
test_key: Optional[str] = None
|
|
154
|
+
variant_name: Optional[str] = None
|
|
155
|
+
evaluation_reason: Optional[str] = None
|
|
156
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|