devcycle-python-server-sdk 2.0.0__py3-none-any.whl → 3.0.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.
- devcycle_python_sdk/VERSION.txt +1 -1
- devcycle_python_sdk/__init__.py +3 -2
- devcycle_python_sdk/api/backoff.py +11 -0
- devcycle_python_sdk/api/bucketing_client.py +17 -25
- devcycle_python_sdk/api/config_client.py +116 -0
- devcycle_python_sdk/api/event_client.py +95 -0
- devcycle_python_sdk/api/local_bucketing.py +436 -0
- devcycle_python_sdk/{dvc_cloud_client.py → cloud_client.py} +69 -41
- devcycle_python_sdk/exceptions.py +25 -3
- devcycle_python_sdk/local_client.py +220 -0
- devcycle_python_sdk/managers/__init__.py +0 -0
- devcycle_python_sdk/managers/config_manager.py +95 -0
- devcycle_python_sdk/managers/event_queue_manager.py +223 -0
- devcycle_python_sdk/models/bucketed_config.py +150 -0
- devcycle_python_sdk/models/event.py +111 -8
- devcycle_python_sdk/models/feature.py +12 -0
- devcycle_python_sdk/models/platform_data.py +33 -0
- devcycle_python_sdk/models/user.py +52 -7
- devcycle_python_sdk/models/variable.py +17 -5
- devcycle_python_sdk/options.py +106 -0
- devcycle_python_sdk/protobuf/__init__.py +0 -0
- devcycle_python_sdk/protobuf/utils.py +129 -0
- devcycle_python_sdk/protobuf/variableForUserParams_pb2.py +46 -0
- devcycle_python_sdk/py.typed +0 -0
- devcycle_python_sdk/util/version.py +1 -1
- {devcycle_python_server_sdk-2.0.0.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/METADATA +3 -2
- devcycle_python_server_sdk-3.0.0.dist-info/RECORD +55 -0
- example/{example.py → cloud_client_example.py} +24 -20
- example/local_bucketing_client_example.py +74 -0
- test/api/__init__.py +0 -0
- test/{test_bucketing_client.py → api/test_bucketing_client.py} +10 -5
- test/api/test_config_client.py +133 -0
- test/api/test_event_client.py +97 -0
- test/api/test_local_bucketing.py +392 -0
- test/fixture/__init__.py +0 -0
- test/fixture/data.py +57 -0
- test/managers/__init__.py +0 -0
- test/managers/test_config_manager.py +132 -0
- test/managers/test_event_queue_manager.py +271 -0
- test/models/__init__.py +0 -0
- test/models/test_bucketed_config.py +26 -0
- test/{test_dvc_cloud_client.py → test_cloud_client.py} +11 -17
- test/test_local_client.py +397 -0
- test/util/__init__.py +0 -0
- test/util/test_utils.py +183 -0
- test/{test_version.py → util/test_version.py} +2 -2
- devcycle_python_sdk/dvc_options.py +0 -18
- devcycle_python_server_sdk-2.0.0.dist-info/RECORD +0 -26
- {devcycle_python_server_sdk-2.0.0.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/LICENSE +0 -0
- {devcycle_python_server_sdk-2.0.0.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/WHEEL +0 -0
- {devcycle_python_server_sdk-2.0.0.dist-info → devcycle_python_server_sdk-3.0.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import random
|
|
3
|
+
import time
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from threading import Lock
|
|
8
|
+
from typing import Any, cast, Optional, List
|
|
9
|
+
|
|
10
|
+
import wasmtime
|
|
11
|
+
from wasmtime import (
|
|
12
|
+
Engine,
|
|
13
|
+
Func,
|
|
14
|
+
FuncType,
|
|
15
|
+
Linker,
|
|
16
|
+
Memory,
|
|
17
|
+
Module,
|
|
18
|
+
Store,
|
|
19
|
+
ValType,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
import devcycle_python_sdk.protobuf.utils as pb_utils
|
|
23
|
+
import devcycle_python_sdk.protobuf.variableForUserParams_pb2 as pb2
|
|
24
|
+
from devcycle_python_sdk.exceptions import (
|
|
25
|
+
VariableTypeMismatchError,
|
|
26
|
+
MalformedConfigError,
|
|
27
|
+
)
|
|
28
|
+
from devcycle_python_sdk.models.bucketed_config import BucketedConfig
|
|
29
|
+
from devcycle_python_sdk.models.user import DevCycleUser
|
|
30
|
+
from devcycle_python_sdk.models.variable import Variable, determine_variable_type
|
|
31
|
+
from devcycle_python_sdk.models.event import FlushPayload
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
wasm_path = Path(__file__).parent.parent / "bucketing-lib.release.wasm"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class WASMError(Exception):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class WASMAbortError(WASMError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LocalBucketing:
|
|
47
|
+
def __init__(self, sdk_key: str) -> None:
|
|
48
|
+
self.random = random.random()
|
|
49
|
+
self.wasm_lock = Lock()
|
|
50
|
+
|
|
51
|
+
wasi_cfg = wasmtime.WasiConfig()
|
|
52
|
+
wasi_cfg.inherit_env()
|
|
53
|
+
wasi_cfg.inherit_stderr()
|
|
54
|
+
wasi_cfg.inherit_stdout()
|
|
55
|
+
|
|
56
|
+
wasm_engine = Engine()
|
|
57
|
+
wasm_module = Module.from_file(
|
|
58
|
+
wasm_engine,
|
|
59
|
+
str(wasm_path),
|
|
60
|
+
)
|
|
61
|
+
wasm_linker = Linker(wasm_engine)
|
|
62
|
+
wasm_store = Store(wasm_engine)
|
|
63
|
+
wasm_store.set_wasi(wasi_cfg)
|
|
64
|
+
wasm_linker.define_wasi()
|
|
65
|
+
|
|
66
|
+
# Needs to return the current time since Epoch in milliseconds
|
|
67
|
+
def __date_now_func():
|
|
68
|
+
# convert from seconds to milliseconds
|
|
69
|
+
return time.time() * 1000
|
|
70
|
+
|
|
71
|
+
wasm_linker.define_func(
|
|
72
|
+
"env", "Date.now", FuncType([], [ValType.f64()]), __date_now_func
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def __abort_func(
|
|
76
|
+
message_ptr=None,
|
|
77
|
+
filename_ptr=None,
|
|
78
|
+
line=0,
|
|
79
|
+
column=0,
|
|
80
|
+
) -> None:
|
|
81
|
+
message = None
|
|
82
|
+
filename = None
|
|
83
|
+
if message_ptr is not None:
|
|
84
|
+
message = self._read_assembly_script_string(message_ptr)
|
|
85
|
+
if filename_ptr is not None:
|
|
86
|
+
filename = self._read_assembly_script_string(filename_ptr)
|
|
87
|
+
|
|
88
|
+
raise WASMAbortError(
|
|
89
|
+
f"Abort in {filename!r}:{line!r}:{column!r} -- {message!r}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
wasm_linker.define_func(
|
|
93
|
+
"env",
|
|
94
|
+
"abort",
|
|
95
|
+
FuncType([ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()], []),
|
|
96
|
+
__abort_func,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def __seed_func():
|
|
100
|
+
return time.time() * random.random()
|
|
101
|
+
|
|
102
|
+
wasm_linker.define_func(
|
|
103
|
+
"env", "seed", FuncType([], [ValType.f64()]), __seed_func
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def __console_log_func(message_ptr) -> None:
|
|
107
|
+
message: str = self._read_assembly_script_string(message_ptr)
|
|
108
|
+
logger.warning(f"WASM console: {message!r}")
|
|
109
|
+
|
|
110
|
+
wasm_linker.define_func(
|
|
111
|
+
"env", "console.log", FuncType([ValType.i32()], []), __console_log_func
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
wasm_instance = wasm_linker.instantiate(wasm_store, wasm_module)
|
|
115
|
+
self.wasm_instance = wasm_instance
|
|
116
|
+
self.wasm_store = wasm_store
|
|
117
|
+
self.wasm_linker = wasm_linker
|
|
118
|
+
self.wasm_module = wasm_module
|
|
119
|
+
self.wasm_engine = wasm_engine
|
|
120
|
+
|
|
121
|
+
wasm_memory: Memory = self._get_export("memory")
|
|
122
|
+
self.wasm_memory = wasm_memory
|
|
123
|
+
|
|
124
|
+
# Bind exported internal AssemblyScript functions
|
|
125
|
+
self.__new: Func = self._get_export("__new")
|
|
126
|
+
self.__pin: Func = self._get_export("__pin")
|
|
127
|
+
self.__unpin: Func = self._get_export("__unpin")
|
|
128
|
+
|
|
129
|
+
# Bind exported WASM functions
|
|
130
|
+
self.initEventQueue = self._get_export("initEventQueue")
|
|
131
|
+
self.flushEventQueue = self._get_export("flushEventQueue")
|
|
132
|
+
self.eventQueueSize = self._get_export("eventQueueSize")
|
|
133
|
+
self.onPayloadSuccess = self._get_export("onPayloadSuccess")
|
|
134
|
+
self.onPayloadFailure = self._get_export("onPayloadFailure")
|
|
135
|
+
self.queueEvent = self._get_export("queueEvent")
|
|
136
|
+
self.queueAggregateEvent = self._get_export("queueAggregateEvent")
|
|
137
|
+
self.setConfigDataUTF8 = self._get_export("setConfigDataUTF8")
|
|
138
|
+
self.setPlatformDataUTF8 = self._get_export("setPlatformDataUTF8")
|
|
139
|
+
self.setClientCustomDataUTF8 = self._get_export("setClientCustomDataUTF8")
|
|
140
|
+
self.generateBucketedConfigForUserUTF8 = self._get_export(
|
|
141
|
+
"generateBucketedConfigForUserUTF8"
|
|
142
|
+
)
|
|
143
|
+
self.VariableForUserProtobuf = self._get_export("variableForUser_PB")
|
|
144
|
+
|
|
145
|
+
# Extract variable type enum values from WASM
|
|
146
|
+
self.variable_type_map = {
|
|
147
|
+
variable_type_key: self._get_export(
|
|
148
|
+
f"VariableType.{variable_type_key}"
|
|
149
|
+
).value(wasm_store)
|
|
150
|
+
for variable_type_key in [
|
|
151
|
+
"Boolean",
|
|
152
|
+
"String",
|
|
153
|
+
"Number",
|
|
154
|
+
"JSON",
|
|
155
|
+
]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
# Set and pin the SDK key so it can be reused multiple times
|
|
159
|
+
self.sdk_key = sdk_key
|
|
160
|
+
self.sdk_key_addr = self._new_assembly_script_string(sdk_key)
|
|
161
|
+
self.__pin(self.wasm_store, self.sdk_key_addr)
|
|
162
|
+
|
|
163
|
+
# Allocate memory for header.
|
|
164
|
+
object_id_uint8_array = 9
|
|
165
|
+
header_pointer = cast(
|
|
166
|
+
int, self.__new(self.wasm_store, 12, object_id_uint8_array)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# An external object that is not referenced from within WebAssembly
|
|
170
|
+
# must be pinned whenever an allocation might happen in between
|
|
171
|
+
# allocating it and passing it to WebAssembly.
|
|
172
|
+
self.__pin(self.wasm_store, header_pointer)
|
|
173
|
+
|
|
174
|
+
self._header_pointer = header_pointer
|
|
175
|
+
|
|
176
|
+
def _get_export(self, export_name: str):
|
|
177
|
+
return self.wasm_instance.exports(self.wasm_store)[export_name]
|
|
178
|
+
|
|
179
|
+
def _new_assembly_script_string(self, param: str) -> int:
|
|
180
|
+
"""
|
|
181
|
+
Allocate memory for a string in AssemblyScript and write the string
|
|
182
|
+
into memory, then return a pointer to the string.
|
|
183
|
+
Only safe for use with ASCII strings.
|
|
184
|
+
"""
|
|
185
|
+
object_id_string = 2
|
|
186
|
+
encoded = param.encode("utf-8")
|
|
187
|
+
try:
|
|
188
|
+
# Create pointer to string buffer in WASM memory.
|
|
189
|
+
pointer = cast(
|
|
190
|
+
int, self.__new(self.wasm_store, len(encoded) * 2, object_id_string)
|
|
191
|
+
)
|
|
192
|
+
except Exception as err:
|
|
193
|
+
raise WASMError(f"Error allocating string in WASM: {err}")
|
|
194
|
+
addr = pointer
|
|
195
|
+
data = self.wasm_memory.data_ptr(self.wasm_store)
|
|
196
|
+
|
|
197
|
+
# Write encoded data into buffer.
|
|
198
|
+
for i, c in enumerate(encoded):
|
|
199
|
+
data[addr + i * 2] = c
|
|
200
|
+
|
|
201
|
+
return pointer
|
|
202
|
+
|
|
203
|
+
def _read_assembly_script_string(self, pointer: int) -> str:
|
|
204
|
+
"""
|
|
205
|
+
Read a string from AssemblyScript memory.
|
|
206
|
+
Only safe for use with ASCII strings.
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
"""
|
|
210
|
+
if pointer == 0:
|
|
211
|
+
raise ValueError(
|
|
212
|
+
"Null pointer passed to _read_assembly_script_string - cannot write string"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Get a direct reference to the WASM memory
|
|
216
|
+
data = self.wasm_memory.data_ptr(self.wasm_store)
|
|
217
|
+
|
|
218
|
+
# Parse the string length from the header.
|
|
219
|
+
string_length = int.from_bytes(data[pointer - 4 : pointer], byteorder="little")
|
|
220
|
+
raw_data = data[pointer : pointer + string_length]
|
|
221
|
+
|
|
222
|
+
# This skips every other index in the resulting array because there
|
|
223
|
+
# isn't a great way to parse UTF-16 cleanly that matches the WTF-16
|
|
224
|
+
# format that ASC uses.
|
|
225
|
+
ret = bytearray(len(raw_data) // 2)
|
|
226
|
+
for i in range(0, len(raw_data), 2):
|
|
227
|
+
ret[i // 2] += raw_data[i]
|
|
228
|
+
|
|
229
|
+
return ret.decode("utf-8")
|
|
230
|
+
|
|
231
|
+
def _new_assembly_script_byte_array(self, param: bytes) -> int:
|
|
232
|
+
"""
|
|
233
|
+
Allocates memory for a byte array in AssemblyScript and writes the byte
|
|
234
|
+
array into memory, then returns a pointer to the byte array.
|
|
235
|
+
"""
|
|
236
|
+
object_id_byte_array = 1
|
|
237
|
+
data_length = len(param)
|
|
238
|
+
try:
|
|
239
|
+
# Allocate memory for buffer.
|
|
240
|
+
buffer_pointer = cast(
|
|
241
|
+
int, self.__new(self.wasm_store, data_length, object_id_byte_array)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# Get a direct reference to the WASM memory
|
|
245
|
+
data = self.wasm_memory.data_ptr(self.wasm_store)
|
|
246
|
+
|
|
247
|
+
# Write the buffer pointer value into the first 4 bytes of the header, little endian.
|
|
248
|
+
for i, b in enumerate(buffer_pointer.to_bytes(4, byteorder="little")):
|
|
249
|
+
data[self._header_pointer + i] = b
|
|
250
|
+
data[self._header_pointer + i + 4] = b
|
|
251
|
+
|
|
252
|
+
little_endian_buffer_len = data_length.to_bytes(4, byteorder="little")
|
|
253
|
+
|
|
254
|
+
# Write the buffer length into bytes 8-12 of the header, little endian.
|
|
255
|
+
for i, b in enumerate(little_endian_buffer_len):
|
|
256
|
+
data[self._header_pointer + 8 + i] = b
|
|
257
|
+
|
|
258
|
+
# Write the byte array data into the WASM buffer.
|
|
259
|
+
for i, b in enumerate(param):
|
|
260
|
+
data[buffer_pointer + i] = b
|
|
261
|
+
|
|
262
|
+
return self._header_pointer
|
|
263
|
+
except Exception as err:
|
|
264
|
+
raise WASMError(f"Error writing byte array to WASM: {err}")
|
|
265
|
+
|
|
266
|
+
def _read_assembly_script_byte_array(self, pointer: int) -> bytes:
|
|
267
|
+
"""
|
|
268
|
+
Read a byte array from AssemblyScript memory.
|
|
269
|
+
"""
|
|
270
|
+
if pointer == 0:
|
|
271
|
+
raise ValueError(
|
|
272
|
+
"Null pointer passed to _read_assembly_script_byte_array - cannot write string"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Get a direct reference to the WASM memory
|
|
276
|
+
data = self.wasm_memory.data_ptr(self.wasm_store)
|
|
277
|
+
|
|
278
|
+
# Parse the data length and data pointer from the header.
|
|
279
|
+
data_length = int.from_bytes(
|
|
280
|
+
data[pointer + 8 : pointer + 12], byteorder="little"
|
|
281
|
+
)
|
|
282
|
+
data_pointer = int.from_bytes(data[pointer : pointer + 4], byteorder="little")
|
|
283
|
+
|
|
284
|
+
ret = bytearray(data_length)
|
|
285
|
+
|
|
286
|
+
# Copy the data from the WASM buffer into the return value.
|
|
287
|
+
for i in range(data_length):
|
|
288
|
+
ret[i] = data[data_pointer + i]
|
|
289
|
+
|
|
290
|
+
return bytes(ret)
|
|
291
|
+
|
|
292
|
+
def init_event_queue(self, options_json: str) -> None:
|
|
293
|
+
with self.wasm_lock:
|
|
294
|
+
options_addr = self._new_assembly_script_string(options_json)
|
|
295
|
+
self.initEventQueue(self.wasm_store, self.sdk_key_addr, options_addr)
|
|
296
|
+
|
|
297
|
+
def get_variable_for_user_protobuf(
|
|
298
|
+
self, user: DevCycleUser, key: str, default_value: Any
|
|
299
|
+
) -> Optional[Variable]:
|
|
300
|
+
var_type = determine_variable_type(default_value)
|
|
301
|
+
pb_variable_type = pb_utils.convert_type_enum_to_variable_type(var_type)
|
|
302
|
+
|
|
303
|
+
params_pb = pb2.VariableForUserParams_PB(
|
|
304
|
+
sdkKey=self.sdk_key,
|
|
305
|
+
variableKey=key,
|
|
306
|
+
variableType=pb_variable_type,
|
|
307
|
+
user=pb_utils.create_dvcuser_pb(user),
|
|
308
|
+
shouldTrackEvent=True,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
params_str = params_pb.SerializeToString()
|
|
312
|
+
|
|
313
|
+
with self.wasm_lock:
|
|
314
|
+
params_addr = self._new_assembly_script_byte_array(params_str)
|
|
315
|
+
variable_addr = self.VariableForUserProtobuf(self.wasm_store, params_addr)
|
|
316
|
+
|
|
317
|
+
if variable_addr == 0:
|
|
318
|
+
return None
|
|
319
|
+
else:
|
|
320
|
+
var_bytes = self._read_assembly_script_byte_array(variable_addr)
|
|
321
|
+
sdk_variable = pb2.SDKVariable_PB()
|
|
322
|
+
sdk_variable.ParseFromString(var_bytes)
|
|
323
|
+
|
|
324
|
+
if sdk_variable.type != pb_variable_type:
|
|
325
|
+
# this situation should never actually happen because the WASM handles
|
|
326
|
+
# it internally and returns a null value from the WASM function
|
|
327
|
+
# This check is here just in case that logic changes in the future
|
|
328
|
+
raise VariableTypeMismatchError(
|
|
329
|
+
f"Variable returned does not match requested type: {pb_variable_type}"
|
|
330
|
+
)
|
|
331
|
+
return pb_utils.create_variable(sdk_variable, default_value)
|
|
332
|
+
|
|
333
|
+
def generate_bucketed_config(self, user: DevCycleUser) -> BucketedConfig:
|
|
334
|
+
user_json = json.dumps(user.to_json())
|
|
335
|
+
|
|
336
|
+
with self.wasm_lock:
|
|
337
|
+
user_json_addr = self._new_assembly_script_byte_array(
|
|
338
|
+
user_json.encode("utf-8")
|
|
339
|
+
)
|
|
340
|
+
config_addr = self.generateBucketedConfigForUserUTF8(
|
|
341
|
+
self.wasm_store, self.sdk_key_addr, user_json_addr
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
config_bytes = self._read_assembly_script_byte_array(config_addr)
|
|
345
|
+
|
|
346
|
+
config_data = json.loads(config_bytes.decode("utf-8"))
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
config = BucketedConfig.from_json(config_data)
|
|
350
|
+
except KeyError as e:
|
|
351
|
+
raise MalformedConfigError(
|
|
352
|
+
f"Failed to parse bucketed config: missing key {e}"
|
|
353
|
+
) from e
|
|
354
|
+
|
|
355
|
+
config.user = user
|
|
356
|
+
|
|
357
|
+
return config
|
|
358
|
+
|
|
359
|
+
def store_config(self, config_json: str) -> None:
|
|
360
|
+
with self.wasm_lock:
|
|
361
|
+
data = config_json.encode("utf-8")
|
|
362
|
+
config_addr = self._new_assembly_script_byte_array(data)
|
|
363
|
+
self.setConfigDataUTF8(self.wasm_store, self.sdk_key_addr, config_addr)
|
|
364
|
+
|
|
365
|
+
def set_platform_data(self, platform_json: str) -> None:
|
|
366
|
+
with self.wasm_lock:
|
|
367
|
+
data = platform_json.encode("utf-8")
|
|
368
|
+
data_addr = self._new_assembly_script_byte_array(data)
|
|
369
|
+
self.setPlatformDataUTF8(self.wasm_store, data_addr)
|
|
370
|
+
|
|
371
|
+
def set_client_custom_data(self, client_data_json: str) -> None:
|
|
372
|
+
with self.wasm_lock:
|
|
373
|
+
data = client_data_json.encode("utf-8")
|
|
374
|
+
data_addr = self._new_assembly_script_byte_array(data)
|
|
375
|
+
self.setClientCustomDataUTF8(self.wasm_store, self.sdk_key_addr, data_addr)
|
|
376
|
+
|
|
377
|
+
def flush_event_queue(self) -> List[FlushPayload]:
|
|
378
|
+
"""
|
|
379
|
+
Collects the events that are ready to send to the server. Events are group into separate payloads
|
|
380
|
+
|
|
381
|
+
Returns: a list of FlushPayload objects, or an empty list if there are no events to send
|
|
382
|
+
"""
|
|
383
|
+
with self.wasm_lock:
|
|
384
|
+
result_addr = self.flushEventQueue(self.wasm_store, self.sdk_key_addr)
|
|
385
|
+
result_str = self._read_assembly_script_string(result_addr)
|
|
386
|
+
result_json = json.loads(result_str)
|
|
387
|
+
return [FlushPayload.from_json(element) for element in result_json]
|
|
388
|
+
|
|
389
|
+
def on_event_payload_success(self, payload_id: str) -> None:
|
|
390
|
+
"""
|
|
391
|
+
Notifies the WASM that the events associated with the payload_id have been sent successfully and
|
|
392
|
+
can be purged from the queue
|
|
393
|
+
"""
|
|
394
|
+
with self.wasm_lock:
|
|
395
|
+
id_addr = self._new_assembly_script_string(payload_id)
|
|
396
|
+
self.onPayloadSuccess(self.wasm_store, self.sdk_key_addr, id_addr)
|
|
397
|
+
|
|
398
|
+
def on_event_payload_failure(self, payload_id: str, retryable: bool) -> None:
|
|
399
|
+
"""
|
|
400
|
+
Notifies the WASM that the events associated with the payload_id failed to be sent and should
|
|
401
|
+
be re-queued.
|
|
402
|
+
"""
|
|
403
|
+
with self.wasm_lock:
|
|
404
|
+
id_addr = self._new_assembly_script_string(payload_id)
|
|
405
|
+
self.onPayloadFailure(
|
|
406
|
+
self.wasm_store, self.sdk_key_addr, id_addr, 1 if retryable else 0
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
def get_event_queue_size(self) -> int:
|
|
410
|
+
"""
|
|
411
|
+
Returns the number of events currently in the queue
|
|
412
|
+
"""
|
|
413
|
+
with self.wasm_lock:
|
|
414
|
+
val = self.eventQueueSize(self.wasm_store, self.sdk_key_addr)
|
|
415
|
+
return int(val)
|
|
416
|
+
|
|
417
|
+
def queue_event(self, user_json: str, event_json: str) -> None:
|
|
418
|
+
with self.wasm_lock:
|
|
419
|
+
user_addr = self._new_assembly_script_string(user_json)
|
|
420
|
+
event_addr = self._new_assembly_script_string(event_json)
|
|
421
|
+
self.queueEvent(self.wasm_store, self.sdk_key_addr, user_addr, event_addr)
|
|
422
|
+
|
|
423
|
+
def queue_aggregate_event(
|
|
424
|
+
self, event_json: str, variable_variation_map_json: str
|
|
425
|
+
) -> None:
|
|
426
|
+
with self.wasm_lock:
|
|
427
|
+
event_addr = self._new_assembly_script_string(event_json)
|
|
428
|
+
variable_variation_map_addr = self._new_assembly_script_string(
|
|
429
|
+
variable_variation_map_json
|
|
430
|
+
)
|
|
431
|
+
self.queueAggregateEvent(
|
|
432
|
+
self.wasm_store,
|
|
433
|
+
self.sdk_key_addr,
|
|
434
|
+
event_addr,
|
|
435
|
+
variable_variation_map_addr,
|
|
436
|
+
)
|
|
@@ -9,8 +9,8 @@ from devcycle_python_sdk.exceptions import (
|
|
|
9
9
|
NotFoundError,
|
|
10
10
|
CloudClientUnauthorizedError,
|
|
11
11
|
)
|
|
12
|
-
from devcycle_python_sdk.models.user import
|
|
13
|
-
from devcycle_python_sdk.models.event import
|
|
12
|
+
from devcycle_python_sdk.models.user import DevCycleUser
|
|
13
|
+
from devcycle_python_sdk.models.event import DevCycleEvent
|
|
14
14
|
from devcycle_python_sdk.models.variable import Variable
|
|
15
15
|
from devcycle_python_sdk.models.feature import Feature
|
|
16
16
|
from devcycle_python_sdk.util.version import sdk_version
|
|
@@ -25,7 +25,7 @@ class DevCycleCloudClient:
|
|
|
25
25
|
sdk_version: str
|
|
26
26
|
|
|
27
27
|
def __init__(self, sdk_key: str, options: DevCycleCloudOptions):
|
|
28
|
-
|
|
28
|
+
_validate_sdk_key(sdk_key)
|
|
29
29
|
|
|
30
30
|
if options is None:
|
|
31
31
|
self.options = DevCycleCloudOptions()
|
|
@@ -38,34 +38,30 @@ class DevCycleCloudClient:
|
|
|
38
38
|
self.sdk_type = "server"
|
|
39
39
|
self.bucketing_api = BucketingAPIClient(sdk_key, self.options)
|
|
40
40
|
|
|
41
|
-
def _add_platform_data_to_user(self, user:
|
|
41
|
+
def _add_platform_data_to_user(self, user: DevCycleUser) -> DevCycleUser:
|
|
42
42
|
user.platform = self.platform
|
|
43
43
|
user.platformVersion = self.platform_version
|
|
44
44
|
user.sdkVersion = self.sdk_version
|
|
45
45
|
user.sdkType = self.sdk_type
|
|
46
46
|
return user
|
|
47
47
|
|
|
48
|
-
def
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
def variable_value(self, user: DevCycleUser, key: str, default_value: Any) -> Any:
|
|
49
|
+
"""
|
|
50
|
+
Evaluates a variable for a user and returns the value. If the user is not bucketed into the variable, the default value will be returned
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"Invalid SDK key provided. Call build with a valid server SDK key"
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
def _validate_user(self, user: User) -> None:
|
|
58
|
-
if user is None:
|
|
59
|
-
raise ValueError("User cannot be None")
|
|
60
|
-
|
|
61
|
-
if user.user_id is None or len(user.user_id) == 0:
|
|
62
|
-
raise ValueError("userId cannot be empty")
|
|
63
|
-
|
|
64
|
-
def variable_value(self, user: User, key: str, default_value: Any) -> Any:
|
|
52
|
+
:param user: The user to evaluate the variable for
|
|
53
|
+
"""
|
|
65
54
|
return self.variable(user, key, default_value).value
|
|
66
55
|
|
|
67
|
-
def variable(self, user:
|
|
68
|
-
|
|
56
|
+
def variable(self, user: DevCycleUser, key: str, default_value: Any) -> Variable:
|
|
57
|
+
"""
|
|
58
|
+
Evaluates a variable for a user.
|
|
59
|
+
|
|
60
|
+
:param user: The user to evaluate the variable for
|
|
61
|
+
:param key: The key of the variable to evaluate
|
|
62
|
+
:param default_value: The default value to return if the user is not bucketed into the variable
|
|
63
|
+
"""
|
|
64
|
+
_validate_user(user)
|
|
69
65
|
user = self._add_platform_data_to_user(user)
|
|
70
66
|
|
|
71
67
|
if not key:
|
|
@@ -77,15 +73,15 @@ class DevCycleCloudClient:
|
|
|
77
73
|
try:
|
|
78
74
|
variable = self.bucketing_api.variable(key, user)
|
|
79
75
|
except CloudClientUnauthorizedError as e:
|
|
80
|
-
logger.warning("DevCycle
|
|
76
|
+
logger.warning("DevCycle SDK key is invalid, unable to make cloud request")
|
|
81
77
|
raise e
|
|
82
78
|
except NotFoundError:
|
|
83
|
-
logger.warning("
|
|
79
|
+
logger.warning(f"Variable not found: {key}")
|
|
84
80
|
return Variable.create_default_variable(
|
|
85
81
|
key=key, default_value=default_value
|
|
86
82
|
)
|
|
87
83
|
except Exception as e:
|
|
88
|
-
logger.error("
|
|
84
|
+
logger.error(f"Error evaluating variable: {e}")
|
|
89
85
|
return Variable.create_default_variable(
|
|
90
86
|
key=key, default_value=default_value
|
|
91
87
|
)
|
|
@@ -95,10 +91,7 @@ class DevCycleCloudClient:
|
|
|
95
91
|
# Allow default value to be a subclass of the same type as the variable
|
|
96
92
|
if not isinstance(default_value, type(variable.value)):
|
|
97
93
|
logger.warning(
|
|
98
|
-
"
|
|
99
|
-
key,
|
|
100
|
-
type(variable.value),
|
|
101
|
-
type(default_value),
|
|
94
|
+
f"Variable {key} is type {type(variable.value)}, but default value is type {type(default_value)}",
|
|
102
95
|
)
|
|
103
96
|
return Variable.create_default_variable(
|
|
104
97
|
key=key, default_value=default_value
|
|
@@ -106,41 +99,58 @@ class DevCycleCloudClient:
|
|
|
106
99
|
|
|
107
100
|
return variable
|
|
108
101
|
|
|
109
|
-
def all_variables(self, user:
|
|
110
|
-
|
|
102
|
+
def all_variables(self, user: DevCycleUser) -> Dict[str, Variable]:
|
|
103
|
+
"""
|
|
104
|
+
Returns all segmented and bucketed variables for a user. This method will return an empty map if the user is not bucketed into any variables
|
|
105
|
+
|
|
106
|
+
:param user: The user to retrieve features for
|
|
107
|
+
"""
|
|
108
|
+
_validate_user(user)
|
|
111
109
|
user = self._add_platform_data_to_user(user)
|
|
112
110
|
|
|
113
111
|
variable_map: Dict[str, Variable] = {}
|
|
114
112
|
try:
|
|
115
113
|
variable_map = self.bucketing_api.variables(user)
|
|
116
114
|
except CloudClientUnauthorizedError as e:
|
|
117
|
-
logger.warning("
|
|
115
|
+
logger.warning("SDK key is invalid, unable to make cloud request")
|
|
118
116
|
raise e
|
|
119
117
|
except Exception as e:
|
|
120
|
-
logger.error("Error retrieving all features for a user:
|
|
118
|
+
logger.error(f"Error retrieving all features for a user: {e}")
|
|
121
119
|
|
|
122
120
|
return variable_map
|
|
123
121
|
|
|
124
|
-
def all_features(self, user:
|
|
125
|
-
|
|
122
|
+
def all_features(self, user: DevCycleUser) -> Dict[str, Feature]:
|
|
123
|
+
"""
|
|
124
|
+
Returns all segmented and bucketed features for a user. This method will return an empty map if the user is not bucketed into any features
|
|
125
|
+
|
|
126
|
+
:param user: The user to retrieve features for
|
|
127
|
+
"""
|
|
128
|
+
_validate_user(user)
|
|
126
129
|
user = self._add_platform_data_to_user(user)
|
|
127
130
|
|
|
128
131
|
feature_map: Dict[str, Feature] = {}
|
|
129
132
|
try:
|
|
130
133
|
feature_map = self.bucketing_api.features(user)
|
|
131
134
|
except CloudClientUnauthorizedError as e:
|
|
132
|
-
logger.warning("
|
|
135
|
+
logger.warning("SDK key is invalid, unable to make cloud request")
|
|
133
136
|
raise e
|
|
134
137
|
except Exception as e:
|
|
135
|
-
logger.error("Error retrieving all features for a user:
|
|
138
|
+
logger.error(f"Error retrieving all features for a user: {e}")
|
|
136
139
|
|
|
137
140
|
return feature_map
|
|
138
141
|
|
|
139
|
-
def track(self, user:
|
|
142
|
+
def track(self, user: DevCycleUser, user_event: DevCycleEvent) -> None:
|
|
143
|
+
"""
|
|
144
|
+
Tracks a custom event for a user.
|
|
145
|
+
|
|
146
|
+
:param user: The user to track the event for
|
|
147
|
+
:param user_event: The event to track
|
|
148
|
+
"""
|
|
149
|
+
|
|
140
150
|
if user_event is None or not user_event.type:
|
|
141
151
|
raise ValueError("Invalid Event")
|
|
142
152
|
|
|
143
|
-
|
|
153
|
+
_validate_user(user)
|
|
144
154
|
user = self._add_platform_data_to_user(user)
|
|
145
155
|
|
|
146
156
|
if user_event is None or not user_event.type:
|
|
@@ -150,7 +160,25 @@ class DevCycleCloudClient:
|
|
|
150
160
|
try:
|
|
151
161
|
self.bucketing_api.track(user, events)
|
|
152
162
|
except CloudClientUnauthorizedError as e:
|
|
153
|
-
logger.warning("
|
|
163
|
+
logger.warning("SDK key is invalid, unable to make cloud request")
|
|
154
164
|
raise e
|
|
155
165
|
except Exception as e:
|
|
156
|
-
logger.error("Error tracking event:
|
|
166
|
+
logger.error(f"Error tracking event: {e}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _validate_sdk_key(sdk_key: str) -> None:
|
|
170
|
+
if sdk_key is None or len(sdk_key) == 0:
|
|
171
|
+
raise ValueError("Missing SDK key! Call initialize with a valid SDK key")
|
|
172
|
+
|
|
173
|
+
if not sdk_key.startswith("server") and not sdk_key.startswith("dvc_server"):
|
|
174
|
+
raise ValueError(
|
|
175
|
+
"Invalid SDK key provided. Please call initialize with a valid server SDK key"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _validate_user(user: DevCycleUser) -> None:
|
|
180
|
+
if user is None:
|
|
181
|
+
raise ValueError("User cannot be None")
|
|
182
|
+
|
|
183
|
+
if user.user_id is None or len(user.user_id) == 0:
|
|
184
|
+
raise ValueError("userId cannot be empty")
|
|
@@ -1,21 +1,43 @@
|
|
|
1
1
|
from typing import Optional
|
|
2
2
|
|
|
3
3
|
|
|
4
|
-
class
|
|
4
|
+
class APIClientError(Exception):
|
|
5
5
|
def __init__(self, message: str, cause: Optional[Exception] = None):
|
|
6
6
|
self.message = message
|
|
7
7
|
self.__cause__ = cause
|
|
8
8
|
|
|
9
9
|
def __str__(self):
|
|
10
|
-
return f"
|
|
10
|
+
return f"APIClientError: {self.message}"
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
class
|
|
13
|
+
class APIClientUnauthorizedError(Exception):
|
|
14
14
|
def __init__(self, message: str):
|
|
15
15
|
self.message = message
|
|
16
16
|
super().__init__(message)
|
|
17
17
|
|
|
18
18
|
|
|
19
|
+
class CloudClientError(APIClientError):
|
|
20
|
+
def __init__(self, message: str, cause: Optional[Exception] = None):
|
|
21
|
+
super().__init__(message, cause)
|
|
22
|
+
|
|
23
|
+
def __str__(self):
|
|
24
|
+
return f"CloudClientException: {self.message}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CloudClientUnauthorizedError(APIClientUnauthorizedError):
|
|
28
|
+
def __init__(self, message: str):
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
|
|
31
|
+
|
|
19
32
|
class NotFoundError(Exception):
|
|
20
33
|
def __init__(self, key: str):
|
|
21
34
|
self.key = key
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class VariableTypeMismatchError(Exception):
|
|
38
|
+
def __init__(self, message: str):
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MalformedConfigError(Exception):
|
|
43
|
+
pass
|