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