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/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
toggletest - ToggleTest Python SDK with WASM-based local evaluation
|
|
3
|
+
|
|
4
|
+
This is the package entry point. It re-exports only the types and classes
|
|
5
|
+
that SDK consumers need. Internal implementation details (WasmEngine,
|
|
6
|
+
RulesStore, SseConnection, EventBuffer) are NOT exported.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from toggletest import ToggleTestClient
|
|
10
|
+
from toggletest import EvalContext, EvalResults, FlagResult, TestResult
|
|
11
|
+
|
|
12
|
+
client = ToggleTestClient(
|
|
13
|
+
api_key="tt_...",
|
|
14
|
+
base_url="http://localhost:3001",
|
|
15
|
+
environment="production",
|
|
16
|
+
)
|
|
17
|
+
client.start()
|
|
18
|
+
|
|
19
|
+
if client.is_enabled("dark-mode", user_id="user-123"):
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
client.close()
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# -- Main client class --
|
|
26
|
+
from .client import ToggleTestClient
|
|
27
|
+
|
|
28
|
+
# -- Configuration and context types --
|
|
29
|
+
from .types import ToggleTestConfig, EvalContext
|
|
30
|
+
|
|
31
|
+
# -- Evaluation result types --
|
|
32
|
+
from .types import FlagResult, TestResult, EvalResults
|
|
33
|
+
|
|
34
|
+
# -- Event tracking types --
|
|
35
|
+
from .types import SdkEvent
|
|
36
|
+
|
|
37
|
+
# -- Listener types --
|
|
38
|
+
from .types import RulesUpdateListener
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
# Client
|
|
42
|
+
"ToggleTestClient",
|
|
43
|
+
# Config / context
|
|
44
|
+
"ToggleTestConfig",
|
|
45
|
+
"EvalContext",
|
|
46
|
+
# Evaluation results
|
|
47
|
+
"FlagResult",
|
|
48
|
+
"TestResult",
|
|
49
|
+
"EvalResults",
|
|
50
|
+
# Events
|
|
51
|
+
"SdkEvent",
|
|
52
|
+
# Listeners
|
|
53
|
+
"RulesUpdateListener",
|
|
54
|
+
]
|
toggletest/client.py
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
"""
|
|
2
|
+
client.py - Main ToggleTest SDK client with WASM-based local evaluation
|
|
3
|
+
|
|
4
|
+
Architecture overview:
|
|
5
|
+
The ToggleTestClient is the primary public interface for the SDK. It
|
|
6
|
+
orchestrates four internal components:
|
|
7
|
+
|
|
8
|
+
1. WasmEngine - Loads the WASM evaluator binary and runs evaluations
|
|
9
|
+
entirely in-process (no network calls per evaluation).
|
|
10
|
+
2. RulesStore - Caches the compiled rules JSON blob from the server,
|
|
11
|
+
uses ETag-based conditional fetching for efficiency.
|
|
12
|
+
3. SseConnection - Listens for ``rules_updated`` SSE events and triggers
|
|
13
|
+
the RulesStore to re-fetch when rules change.
|
|
14
|
+
4. EventBuffer - Batches analytics events and sends them periodically.
|
|
15
|
+
|
|
16
|
+
Lifecycle:
|
|
17
|
+
client = ToggleTestClient(api_key="tt_...", base_url="http://...")
|
|
18
|
+
client.start() # Fetches WASM + rules, connects SSE
|
|
19
|
+
client.is_enabled("flag") # Local evaluation (synchronous!)
|
|
20
|
+
client.track("conversion") # Buffered event tracking
|
|
21
|
+
client.close() # Disconnect SSE, flush events
|
|
22
|
+
|
|
23
|
+
The key insight is that after start() completes, ALL evaluation is local.
|
|
24
|
+
There are zero network calls in the hot path. The only ongoing network
|
|
25
|
+
activity is:
|
|
26
|
+
- SSE connection for change notifications (lightweight, server-push)
|
|
27
|
+
- Periodic event flushes (batched, fire-and-forget)
|
|
28
|
+
- Occasional rules re-fetch when notified of changes
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
from typing import Any, Callable, Dict, Optional
|
|
35
|
+
|
|
36
|
+
import httpx
|
|
37
|
+
|
|
38
|
+
from .event_buffer import EventBuffer
|
|
39
|
+
from .rules_store import RulesStore
|
|
40
|
+
from .sse import SseConnection
|
|
41
|
+
from .types import (
|
|
42
|
+
EvalContext,
|
|
43
|
+
EvalResults,
|
|
44
|
+
FlagResult,
|
|
45
|
+
RulesUpdateListener,
|
|
46
|
+
SdkEvent,
|
|
47
|
+
TestResult,
|
|
48
|
+
)
|
|
49
|
+
from .wasm_engine import WasmEngine
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger("toggletest")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ToggleTestClient:
|
|
55
|
+
"""ToggleTest SDK client with WASM-based local evaluation.
|
|
56
|
+
|
|
57
|
+
All feature flag and A/B test evaluations happen locally via a WASM
|
|
58
|
+
engine after the initial setup completes. The server is only contacted
|
|
59
|
+
for:
|
|
60
|
+
- Fetching the WASM binary (once, at startup)
|
|
61
|
+
- Fetching the rules JSON (at startup + on change notifications)
|
|
62
|
+
- SSE stream for real-time change notifications
|
|
63
|
+
- Batched analytics event submission
|
|
64
|
+
|
|
65
|
+
Constructor args (flat style for convenience):
|
|
66
|
+
api_key: SDK API key, typically prefixed with "tt_".
|
|
67
|
+
base_url: Base URL of the ToggleTest API.
|
|
68
|
+
environment: Optional environment identifier (e.g. "production").
|
|
69
|
+
on_ready: Called once the SDK is fully initialized.
|
|
70
|
+
on_error: Called when a non-fatal error occurs.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
api_key: str,
|
|
77
|
+
base_url: str,
|
|
78
|
+
environment: Optional[str] = None,
|
|
79
|
+
on_ready: Optional[Callable[[], None]] = None,
|
|
80
|
+
on_error: Optional[Callable[[Exception], None]] = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Initialize the client.
|
|
83
|
+
|
|
84
|
+
No network calls are made in the constructor. All I/O happens
|
|
85
|
+
in ``start()``.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
api_key: SDK API key sent as x-api-key header.
|
|
89
|
+
base_url: Base URL of the ToggleTest API.
|
|
90
|
+
environment: Optional environment identifier.
|
|
91
|
+
on_ready: Callback fired once WASM + rules are loaded.
|
|
92
|
+
on_error: Callback fired on non-fatal errors (SSE, fetch).
|
|
93
|
+
"""
|
|
94
|
+
self._api_key = api_key
|
|
95
|
+
self._base_url = base_url
|
|
96
|
+
self._environment = environment
|
|
97
|
+
self._on_ready = on_ready
|
|
98
|
+
self._on_error = on_error
|
|
99
|
+
|
|
100
|
+
# Warn if using HTTP (API key transmitted in the clear).
|
|
101
|
+
if self._base_url.startswith('http://') and 'localhost' not in self._base_url and '127.0.0.1' not in self._base_url:
|
|
102
|
+
import warnings
|
|
103
|
+
warnings.warn(
|
|
104
|
+
'[ToggleTest] WARNING: Using HTTP. API key will be transmitted insecurely. Use HTTPS in production.',
|
|
105
|
+
stacklevel=2,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Shared HTTP client for all outbound requests (connection pooling).
|
|
109
|
+
self._http_client: Optional[httpx.Client] = None
|
|
110
|
+
|
|
111
|
+
# -- Internal components (initialized here, I/O deferred to start) --
|
|
112
|
+
|
|
113
|
+
# WASM evaluation engine. Loaded once, used for all evaluations.
|
|
114
|
+
self._engine = WasmEngine()
|
|
115
|
+
|
|
116
|
+
# Cached rules JSON with ETag-based conditional fetching.
|
|
117
|
+
self._rules_store = RulesStore(
|
|
118
|
+
base_url=base_url,
|
|
119
|
+
api_key=api_key,
|
|
120
|
+
environment=environment,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# SSE connection for real-time rules change notifications.
|
|
124
|
+
self._sse: Optional[SseConnection] = None
|
|
125
|
+
|
|
126
|
+
# Batched event buffer for analytics tracking.
|
|
127
|
+
self._event_buffer = EventBuffer(
|
|
128
|
+
base_url=base_url,
|
|
129
|
+
api_key=api_key,
|
|
130
|
+
environment=environment,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Whether the client has completed initialization
|
|
134
|
+
# (WASM loaded + rules fetched).
|
|
135
|
+
self._ready = False
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
# Properties
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def ready(self) -> bool:
|
|
143
|
+
"""Whether the SDK has completed initialization and is ready."""
|
|
144
|
+
return self._ready
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
# Lifecycle
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def start(self) -> None:
|
|
151
|
+
"""Initialize the SDK.
|
|
152
|
+
|
|
153
|
+
This method performs four steps in sequence:
|
|
154
|
+
1. Fetches the WASM evaluator binary from GET /sdk/evaluator.wasm
|
|
155
|
+
2. Loads the WASM binary into the wasmtime engine
|
|
156
|
+
3. Fetches the initial rules JSON from GET /sdk/rules
|
|
157
|
+
4. Starts the SSE connection for change notifications
|
|
158
|
+
5. Starts the event buffer flush timer
|
|
159
|
+
|
|
160
|
+
After this method returns, the SDK is fully operational and all
|
|
161
|
+
evaluation methods can be called synchronously.
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
RuntimeError: If WASM loading fails.
|
|
165
|
+
httpx.HTTPError: If the initial rules fetch fails.
|
|
166
|
+
"""
|
|
167
|
+
# Create the shared HTTP client with sensible timeouts.
|
|
168
|
+
self._http_client = httpx.Client(
|
|
169
|
+
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Pass the shared client to sub-components.
|
|
173
|
+
self._rules_store.set_http_client(self._http_client)
|
|
174
|
+
self._event_buffer.set_http_client(self._http_client)
|
|
175
|
+
|
|
176
|
+
# -- Step 1: Fetch the WASM evaluator binary --
|
|
177
|
+
wasm_bytes = self._fetch_wasm_binary()
|
|
178
|
+
|
|
179
|
+
# -- Step 2: Load and compile the WASM module via wasmtime --
|
|
180
|
+
self._engine.load(wasm_bytes)
|
|
181
|
+
|
|
182
|
+
# -- Step 3: Fetch the initial rules --
|
|
183
|
+
self._rules_store.fetch_rules()
|
|
184
|
+
|
|
185
|
+
# Mark as ready. From this point, all evaluation is local.
|
|
186
|
+
self._ready = True
|
|
187
|
+
|
|
188
|
+
# -- Step 4: Connect SSE for real-time change notifications --
|
|
189
|
+
# SSE is started after we're ready so evaluation works even if
|
|
190
|
+
# SSE fails to connect.
|
|
191
|
+
self._sse = SseConnection(
|
|
192
|
+
base_url=self._base_url,
|
|
193
|
+
api_key=self._api_key,
|
|
194
|
+
environment=self._environment,
|
|
195
|
+
rules_store=self._rules_store,
|
|
196
|
+
on_error=self._on_error,
|
|
197
|
+
)
|
|
198
|
+
self._sse.connect()
|
|
199
|
+
|
|
200
|
+
# -- Step 5: Start the event buffer --
|
|
201
|
+
self._event_buffer.start()
|
|
202
|
+
|
|
203
|
+
# Notify the consumer that the SDK is ready.
|
|
204
|
+
if self._on_ready:
|
|
205
|
+
self._on_ready()
|
|
206
|
+
|
|
207
|
+
def close(self) -> None:
|
|
208
|
+
"""Gracefully shut down the SDK.
|
|
209
|
+
|
|
210
|
+
Disconnects the SSE stream, flushes any remaining buffered events,
|
|
211
|
+
and releases resources. After calling close(), the client should
|
|
212
|
+
not be used for further evaluations.
|
|
213
|
+
"""
|
|
214
|
+
# Disconnect SSE first to stop triggering new rule fetches.
|
|
215
|
+
if self._sse is not None:
|
|
216
|
+
self._sse.close()
|
|
217
|
+
self._sse = None
|
|
218
|
+
|
|
219
|
+
# Flush remaining events to the server (best-effort).
|
|
220
|
+
self._event_buffer.stop()
|
|
221
|
+
|
|
222
|
+
# Close the shared HTTP client to release connection pool resources.
|
|
223
|
+
if self._http_client is not None:
|
|
224
|
+
self._http_client.close()
|
|
225
|
+
self._http_client = None
|
|
226
|
+
|
|
227
|
+
self._ready = False
|
|
228
|
+
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
# Evaluation methods (local, synchronous, zero network calls)
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def is_enabled(
|
|
234
|
+
self,
|
|
235
|
+
flag_key: str,
|
|
236
|
+
*,
|
|
237
|
+
user_id: str,
|
|
238
|
+
attributes: Optional[Dict[str, Any]] = None,
|
|
239
|
+
) -> bool:
|
|
240
|
+
"""Check if a feature flag is enabled for the given user.
|
|
241
|
+
|
|
242
|
+
This is the most common evaluation method. It evaluates the flag
|
|
243
|
+
and returns a simple boolean. If the flag is not found in the
|
|
244
|
+
rules, it returns False (safe default).
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
flag_key: The flag key to evaluate (e.g. "dark-mode").
|
|
248
|
+
user_id: Unique identifier for the end-user.
|
|
249
|
+
attributes: Optional user attributes for targeting rules.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
True if the flag is enabled, False otherwise.
|
|
253
|
+
|
|
254
|
+
Raises:
|
|
255
|
+
RuntimeError: If the SDK is not ready.
|
|
256
|
+
"""
|
|
257
|
+
result = self.evaluate_flag(
|
|
258
|
+
flag_key, user_id=user_id, attributes=attributes
|
|
259
|
+
)
|
|
260
|
+
# Coerce to boolean. The flag value could be a boolean, string,
|
|
261
|
+
# or number depending on the flag type.
|
|
262
|
+
return bool(result.value)
|
|
263
|
+
|
|
264
|
+
def evaluate_flag(
|
|
265
|
+
self,
|
|
266
|
+
flag_key: str,
|
|
267
|
+
*,
|
|
268
|
+
user_id: str,
|
|
269
|
+
attributes: Optional[Dict[str, Any]] = None,
|
|
270
|
+
) -> FlagResult:
|
|
271
|
+
"""Evaluate a single feature flag and return the full result.
|
|
272
|
+
|
|
273
|
+
Uses the optimized single-flag WASM evaluation path when available,
|
|
274
|
+
avoiding the overhead of evaluating all flags.
|
|
275
|
+
|
|
276
|
+
Returns the flag value, the reason for the result (e.g.
|
|
277
|
+
"rule_match", "default", "disabled"), and optionally the rule ID
|
|
278
|
+
that matched.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
flag_key: The flag key to evaluate.
|
|
282
|
+
user_id: Unique identifier for the end-user.
|
|
283
|
+
attributes: Optional user attributes for targeting rules.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
A FlagResult with value, reason, and optional rule_id.
|
|
287
|
+
|
|
288
|
+
Raises:
|
|
289
|
+
RuntimeError: If the SDK is not ready or WASM evaluation fails.
|
|
290
|
+
"""
|
|
291
|
+
self._ensure_ready()
|
|
292
|
+
|
|
293
|
+
context = EvalContext(user_id=user_id, attributes=attributes or {})
|
|
294
|
+
return self._run_single_evaluation(flag_key, context)
|
|
295
|
+
|
|
296
|
+
def evaluate_all(
|
|
297
|
+
self,
|
|
298
|
+
*,
|
|
299
|
+
user_id: str,
|
|
300
|
+
attributes: Optional[Dict[str, Any]] = None,
|
|
301
|
+
) -> EvalResults:
|
|
302
|
+
"""Evaluate ALL flags and tests against the given context.
|
|
303
|
+
|
|
304
|
+
Useful for bootstrapping a client-side SDK or for debugging.
|
|
305
|
+
Returns the complete evaluation results from the WASM engine.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
user_id: Unique identifier for the end-user.
|
|
309
|
+
attributes: Optional user attributes for targeting rules.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
The full EvalResults (all flags + all tests).
|
|
313
|
+
|
|
314
|
+
Raises:
|
|
315
|
+
RuntimeError: If the SDK is not ready.
|
|
316
|
+
"""
|
|
317
|
+
self._ensure_ready()
|
|
318
|
+
|
|
319
|
+
context = EvalContext(user_id=user_id, attributes=attributes or {})
|
|
320
|
+
return self._run_evaluation(context)
|
|
321
|
+
|
|
322
|
+
def get_variant(
|
|
323
|
+
self,
|
|
324
|
+
test_key: str,
|
|
325
|
+
*,
|
|
326
|
+
user_id: str,
|
|
327
|
+
attributes: Optional[Dict[str, Any]] = None,
|
|
328
|
+
) -> TestResult:
|
|
329
|
+
"""Get the variant assignment for an A/B test.
|
|
330
|
+
|
|
331
|
+
The WASM engine deterministically assigns users to variants based
|
|
332
|
+
on a hash of (user_id + test_key), so the same user always gets
|
|
333
|
+
the same variant without any server-side state.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
test_key: The A/B test key (e.g. "pricing-test").
|
|
337
|
+
user_id: Unique identifier for the end-user.
|
|
338
|
+
attributes: Optional user attributes for targeting rules.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
A TestResult with variant name and bucket number.
|
|
342
|
+
|
|
343
|
+
Raises:
|
|
344
|
+
RuntimeError: If the SDK is not ready.
|
|
345
|
+
"""
|
|
346
|
+
self._ensure_ready()
|
|
347
|
+
|
|
348
|
+
context = EvalContext(user_id=user_id, attributes=attributes or {})
|
|
349
|
+
results = self._run_evaluation(context)
|
|
350
|
+
test_result = results.tests.get(test_key)
|
|
351
|
+
|
|
352
|
+
# If the test is not in the rules, return a safe default.
|
|
353
|
+
if test_result is None:
|
|
354
|
+
return TestResult(variant="control", bucket=0)
|
|
355
|
+
|
|
356
|
+
return test_result
|
|
357
|
+
|
|
358
|
+
# ------------------------------------------------------------------
|
|
359
|
+
# Event tracking
|
|
360
|
+
# ------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
def track(
|
|
363
|
+
self,
|
|
364
|
+
event_type: str,
|
|
365
|
+
*,
|
|
366
|
+
end_user_id: str,
|
|
367
|
+
test_key: Optional[str] = None,
|
|
368
|
+
flag_key: Optional[str] = None,
|
|
369
|
+
variant_name: Optional[str] = None,
|
|
370
|
+
evaluation_reason: Optional[str] = None,
|
|
371
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
372
|
+
) -> None:
|
|
373
|
+
"""Track an analytics event.
|
|
374
|
+
|
|
375
|
+
Events are buffered and sent to the server in batches. This method
|
|
376
|
+
returns immediately (non-blocking).
|
|
377
|
+
|
|
378
|
+
Common event types:
|
|
379
|
+
- "conversion" - User completed a goal
|
|
380
|
+
- "flag_evaluation" - A flag was evaluated
|
|
381
|
+
- "variant_assignment" - User was assigned to a variant
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
event_type: The type of event to track.
|
|
385
|
+
end_user_id: The end-user this event is about.
|
|
386
|
+
test_key: The A/B test key, if relevant.
|
|
387
|
+
flag_key: The flag key, if relevant.
|
|
388
|
+
variant_name: The variant name, if relevant.
|
|
389
|
+
evaluation_reason: The reason from the WASM engine, if relevant.
|
|
390
|
+
metadata: Arbitrary metadata attached to the event.
|
|
391
|
+
"""
|
|
392
|
+
self._event_buffer.push(
|
|
393
|
+
SdkEvent(
|
|
394
|
+
event_type=event_type, # type: ignore[arg-type]
|
|
395
|
+
end_user_id=end_user_id,
|
|
396
|
+
test_key=test_key,
|
|
397
|
+
flag_key=flag_key,
|
|
398
|
+
variant_name=variant_name,
|
|
399
|
+
evaluation_reason=evaluation_reason,
|
|
400
|
+
metadata=metadata or {},
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
# ------------------------------------------------------------------
|
|
405
|
+
# Listener management
|
|
406
|
+
# ------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
def on_rules_update(
|
|
409
|
+
self, listener: RulesUpdateListener
|
|
410
|
+
) -> Callable[[], None]:
|
|
411
|
+
"""Register a listener for rules updates.
|
|
412
|
+
|
|
413
|
+
The listener is called with the new version hash whenever the
|
|
414
|
+
rules are re-fetched from the server. Useful for logging or
|
|
415
|
+
triggering re-evaluation of cached results.
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
listener: Callback receiving the new rules version hash.
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
An unsubscribe function.
|
|
422
|
+
"""
|
|
423
|
+
return self._rules_store.on_update(listener)
|
|
424
|
+
|
|
425
|
+
# ------------------------------------------------------------------
|
|
426
|
+
# Private helpers
|
|
427
|
+
# ------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
def _fetch_wasm_binary(self) -> bytes:
|
|
430
|
+
"""Fetch the WASM evaluator binary from the server.
|
|
431
|
+
|
|
432
|
+
Uses x-api-key header for authentication.
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
The raw WASM bytes.
|
|
436
|
+
|
|
437
|
+
Raises:
|
|
438
|
+
httpx.HTTPStatusError: On non-2xx responses.
|
|
439
|
+
httpx.HTTPError: On network errors.
|
|
440
|
+
"""
|
|
441
|
+
headers: Dict[str, str] = {
|
|
442
|
+
"x-api-key": self._api_key,
|
|
443
|
+
}
|
|
444
|
+
if self._environment:
|
|
445
|
+
headers["x-environment"] = self._environment
|
|
446
|
+
|
|
447
|
+
client = self._http_client or httpx.Client(
|
|
448
|
+
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0)
|
|
449
|
+
)
|
|
450
|
+
resp = client.get(
|
|
451
|
+
f"{self._base_url}/sdk/evaluator.wasm",
|
|
452
|
+
headers=headers,
|
|
453
|
+
timeout=30.0, # WASM binary may be large, allow more time.
|
|
454
|
+
)
|
|
455
|
+
resp.raise_for_status()
|
|
456
|
+
return resp.content
|
|
457
|
+
|
|
458
|
+
def _run_evaluation(self, context: EvalContext) -> EvalResults:
|
|
459
|
+
"""Run the WASM evaluation with the cached rules and given context.
|
|
460
|
+
|
|
461
|
+
Encapsulates the interaction between RulesStore and WasmEngine.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
context: The user context for evaluation.
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
The full evaluation results.
|
|
468
|
+
|
|
469
|
+
Raises:
|
|
470
|
+
RuntimeError: If rules are not available.
|
|
471
|
+
"""
|
|
472
|
+
rules_json = self._rules_store.get_rules_json()
|
|
473
|
+
|
|
474
|
+
if rules_json is None:
|
|
475
|
+
raise RuntimeError(
|
|
476
|
+
"Rules not available. Has start() been called?"
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
return self._engine.evaluate(rules_json, context)
|
|
480
|
+
|
|
481
|
+
def _run_single_evaluation(
|
|
482
|
+
self, flag_key: str, context: EvalContext
|
|
483
|
+
) -> FlagResult:
|
|
484
|
+
"""Run a single-flag WASM evaluation with the cached rules.
|
|
485
|
+
|
|
486
|
+
Uses ``WasmEngine.evaluate_single()`` which automatically selects
|
|
487
|
+
the best available WASM path (fast-path, one-shot, or fallback to
|
|
488
|
+
full evaluation).
|
|
489
|
+
|
|
490
|
+
Args:
|
|
491
|
+
flag_key: The flag key to evaluate.
|
|
492
|
+
context: The user context for evaluation.
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
A FlagResult with value, reason, and optional rule_id.
|
|
496
|
+
|
|
497
|
+
Raises:
|
|
498
|
+
RuntimeError: If rules are not available.
|
|
499
|
+
"""
|
|
500
|
+
rules_json = self._rules_store.get_rules_json()
|
|
501
|
+
|
|
502
|
+
if rules_json is None:
|
|
503
|
+
raise RuntimeError(
|
|
504
|
+
"Rules not available. Has start() been called?"
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
return self._engine.evaluate_single(rules_json, flag_key, context)
|
|
508
|
+
|
|
509
|
+
def _ensure_ready(self) -> None:
|
|
510
|
+
"""Guard that throws if the SDK has not been initialized.
|
|
511
|
+
|
|
512
|
+
Called at the top of every public evaluation method.
|
|
513
|
+
|
|
514
|
+
Raises:
|
|
515
|
+
RuntimeError: If start() has not been called or has not completed.
|
|
516
|
+
"""
|
|
517
|
+
if not self._ready:
|
|
518
|
+
raise RuntimeError(
|
|
519
|
+
"ToggleTestClient is not ready. Call client.start() first."
|
|
520
|
+
)
|