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/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)