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
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wasm_engine.py - WASM evaluation engine for ToggleTest (wasmtime-py)
|
|
3
|
+
|
|
4
|
+
Architecture overview:
|
|
5
|
+
1. The WASM binary (evaluator.wasm) is fetched once from the server on start.
|
|
6
|
+
2. The engine exposes an ``evaluate()`` method that writes the rules JSON and
|
|
7
|
+
evaluation context into WASM linear memory, calls the evaluator, and
|
|
8
|
+
reads the result back out.
|
|
9
|
+
3. Memory management uses the WASM module's own allocator (``tt_alloc`` /
|
|
10
|
+
``tt_dealloc``) so there is no need for wasm-bindgen or any glue code.
|
|
11
|
+
|
|
12
|
+
Two evaluation paths are supported for **all flags**:
|
|
13
|
+
- **One-shot** (``tt_evaluate``): Parses rules + context, evaluates. Always available.
|
|
14
|
+
- **Fast path** (``tt_set_rules`` + ``tt_evaluate_ctx``): Pre-sorts targeting rules
|
|
15
|
+
and pre-computes topological order once per instance, then evaluates with
|
|
16
|
+
context-only parsing. Used automatically when the WASM binary includes the
|
|
17
|
+
newer exports. Even though Python creates a fresh instance per call, the
|
|
18
|
+
fast path avoids per-flag clone+sort overhead within each evaluation.
|
|
19
|
+
|
|
20
|
+
Two evaluation paths are supported for **single flag** evaluation:
|
|
21
|
+
- **One-shot** (``tt_evaluate_single``): Parses rules + flag key + context in one call.
|
|
22
|
+
- **Fast path** (``tt_set_rules`` + ``tt_evaluate_single_ctx``): Cache rules first,
|
|
23
|
+
then evaluate a single flag with flag key + context only.
|
|
24
|
+
|
|
25
|
+
WASM interface (raw exports, no wasm-bindgen):
|
|
26
|
+
- ``tt_alloc(size: i32) -> i32`` -- allocate *size* bytes, return pointer
|
|
27
|
+
- ``tt_dealloc(ptr: i32, size: i32)`` -- free *size* bytes at *ptr*
|
|
28
|
+
- ``tt_evaluate(rules_ptr, rules_len, ctx_ptr, ctx_len) -> i32``
|
|
29
|
+
Returns a pointer to: [u32 LE length][JSON payload bytes]
|
|
30
|
+
- ``tt_set_rules(rules_ptr, rules_len) -> i32`` (optional)
|
|
31
|
+
Cache parsed rules. Returns 0=ok, 1=error.
|
|
32
|
+
- ``tt_evaluate_ctx(ctx_ptr, ctx_len) -> i32`` (optional)
|
|
33
|
+
Evaluate using cached rules. Same result format as tt_evaluate.
|
|
34
|
+
- ``tt_evaluate_single(rules_ptr, rules_len, flag_key_ptr, flag_key_len, ctx_ptr, ctx_len) -> i32`` (optional)
|
|
35
|
+
Evaluate a single flag. Returns [u32 LE length][FlagResult JSON].
|
|
36
|
+
- ``tt_evaluate_single_ctx(flag_key_ptr, flag_key_len, ctx_ptr, ctx_len) -> i32`` (optional)
|
|
37
|
+
Evaluate a single flag using cached rules. Same result format.
|
|
38
|
+
|
|
39
|
+
This module uses **wasmtime-py** as the WASM runtime. wasmtime compiles
|
|
40
|
+
the WASM ahead-of-time to native code, giving near-native evaluation speed.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import json
|
|
46
|
+
import struct
|
|
47
|
+
from typing import Optional
|
|
48
|
+
|
|
49
|
+
import wasmtime
|
|
50
|
+
|
|
51
|
+
from .types import EvalContext, EvalResults, FlagResult, TestResult
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class WasmEngine:
|
|
55
|
+
"""Loads and runs the ToggleTest WASM evaluator binary via wasmtime.
|
|
56
|
+
|
|
57
|
+
Thread-safety: a single WasmEngine instance can be called from multiple
|
|
58
|
+
threads because wasmtime Stores are cheap to create and each ``evaluate``
|
|
59
|
+
call creates its own Store + Instance. However, the ``load`` method must
|
|
60
|
+
be called exactly once before any ``evaluate`` calls.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
# The compiled WASM module. Set by load(), None until then.
|
|
65
|
+
self._module: Optional[wasmtime.Module] = None
|
|
66
|
+
|
|
67
|
+
# The wasmtime Engine is reusable and thread-safe. It holds compiled
|
|
68
|
+
# code caches and configuration shared across all instantiations.
|
|
69
|
+
self._engine: wasmtime.Engine = wasmtime.Engine()
|
|
70
|
+
|
|
71
|
+
# Whether the WASM binary supports the fast-path exports.
|
|
72
|
+
self._has_fast_path: bool = False
|
|
73
|
+
|
|
74
|
+
# Whether the WASM binary supports single-flag evaluation exports.
|
|
75
|
+
self._has_single_flag: bool = False
|
|
76
|
+
|
|
77
|
+
# TODO: Add WASM binary integrity verification (Content-Digest header or SHA-256 checksum)
|
|
78
|
+
def load(self, wasm_bytes: bytes) -> None:
|
|
79
|
+
"""Compile and validate the WASM evaluator binary.
|
|
80
|
+
|
|
81
|
+
This compiles the WASM bytecode into native machine code via wasmtime.
|
|
82
|
+
The compilation result is cached in ``self._module`` and reused for
|
|
83
|
+
every subsequent ``evaluate`` call (instantiation is cheap).
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
wasm_bytes: The raw .wasm binary fetched from GET /sdk/evaluator.wasm.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
wasmtime.WasmtimeError: If the binary is invalid or missing
|
|
90
|
+
required exports (tt_alloc, tt_dealloc, tt_evaluate, memory).
|
|
91
|
+
"""
|
|
92
|
+
# Compile the WASM bytes into a reusable Module.
|
|
93
|
+
self._module = wasmtime.Module(self._engine, wasm_bytes)
|
|
94
|
+
|
|
95
|
+
# Sanity-check: verify the module exports the functions we need.
|
|
96
|
+
# wasmtime.Module.exports is a list of ExportType objects.
|
|
97
|
+
export_names = {exp.name for exp in self._module.exports}
|
|
98
|
+
required = {"tt_alloc", "tt_dealloc", "tt_evaluate", "memory"}
|
|
99
|
+
missing = required - export_names
|
|
100
|
+
if missing:
|
|
101
|
+
raise RuntimeError(
|
|
102
|
+
f"WASM module is missing required exports: {missing}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Detect optional fast-path exports (backward-compatible with older WASM binaries).
|
|
106
|
+
self._has_fast_path = (
|
|
107
|
+
"tt_set_rules" in export_names and "tt_evaluate_ctx" in export_names
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Detect optional single-flag evaluation exports.
|
|
111
|
+
self._has_single_flag = (
|
|
112
|
+
"tt_evaluate_single" in export_names and "tt_evaluate_single_ctx" in export_names
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def loaded(self) -> bool:
|
|
117
|
+
"""Return True if the WASM module has been loaded and is ready."""
|
|
118
|
+
return self._module is not None
|
|
119
|
+
|
|
120
|
+
def evaluate(self, rules_json: str, context: EvalContext) -> EvalResults:
|
|
121
|
+
"""Evaluate all rules against the given context via WASM.
|
|
122
|
+
|
|
123
|
+
If the WASM binary supports the fast-path exports (tt_set_rules /
|
|
124
|
+
tt_evaluate_ctx), rules are pre-parsed and cached in the instance
|
|
125
|
+
before evaluation. This avoids per-flag clone+sort overhead.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
rules_json: The full rules JSON string (cached by RulesStore).
|
|
129
|
+
context: The evaluation context for the current user/request.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The full evaluation results (all flags + all tests).
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
RuntimeError: If the WASM module is not loaded or evaluation fails.
|
|
136
|
+
"""
|
|
137
|
+
if self._module is None:
|
|
138
|
+
raise RuntimeError("WASM engine not loaded. Call load() first.")
|
|
139
|
+
|
|
140
|
+
# -- Step 1: Encode inputs as UTF-8 bytes --
|
|
141
|
+
rules_bytes = rules_json.encode("utf-8")
|
|
142
|
+
context_bytes = json.dumps(
|
|
143
|
+
{"user_id": context.user_id, "attributes": context.attributes or {}}
|
|
144
|
+
).encode("utf-8")
|
|
145
|
+
|
|
146
|
+
# -- Step 2: Create a fresh Store + Instance --
|
|
147
|
+
# Each evaluation gets its own Store so there is no shared mutable
|
|
148
|
+
# state between concurrent calls. wasmtime Instances are lightweight.
|
|
149
|
+
store = wasmtime.Store(self._engine)
|
|
150
|
+
instance = wasmtime.Instance(store, self._module, [])
|
|
151
|
+
|
|
152
|
+
# Extract the exports we need.
|
|
153
|
+
exports = instance.exports(store)
|
|
154
|
+
tt_alloc = exports["tt_alloc"]
|
|
155
|
+
tt_dealloc = exports["tt_dealloc"]
|
|
156
|
+
memory = exports["memory"]
|
|
157
|
+
|
|
158
|
+
# Type-narrow: wasmtime exports can be Func, Memory, Global, or Table.
|
|
159
|
+
if not isinstance(tt_alloc, wasmtime.Func):
|
|
160
|
+
raise TypeError(f"Expected wasmtime.Func for tt_alloc, got {type(tt_alloc)}")
|
|
161
|
+
if not isinstance(tt_dealloc, wasmtime.Func):
|
|
162
|
+
raise TypeError(f"Expected wasmtime.Func for tt_dealloc, got {type(tt_dealloc)}")
|
|
163
|
+
if not isinstance(memory, wasmtime.Memory):
|
|
164
|
+
raise TypeError(f"Expected wasmtime.Memory for memory, got {type(memory)}")
|
|
165
|
+
|
|
166
|
+
# Try the fast path if available.
|
|
167
|
+
if self._has_fast_path:
|
|
168
|
+
tt_set_rules = exports.get("tt_set_rules")
|
|
169
|
+
tt_evaluate_ctx = exports.get("tt_evaluate_ctx")
|
|
170
|
+
if isinstance(tt_set_rules, wasmtime.Func) and isinstance(tt_evaluate_ctx, wasmtime.Func):
|
|
171
|
+
return self._evaluate_fast_path(
|
|
172
|
+
store, tt_alloc, tt_dealloc, tt_set_rules, tt_evaluate_ctx,
|
|
173
|
+
memory, rules_bytes, context_bytes,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Fall back to one-shot path.
|
|
177
|
+
tt_evaluate = exports["tt_evaluate"]
|
|
178
|
+
if not isinstance(tt_evaluate, wasmtime.Func):
|
|
179
|
+
raise TypeError(f"Expected wasmtime.Func for tt_evaluate, got {type(tt_evaluate)}")
|
|
180
|
+
|
|
181
|
+
return self._evaluate_one_shot(
|
|
182
|
+
store, tt_alloc, tt_dealloc, tt_evaluate,
|
|
183
|
+
memory, rules_bytes, context_bytes,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def _evaluate_fast_path(
|
|
187
|
+
self,
|
|
188
|
+
store: wasmtime.Store,
|
|
189
|
+
tt_alloc: wasmtime.Func,
|
|
190
|
+
tt_dealloc: wasmtime.Func,
|
|
191
|
+
tt_set_rules: wasmtime.Func,
|
|
192
|
+
tt_evaluate_ctx: wasmtime.Func,
|
|
193
|
+
memory: wasmtime.Memory,
|
|
194
|
+
rules_bytes: bytes,
|
|
195
|
+
context_bytes: bytes,
|
|
196
|
+
) -> EvalResults:
|
|
197
|
+
"""Fast path: cache rules in WASM, then evaluate with context only."""
|
|
198
|
+
rules_ptr = 0
|
|
199
|
+
ctx_ptr = 0
|
|
200
|
+
result_ptr = 0
|
|
201
|
+
result_total_size = 0
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
# -- Cache rules in the WASM instance --
|
|
205
|
+
rules_ptr = tt_alloc(store, len(rules_bytes))
|
|
206
|
+
mem_data = memory.data_ptr(store)
|
|
207
|
+
mem_len = memory.data_len(store)
|
|
208
|
+
_write_to_memory(mem_data, mem_len, rules_ptr, rules_bytes)
|
|
209
|
+
|
|
210
|
+
set_result = tt_set_rules(store, rules_ptr, len(rules_bytes))
|
|
211
|
+
|
|
212
|
+
# Free the rules buffer (WASM has its own internal copy now).
|
|
213
|
+
tt_dealloc(store, rules_ptr, len(rules_bytes))
|
|
214
|
+
rules_ptr = 0 # Prevent double-free in finally.
|
|
215
|
+
|
|
216
|
+
if set_result != 0:
|
|
217
|
+
raise RuntimeError("tt_set_rules failed (parse error)")
|
|
218
|
+
|
|
219
|
+
# -- Evaluate with context only --
|
|
220
|
+
ctx_ptr = tt_alloc(store, len(context_bytes))
|
|
221
|
+
mem_data = memory.data_ptr(store)
|
|
222
|
+
mem_len = memory.data_len(store)
|
|
223
|
+
_write_to_memory(mem_data, mem_len, ctx_ptr, context_bytes)
|
|
224
|
+
|
|
225
|
+
result_ptr = tt_evaluate_ctx(store, ctx_ptr, len(context_bytes))
|
|
226
|
+
|
|
227
|
+
# -- Read the result --
|
|
228
|
+
mem_data = memory.data_ptr(store)
|
|
229
|
+
mem_len = memory.data_len(store)
|
|
230
|
+
|
|
231
|
+
length_bytes = _read_from_memory(mem_data, mem_len, result_ptr, 4)
|
|
232
|
+
result_len = struct.unpack("<I", length_bytes)[0]
|
|
233
|
+
|
|
234
|
+
MAX_RESULT_SIZE = 10 * 1024 * 1024 # 10MB
|
|
235
|
+
if result_len > MAX_RESULT_SIZE:
|
|
236
|
+
raise ValueError(f"WASM result size {result_len} exceeds maximum {MAX_RESULT_SIZE}")
|
|
237
|
+
|
|
238
|
+
result_total_size = 4 + result_len
|
|
239
|
+
result_bytes = _read_from_memory(
|
|
240
|
+
mem_data, mem_len, result_ptr + 4, result_len
|
|
241
|
+
)
|
|
242
|
+
result_json_str = result_bytes.decode("utf-8")
|
|
243
|
+
|
|
244
|
+
return _parse_eval_results(json.loads(result_json_str))
|
|
245
|
+
|
|
246
|
+
finally:
|
|
247
|
+
if rules_ptr:
|
|
248
|
+
tt_dealloc(store, rules_ptr, len(rules_bytes))
|
|
249
|
+
if ctx_ptr:
|
|
250
|
+
tt_dealloc(store, ctx_ptr, len(context_bytes))
|
|
251
|
+
if result_ptr and result_total_size > 0:
|
|
252
|
+
tt_dealloc(store, result_ptr, result_total_size)
|
|
253
|
+
|
|
254
|
+
def _evaluate_one_shot(
|
|
255
|
+
self,
|
|
256
|
+
store: wasmtime.Store,
|
|
257
|
+
tt_alloc: wasmtime.Func,
|
|
258
|
+
tt_dealloc: wasmtime.Func,
|
|
259
|
+
tt_evaluate: wasmtime.Func,
|
|
260
|
+
memory: wasmtime.Memory,
|
|
261
|
+
rules_bytes: bytes,
|
|
262
|
+
context_bytes: bytes,
|
|
263
|
+
) -> EvalResults:
|
|
264
|
+
"""One-shot path: send both rules and context to tt_evaluate."""
|
|
265
|
+
rules_ptr = 0
|
|
266
|
+
ctx_ptr = 0
|
|
267
|
+
result_ptr = 0
|
|
268
|
+
result_total_size = 0
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
# -- Allocate and write input buffers --
|
|
272
|
+
rules_ptr = tt_alloc(store, len(rules_bytes))
|
|
273
|
+
ctx_ptr = tt_alloc(store, len(context_bytes))
|
|
274
|
+
|
|
275
|
+
mem_data = memory.data_ptr(store)
|
|
276
|
+
mem_len = memory.data_len(store)
|
|
277
|
+
_write_to_memory(mem_data, mem_len, rules_ptr, rules_bytes)
|
|
278
|
+
_write_to_memory(mem_data, mem_len, ctx_ptr, context_bytes)
|
|
279
|
+
|
|
280
|
+
# -- Call the WASM evaluator --
|
|
281
|
+
result_ptr = tt_evaluate(
|
|
282
|
+
store,
|
|
283
|
+
rules_ptr,
|
|
284
|
+
len(rules_bytes),
|
|
285
|
+
ctx_ptr,
|
|
286
|
+
len(context_bytes),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# -- Read the result --
|
|
290
|
+
mem_data = memory.data_ptr(store)
|
|
291
|
+
mem_len = memory.data_len(store)
|
|
292
|
+
|
|
293
|
+
length_bytes = _read_from_memory(mem_data, mem_len, result_ptr, 4)
|
|
294
|
+
result_len = struct.unpack("<I", length_bytes)[0]
|
|
295
|
+
|
|
296
|
+
MAX_RESULT_SIZE = 10 * 1024 * 1024 # 10MB
|
|
297
|
+
if result_len > MAX_RESULT_SIZE:
|
|
298
|
+
raise ValueError(f"WASM result size {result_len} exceeds maximum {MAX_RESULT_SIZE}")
|
|
299
|
+
|
|
300
|
+
result_total_size = 4 + result_len
|
|
301
|
+
result_bytes = _read_from_memory(
|
|
302
|
+
mem_data, mem_len, result_ptr + 4, result_len
|
|
303
|
+
)
|
|
304
|
+
result_json_str = result_bytes.decode("utf-8")
|
|
305
|
+
|
|
306
|
+
return _parse_eval_results(json.loads(result_json_str))
|
|
307
|
+
|
|
308
|
+
finally:
|
|
309
|
+
if rules_ptr:
|
|
310
|
+
tt_dealloc(store, rules_ptr, len(rules_bytes))
|
|
311
|
+
if ctx_ptr:
|
|
312
|
+
tt_dealloc(store, ctx_ptr, len(context_bytes))
|
|
313
|
+
if result_ptr and result_total_size > 0:
|
|
314
|
+
tt_dealloc(store, result_ptr, result_total_size)
|
|
315
|
+
|
|
316
|
+
def evaluate_single(
|
|
317
|
+
self, rules_json: str, flag_key: str, context: EvalContext
|
|
318
|
+
) -> FlagResult:
|
|
319
|
+
"""Evaluate a single flag against the given context via WASM.
|
|
320
|
+
|
|
321
|
+
When the WASM binary supports single-flag exports, this avoids
|
|
322
|
+
evaluating *all* flags and parsing the full result set. Falls back
|
|
323
|
+
to the full ``evaluate()`` method on older WASM binaries.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
rules_json: The full rules JSON string (cached by RulesStore).
|
|
327
|
+
flag_key: The flag key to evaluate (e.g. "dark-mode").
|
|
328
|
+
context: The evaluation context for the current user/request.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
A FlagResult with value, reason, and optional rule_id.
|
|
332
|
+
|
|
333
|
+
Raises:
|
|
334
|
+
RuntimeError: If the WASM module is not loaded or evaluation fails.
|
|
335
|
+
"""
|
|
336
|
+
if self._module is None:
|
|
337
|
+
raise RuntimeError("WASM engine not loaded. Call load() first.")
|
|
338
|
+
|
|
339
|
+
# -- Step 1: Encode inputs as UTF-8 bytes --
|
|
340
|
+
rules_bytes = rules_json.encode("utf-8")
|
|
341
|
+
flag_key_bytes = flag_key.encode("utf-8")
|
|
342
|
+
context_bytes = json.dumps(
|
|
343
|
+
{"user_id": context.user_id, "attributes": context.attributes or {}}
|
|
344
|
+
).encode("utf-8")
|
|
345
|
+
|
|
346
|
+
# -- Step 2: Create a fresh Store + Instance --
|
|
347
|
+
store = wasmtime.Store(self._engine)
|
|
348
|
+
instance = wasmtime.Instance(store, self._module, [])
|
|
349
|
+
|
|
350
|
+
# Extract the exports we need.
|
|
351
|
+
exports = instance.exports(store)
|
|
352
|
+
tt_alloc = exports["tt_alloc"]
|
|
353
|
+
tt_dealloc = exports["tt_dealloc"]
|
|
354
|
+
memory = exports["memory"]
|
|
355
|
+
|
|
356
|
+
# Type-narrow: wasmtime exports can be Func, Memory, Global, or Table.
|
|
357
|
+
if not isinstance(tt_alloc, wasmtime.Func):
|
|
358
|
+
raise TypeError(f"Expected wasmtime.Func for tt_alloc, got {type(tt_alloc)}")
|
|
359
|
+
if not isinstance(tt_dealloc, wasmtime.Func):
|
|
360
|
+
raise TypeError(f"Expected wasmtime.Func for tt_dealloc, got {type(tt_dealloc)}")
|
|
361
|
+
if not isinstance(memory, wasmtime.Memory):
|
|
362
|
+
raise TypeError(f"Expected wasmtime.Memory for memory, got {type(memory)}")
|
|
363
|
+
|
|
364
|
+
# -- Step 3: Choose the best evaluation path --
|
|
365
|
+
|
|
366
|
+
# Prefer the fast path (cached rules + single flag eval) when available.
|
|
367
|
+
if self._has_fast_path and self._has_single_flag:
|
|
368
|
+
tt_set_rules = exports.get("tt_set_rules")
|
|
369
|
+
tt_evaluate_single_ctx = exports.get("tt_evaluate_single_ctx")
|
|
370
|
+
if isinstance(tt_set_rules, wasmtime.Func) and isinstance(tt_evaluate_single_ctx, wasmtime.Func):
|
|
371
|
+
return self._evaluate_single_fast_path(
|
|
372
|
+
store, tt_alloc, tt_dealloc, tt_set_rules,
|
|
373
|
+
tt_evaluate_single_ctx, memory,
|
|
374
|
+
rules_bytes, flag_key_bytes, context_bytes,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# One-shot single-flag eval (no cached rules).
|
|
378
|
+
if self._has_single_flag:
|
|
379
|
+
tt_evaluate_single = exports.get("tt_evaluate_single")
|
|
380
|
+
if isinstance(tt_evaluate_single, wasmtime.Func):
|
|
381
|
+
return self._evaluate_single_one_shot(
|
|
382
|
+
store, tt_alloc, tt_dealloc, tt_evaluate_single,
|
|
383
|
+
memory, rules_bytes, flag_key_bytes, context_bytes,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# Fall back to full evaluation and extract the single flag.
|
|
387
|
+
results = self.evaluate(rules_json, context)
|
|
388
|
+
flag_result = results.flags.get(flag_key)
|
|
389
|
+
if flag_result is None:
|
|
390
|
+
return FlagResult(value=False, reason="not_found")
|
|
391
|
+
return flag_result
|
|
392
|
+
|
|
393
|
+
def _evaluate_single_fast_path(
|
|
394
|
+
self,
|
|
395
|
+
store: wasmtime.Store,
|
|
396
|
+
tt_alloc: wasmtime.Func,
|
|
397
|
+
tt_dealloc: wasmtime.Func,
|
|
398
|
+
tt_set_rules: wasmtime.Func,
|
|
399
|
+
tt_evaluate_single_ctx: wasmtime.Func,
|
|
400
|
+
memory: wasmtime.Memory,
|
|
401
|
+
rules_bytes: bytes,
|
|
402
|
+
flag_key_bytes: bytes,
|
|
403
|
+
context_bytes: bytes,
|
|
404
|
+
) -> FlagResult:
|
|
405
|
+
"""Fast path: cache rules, then evaluate a single flag with context."""
|
|
406
|
+
rules_ptr = 0
|
|
407
|
+
flag_key_ptr = 0
|
|
408
|
+
ctx_ptr = 0
|
|
409
|
+
result_ptr = 0
|
|
410
|
+
result_total_size = 0
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
# -- Cache rules in the WASM instance --
|
|
414
|
+
rules_ptr = tt_alloc(store, len(rules_bytes))
|
|
415
|
+
mem_data = memory.data_ptr(store)
|
|
416
|
+
mem_len = memory.data_len(store)
|
|
417
|
+
_write_to_memory(mem_data, mem_len, rules_ptr, rules_bytes)
|
|
418
|
+
|
|
419
|
+
set_result = tt_set_rules(store, rules_ptr, len(rules_bytes))
|
|
420
|
+
|
|
421
|
+
# Free the rules buffer (WASM has its own internal copy now).
|
|
422
|
+
tt_dealloc(store, rules_ptr, len(rules_bytes))
|
|
423
|
+
rules_ptr = 0 # Prevent double-free in finally.
|
|
424
|
+
|
|
425
|
+
if set_result != 0:
|
|
426
|
+
raise RuntimeError("tt_set_rules failed (parse error)")
|
|
427
|
+
|
|
428
|
+
# -- Allocate and write flag key + context --
|
|
429
|
+
flag_key_ptr = tt_alloc(store, len(flag_key_bytes))
|
|
430
|
+
ctx_ptr = tt_alloc(store, len(context_bytes))
|
|
431
|
+
|
|
432
|
+
mem_data = memory.data_ptr(store)
|
|
433
|
+
mem_len = memory.data_len(store)
|
|
434
|
+
_write_to_memory(mem_data, mem_len, flag_key_ptr, flag_key_bytes)
|
|
435
|
+
_write_to_memory(mem_data, mem_len, ctx_ptr, context_bytes)
|
|
436
|
+
|
|
437
|
+
# -- Call single-flag evaluation --
|
|
438
|
+
result_ptr = tt_evaluate_single_ctx(
|
|
439
|
+
store,
|
|
440
|
+
flag_key_ptr, len(flag_key_bytes),
|
|
441
|
+
ctx_ptr, len(context_bytes),
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# -- Read the result --
|
|
445
|
+
mem_data = memory.data_ptr(store)
|
|
446
|
+
mem_len = memory.data_len(store)
|
|
447
|
+
|
|
448
|
+
length_bytes = _read_from_memory(mem_data, mem_len, result_ptr, 4)
|
|
449
|
+
result_len = struct.unpack("<I", length_bytes)[0]
|
|
450
|
+
|
|
451
|
+
MAX_RESULT_SIZE = 10 * 1024 * 1024 # 10MB
|
|
452
|
+
if result_len > MAX_RESULT_SIZE:
|
|
453
|
+
raise ValueError(f"WASM result size {result_len} exceeds maximum {MAX_RESULT_SIZE}")
|
|
454
|
+
|
|
455
|
+
result_total_size = 4 + result_len
|
|
456
|
+
result_bytes = _read_from_memory(
|
|
457
|
+
mem_data, mem_len, result_ptr + 4, result_len
|
|
458
|
+
)
|
|
459
|
+
result_json_str = result_bytes.decode("utf-8")
|
|
460
|
+
|
|
461
|
+
return _parse_flag_result(json.loads(result_json_str))
|
|
462
|
+
|
|
463
|
+
finally:
|
|
464
|
+
if rules_ptr:
|
|
465
|
+
tt_dealloc(store, rules_ptr, len(rules_bytes))
|
|
466
|
+
if flag_key_ptr:
|
|
467
|
+
tt_dealloc(store, flag_key_ptr, len(flag_key_bytes))
|
|
468
|
+
if ctx_ptr:
|
|
469
|
+
tt_dealloc(store, ctx_ptr, len(context_bytes))
|
|
470
|
+
if result_ptr and result_total_size > 0:
|
|
471
|
+
tt_dealloc(store, result_ptr, result_total_size)
|
|
472
|
+
|
|
473
|
+
def _evaluate_single_one_shot(
|
|
474
|
+
self,
|
|
475
|
+
store: wasmtime.Store,
|
|
476
|
+
tt_alloc: wasmtime.Func,
|
|
477
|
+
tt_dealloc: wasmtime.Func,
|
|
478
|
+
tt_evaluate_single: wasmtime.Func,
|
|
479
|
+
memory: wasmtime.Memory,
|
|
480
|
+
rules_bytes: bytes,
|
|
481
|
+
flag_key_bytes: bytes,
|
|
482
|
+
context_bytes: bytes,
|
|
483
|
+
) -> FlagResult:
|
|
484
|
+
"""One-shot path: send rules + flag key + context to tt_evaluate_single."""
|
|
485
|
+
rules_ptr = 0
|
|
486
|
+
flag_key_ptr = 0
|
|
487
|
+
ctx_ptr = 0
|
|
488
|
+
result_ptr = 0
|
|
489
|
+
result_total_size = 0
|
|
490
|
+
|
|
491
|
+
try:
|
|
492
|
+
# -- Allocate and write input buffers --
|
|
493
|
+
rules_ptr = tt_alloc(store, len(rules_bytes))
|
|
494
|
+
flag_key_ptr = tt_alloc(store, len(flag_key_bytes))
|
|
495
|
+
ctx_ptr = tt_alloc(store, len(context_bytes))
|
|
496
|
+
|
|
497
|
+
mem_data = memory.data_ptr(store)
|
|
498
|
+
mem_len = memory.data_len(store)
|
|
499
|
+
_write_to_memory(mem_data, mem_len, rules_ptr, rules_bytes)
|
|
500
|
+
_write_to_memory(mem_data, mem_len, flag_key_ptr, flag_key_bytes)
|
|
501
|
+
_write_to_memory(mem_data, mem_len, ctx_ptr, context_bytes)
|
|
502
|
+
|
|
503
|
+
# -- Call the WASM single-flag evaluator --
|
|
504
|
+
result_ptr = tt_evaluate_single(
|
|
505
|
+
store,
|
|
506
|
+
rules_ptr, len(rules_bytes),
|
|
507
|
+
flag_key_ptr, len(flag_key_bytes),
|
|
508
|
+
ctx_ptr, len(context_bytes),
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# -- Read the result --
|
|
512
|
+
mem_data = memory.data_ptr(store)
|
|
513
|
+
mem_len = memory.data_len(store)
|
|
514
|
+
|
|
515
|
+
length_bytes = _read_from_memory(mem_data, mem_len, result_ptr, 4)
|
|
516
|
+
result_len = struct.unpack("<I", length_bytes)[0]
|
|
517
|
+
|
|
518
|
+
MAX_RESULT_SIZE = 10 * 1024 * 1024 # 10MB
|
|
519
|
+
if result_len > MAX_RESULT_SIZE:
|
|
520
|
+
raise ValueError(f"WASM result size {result_len} exceeds maximum {MAX_RESULT_SIZE}")
|
|
521
|
+
|
|
522
|
+
result_total_size = 4 + result_len
|
|
523
|
+
result_bytes = _read_from_memory(
|
|
524
|
+
mem_data, mem_len, result_ptr + 4, result_len
|
|
525
|
+
)
|
|
526
|
+
result_json_str = result_bytes.decode("utf-8")
|
|
527
|
+
|
|
528
|
+
return _parse_flag_result(json.loads(result_json_str))
|
|
529
|
+
|
|
530
|
+
finally:
|
|
531
|
+
if rules_ptr:
|
|
532
|
+
tt_dealloc(store, rules_ptr, len(rules_bytes))
|
|
533
|
+
if flag_key_ptr:
|
|
534
|
+
tt_dealloc(store, flag_key_ptr, len(flag_key_bytes))
|
|
535
|
+
if ctx_ptr:
|
|
536
|
+
tt_dealloc(store, ctx_ptr, len(context_bytes))
|
|
537
|
+
if result_ptr and result_total_size > 0:
|
|
538
|
+
tt_dealloc(store, result_ptr, result_total_size)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# ---------------------------------------------------------------------------
|
|
542
|
+
# Memory helpers (ctypes-based access to WASM linear memory)
|
|
543
|
+
# ---------------------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _write_to_memory(
|
|
547
|
+
mem_data: "ctypes pointer",
|
|
548
|
+
mem_len: int,
|
|
549
|
+
offset: int,
|
|
550
|
+
data: bytes,
|
|
551
|
+
) -> None:
|
|
552
|
+
"""Write ``data`` bytes into WASM linear memory at ``offset``.
|
|
553
|
+
|
|
554
|
+
Uses ctypes to directly copy bytes into the raw memory pointer exposed
|
|
555
|
+
by wasmtime. This avoids Python-level byte-by-byte copies.
|
|
556
|
+
|
|
557
|
+
Args:
|
|
558
|
+
mem_data: The raw ctypes pointer from memory.data_ptr(store).
|
|
559
|
+
mem_len: Total length of the WASM linear memory in bytes.
|
|
560
|
+
offset: Byte offset within the memory to start writing.
|
|
561
|
+
data: The bytes to write.
|
|
562
|
+
|
|
563
|
+
Raises:
|
|
564
|
+
RuntimeError: If the write would exceed the memory bounds.
|
|
565
|
+
"""
|
|
566
|
+
import ctypes
|
|
567
|
+
|
|
568
|
+
if offset + len(data) > mem_len:
|
|
569
|
+
raise RuntimeError(
|
|
570
|
+
f"WASM memory write out of bounds: offset={offset}, "
|
|
571
|
+
f"len={len(data)}, mem_len={mem_len}"
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
# Create a ctypes array type for the data length, then copy bytes in.
|
|
575
|
+
src = (ctypes.c_ubyte * len(data)).from_buffer_copy(data)
|
|
576
|
+
ctypes.memmove(
|
|
577
|
+
ctypes.cast(mem_data, ctypes.POINTER(ctypes.c_ubyte * mem_len))[0][offset:], # noqa: E501
|
|
578
|
+
src,
|
|
579
|
+
len(data),
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _read_from_memory(
|
|
584
|
+
mem_data: "ctypes pointer",
|
|
585
|
+
mem_len: int,
|
|
586
|
+
offset: int,
|
|
587
|
+
length: int,
|
|
588
|
+
) -> bytes:
|
|
589
|
+
"""Read ``length`` bytes from WASM linear memory at ``offset``.
|
|
590
|
+
|
|
591
|
+
Args:
|
|
592
|
+
mem_data: The raw ctypes pointer from memory.data_ptr(store).
|
|
593
|
+
mem_len: Total length of the WASM linear memory in bytes.
|
|
594
|
+
offset: Byte offset within the memory to start reading.
|
|
595
|
+
length: Number of bytes to read.
|
|
596
|
+
|
|
597
|
+
Returns:
|
|
598
|
+
A ``bytes`` object containing the requested data.
|
|
599
|
+
|
|
600
|
+
Raises:
|
|
601
|
+
RuntimeError: If the read would exceed the memory bounds.
|
|
602
|
+
"""
|
|
603
|
+
import ctypes
|
|
604
|
+
|
|
605
|
+
if offset + length > mem_len:
|
|
606
|
+
raise RuntimeError(
|
|
607
|
+
f"WASM memory read out of bounds: offset={offset}, "
|
|
608
|
+
f"len={length}, mem_len={mem_len}"
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
# Cast the raw pointer to a byte array and slice out the region we need.
|
|
612
|
+
array_type = ctypes.c_ubyte * mem_len
|
|
613
|
+
full_array = ctypes.cast(mem_data, ctypes.POINTER(array_type)).contents
|
|
614
|
+
return bytes(full_array[offset : offset + length])
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
# ---------------------------------------------------------------------------
|
|
618
|
+
# Result parsing
|
|
619
|
+
# ---------------------------------------------------------------------------
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _parse_eval_results(raw: dict) -> EvalResults:
|
|
623
|
+
"""Parse the raw JSON dict from the WASM engine into typed EvalResults.
|
|
624
|
+
|
|
625
|
+
The WASM engine returns JSON in this shape:
|
|
626
|
+
{
|
|
627
|
+
"flags": { "key": { "value": ..., "reason": "...", "rule_id": "..." } },
|
|
628
|
+
"tests": { "key": { "variant": "...", "bucket": 1234 } }
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
Args:
|
|
632
|
+
raw: The parsed JSON dict from the WASM evaluator.
|
|
633
|
+
|
|
634
|
+
Returns:
|
|
635
|
+
A fully typed EvalResults instance.
|
|
636
|
+
"""
|
|
637
|
+
# Parse flag results.
|
|
638
|
+
flags = {}
|
|
639
|
+
for key, flag_data in raw.get("flags", {}).items():
|
|
640
|
+
flags[key] = FlagResult(
|
|
641
|
+
value=flag_data.get("value", False),
|
|
642
|
+
reason=flag_data.get("reason", "unknown"),
|
|
643
|
+
rule_id=flag_data.get("rule_id"),
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
# Parse test results.
|
|
647
|
+
tests = {}
|
|
648
|
+
for key, test_data in raw.get("tests", {}).items():
|
|
649
|
+
tests[key] = TestResult(
|
|
650
|
+
variant=test_data.get("variant", "control"),
|
|
651
|
+
bucket=test_data.get("bucket", 0),
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
return EvalResults(flags=flags, tests=tests)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _parse_flag_result(raw: dict) -> FlagResult:
|
|
658
|
+
"""Parse the raw JSON dict from a single-flag WASM evaluation into a FlagResult.
|
|
659
|
+
|
|
660
|
+
The single-flag WASM exports return JSON in this shape:
|
|
661
|
+
{"value": ..., "reason": "...", "rule_id": "..."}
|
|
662
|
+
|
|
663
|
+
Args:
|
|
664
|
+
raw: The parsed JSON dict from the WASM single-flag evaluator.
|
|
665
|
+
|
|
666
|
+
Returns:
|
|
667
|
+
A typed FlagResult instance.
|
|
668
|
+
"""
|
|
669
|
+
return FlagResult(
|
|
670
|
+
value=raw.get("value", False),
|
|
671
|
+
reason=raw.get("reason", "unknown"),
|
|
672
|
+
rule_id=raw.get("rule_id"),
|
|
673
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: toggletest
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ToggleTest SDK for feature flags and A/B testing
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Requires-Dist: httpx-sse>=0.4
|
|
8
|
+
Requires-Dist: httpx>=0.25
|
|
9
|
+
Requires-Dist: wasmtime>=27.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
toggletest/__init__.py,sha256=BRnoVmyZ8UvoFhU1d_bMw1KrmMaOoUCLkfLuJF0ShZ4,1291
|
|
2
|
+
toggletest/client.py,sha256=NynnAj-9JjB0ioiVLeEe17gx6sx0qb2cN8EQCWmusf4,18262
|
|
3
|
+
toggletest/event_buffer.py,sha256=3ZrE8h37TmhxkV9HB8C2xjqaNyYN7UFltCn7o2Y3a_s,8490
|
|
4
|
+
toggletest/rules_store.py,sha256=IwyQ2MyGdIkwzDIvQyYRaaaSvqgVWmZqZg9_lXtSVD0,8927
|
|
5
|
+
toggletest/sse.py,sha256=WpBm7F5ARUWxg_IO_V_ECpx806h_h1soVr_THwIpuY0,7994
|
|
6
|
+
toggletest/types.py,sha256=nychoAZhyCtFKyDiNT-M6nj_teWwr-EF58X3WRJK_C4,5342
|
|
7
|
+
toggletest/wasm_engine.py,sha256=KnUlH5g095i2qf0MvqTovFci8bpsuVqq5t9ofcOtMS8,26626
|
|
8
|
+
toggletest-0.1.0.dist-info/METADATA,sha256=zo61A9_IYgRf-kmY7MLVTkxz1LcN03FzYBTYKgmUUUc,350
|
|
9
|
+
toggletest-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
10
|
+
toggletest-0.1.0.dist-info/RECORD,,
|