growthbook 2.1.2__py2.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.
- growthbook/__init__.py +22 -0
- growthbook/common_types.py +446 -0
- growthbook/core.py +985 -0
- growthbook/growthbook.py +1175 -0
- growthbook/growthbook_client.py +661 -0
- growthbook/plugins/__init__.py +16 -0
- growthbook/plugins/base.py +103 -0
- growthbook/plugins/growthbook_tracking.py +285 -0
- growthbook/plugins/request_context.py +358 -0
- growthbook/py.typed +0 -0
- growthbook-2.1.2.dist-info/METADATA +700 -0
- growthbook-2.1.2.dist-info/RECORD +15 -0
- growthbook-2.1.2.dist-info/WHEEL +6 -0
- growthbook-2.1.2.dist-info/licenses/LICENSE +22 -0
- growthbook-2.1.2.dist-info/top_level.txt +1 -0
growthbook/core.py
ADDED
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from urllib.parse import urlparse, parse_qs
|
|
6
|
+
from typing import Callable, Optional, Any, Set, Tuple, List, Dict
|
|
7
|
+
from .common_types import EvaluationContext, FeatureResult, Experiment, Filter, Result, UserContext, VariationMeta
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("growthbook.core")
|
|
11
|
+
|
|
12
|
+
def evalCondition(attributes: Dict[str, Any], condition: Dict[str, Any], savedGroups: Optional[Dict[str, Any]] = None) -> bool:
|
|
13
|
+
for key, value in condition.items():
|
|
14
|
+
if key == "$or":
|
|
15
|
+
if not evalOr(attributes, value, savedGroups):
|
|
16
|
+
return False
|
|
17
|
+
elif key == "$nor":
|
|
18
|
+
if evalOr(attributes, value, savedGroups):
|
|
19
|
+
return False
|
|
20
|
+
elif key == "$and":
|
|
21
|
+
if not evalAnd(attributes, value, savedGroups):
|
|
22
|
+
return False
|
|
23
|
+
elif key == "$not":
|
|
24
|
+
if evalCondition(attributes, value, savedGroups):
|
|
25
|
+
return False
|
|
26
|
+
elif not evalConditionValue(value, getPath(attributes, key), savedGroups):
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
def evalOr(attributes: Dict[str, Any], conditions: List[Any], savedGroups: Optional[Dict[str, Any]]) -> bool:
|
|
32
|
+
if len(conditions) == 0:
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
for condition in conditions:
|
|
36
|
+
if evalCondition(attributes, condition, savedGroups):
|
|
37
|
+
return True
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def evalAnd(attributes: Dict[str, Any], conditions: List[Any], savedGroups: Optional[Dict[str, Any]]) -> bool:
|
|
42
|
+
for condition in conditions:
|
|
43
|
+
if not evalCondition(attributes, condition, savedGroups):
|
|
44
|
+
return False
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
def isOperatorObject(obj: Any) -> bool:
|
|
48
|
+
for key in obj.keys():
|
|
49
|
+
if key[0] != "$":
|
|
50
|
+
return False
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
def getType(attributeValue) -> str:
|
|
54
|
+
t = type(attributeValue)
|
|
55
|
+
|
|
56
|
+
if attributeValue is None:
|
|
57
|
+
return "null"
|
|
58
|
+
if t is int or t is float:
|
|
59
|
+
return "number"
|
|
60
|
+
if t is str:
|
|
61
|
+
return "string"
|
|
62
|
+
if t is list or t is set:
|
|
63
|
+
return "array"
|
|
64
|
+
if t is dict:
|
|
65
|
+
return "object"
|
|
66
|
+
if t is bool:
|
|
67
|
+
return "boolean"
|
|
68
|
+
return "unknown"
|
|
69
|
+
|
|
70
|
+
def getPath(attributes, path):
|
|
71
|
+
current = attributes
|
|
72
|
+
for segment in path.split("."):
|
|
73
|
+
if type(current) is dict and segment in current:
|
|
74
|
+
current = current[segment]
|
|
75
|
+
else:
|
|
76
|
+
return None
|
|
77
|
+
return current
|
|
78
|
+
|
|
79
|
+
def evalConditionValue(conditionValue, attributeValue, savedGroups, insensitive: bool = False) -> bool:
|
|
80
|
+
if type(conditionValue) is dict and isOperatorObject(conditionValue):
|
|
81
|
+
for key, value in conditionValue.items():
|
|
82
|
+
if not evalOperatorCondition(key, attributeValue, value, savedGroups):
|
|
83
|
+
return False
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
# Simple equality comparison with optional case-insensitivity
|
|
87
|
+
if insensitive and type(conditionValue) is str and type(attributeValue) is str:
|
|
88
|
+
return conditionValue.lower() == attributeValue.lower()
|
|
89
|
+
|
|
90
|
+
return bool(conditionValue == attributeValue)
|
|
91
|
+
|
|
92
|
+
def elemMatch(condition, attributeValue, savedGroups) -> bool:
|
|
93
|
+
if not type(attributeValue) is list:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
for item in attributeValue:
|
|
97
|
+
if isOperatorObject(condition):
|
|
98
|
+
if evalConditionValue(condition, item, savedGroups):
|
|
99
|
+
return True
|
|
100
|
+
else:
|
|
101
|
+
if evalCondition(item, condition, savedGroups):
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
def compare(val1, val2) -> int:
|
|
107
|
+
if (type(val1) is int or type(val1) is float) and not (type(val2) is int or type(val2) is float):
|
|
108
|
+
if (val2 is None):
|
|
109
|
+
val2 = 0
|
|
110
|
+
else:
|
|
111
|
+
val2 = float(val2)
|
|
112
|
+
|
|
113
|
+
if (type(val2) is int or type(val2) is float) and not (type(val1) is int or type(val1) is float):
|
|
114
|
+
if (val1 is None):
|
|
115
|
+
val1 = 0
|
|
116
|
+
else:
|
|
117
|
+
val1 = float(val1)
|
|
118
|
+
|
|
119
|
+
if val1 > val2:
|
|
120
|
+
return 1
|
|
121
|
+
if val1 < val2:
|
|
122
|
+
return -1
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
def evalOperatorCondition(operator, attributeValue, conditionValue, savedGroups) -> bool:
|
|
126
|
+
if operator == "$eq":
|
|
127
|
+
try:
|
|
128
|
+
return compare(attributeValue, conditionValue) == 0
|
|
129
|
+
except Exception:
|
|
130
|
+
return False
|
|
131
|
+
elif operator == "$ne":
|
|
132
|
+
try:
|
|
133
|
+
return compare(attributeValue, conditionValue) != 0
|
|
134
|
+
except Exception:
|
|
135
|
+
return False
|
|
136
|
+
elif operator == "$lt":
|
|
137
|
+
try:
|
|
138
|
+
return compare(attributeValue, conditionValue) < 0
|
|
139
|
+
except Exception:
|
|
140
|
+
return False
|
|
141
|
+
elif operator == "$lte":
|
|
142
|
+
try:
|
|
143
|
+
return compare(attributeValue, conditionValue) <= 0
|
|
144
|
+
except Exception:
|
|
145
|
+
return False
|
|
146
|
+
elif operator == "$gt":
|
|
147
|
+
try:
|
|
148
|
+
return compare(attributeValue, conditionValue) > 0
|
|
149
|
+
except Exception:
|
|
150
|
+
return False
|
|
151
|
+
elif operator == "$gte":
|
|
152
|
+
try:
|
|
153
|
+
return compare(attributeValue, conditionValue) >= 0
|
|
154
|
+
except Exception:
|
|
155
|
+
return False
|
|
156
|
+
elif operator == "$veq":
|
|
157
|
+
return paddedVersionString(attributeValue) == paddedVersionString(conditionValue)
|
|
158
|
+
elif operator == "$vne":
|
|
159
|
+
return paddedVersionString(attributeValue) != paddedVersionString(conditionValue)
|
|
160
|
+
elif operator == "$vlt":
|
|
161
|
+
return paddedVersionString(attributeValue) < paddedVersionString(conditionValue)
|
|
162
|
+
elif operator == "$vlte":
|
|
163
|
+
return paddedVersionString(attributeValue) <= paddedVersionString(conditionValue)
|
|
164
|
+
elif operator == "$vgt":
|
|
165
|
+
return paddedVersionString(attributeValue) > paddedVersionString(conditionValue)
|
|
166
|
+
elif operator == "$vgte":
|
|
167
|
+
return paddedVersionString(attributeValue) >= paddedVersionString(conditionValue)
|
|
168
|
+
elif operator == "$inGroup":
|
|
169
|
+
if not type(conditionValue) is str:
|
|
170
|
+
return False
|
|
171
|
+
if not conditionValue in savedGroups:
|
|
172
|
+
return False
|
|
173
|
+
return isIn(savedGroups[conditionValue] or [], attributeValue)
|
|
174
|
+
elif operator == "$notInGroup":
|
|
175
|
+
if not type(conditionValue) is str:
|
|
176
|
+
return False
|
|
177
|
+
if not conditionValue in savedGroups:
|
|
178
|
+
return True
|
|
179
|
+
return not isIn(savedGroups[conditionValue] or [], attributeValue)
|
|
180
|
+
elif operator == "$regex":
|
|
181
|
+
try:
|
|
182
|
+
r = re.compile(conditionValue)
|
|
183
|
+
return bool(r.search(attributeValue))
|
|
184
|
+
except Exception:
|
|
185
|
+
return False
|
|
186
|
+
elif operator == "$regexi":
|
|
187
|
+
try:
|
|
188
|
+
r = re.compile(conditionValue, re.IGNORECASE)
|
|
189
|
+
return bool(r.search(attributeValue))
|
|
190
|
+
except Exception:
|
|
191
|
+
return False
|
|
192
|
+
elif operator == "$notRegex":
|
|
193
|
+
try:
|
|
194
|
+
r = re.compile(conditionValue)
|
|
195
|
+
return not bool(r.search(attributeValue))
|
|
196
|
+
except Exception:
|
|
197
|
+
return False
|
|
198
|
+
elif operator == "$notRegexi":
|
|
199
|
+
try:
|
|
200
|
+
r = re.compile(conditionValue, re.IGNORECASE)
|
|
201
|
+
return not bool(r.search(attributeValue))
|
|
202
|
+
except Exception:
|
|
203
|
+
return False
|
|
204
|
+
elif operator == "$in":
|
|
205
|
+
if not type(conditionValue) is list:
|
|
206
|
+
return False
|
|
207
|
+
return isIn(conditionValue, attributeValue)
|
|
208
|
+
elif operator == "$nin":
|
|
209
|
+
if not type(conditionValue) is list:
|
|
210
|
+
return False
|
|
211
|
+
return not isIn(conditionValue, attributeValue)
|
|
212
|
+
elif operator == "$ini":
|
|
213
|
+
if not type(conditionValue) is list:
|
|
214
|
+
return False
|
|
215
|
+
return isIn(conditionValue, attributeValue, insensitive=True)
|
|
216
|
+
elif operator == "$nini":
|
|
217
|
+
if not type(conditionValue) is list:
|
|
218
|
+
return False
|
|
219
|
+
return not isIn(conditionValue, attributeValue, insensitive=True)
|
|
220
|
+
elif operator == "$elemMatch":
|
|
221
|
+
return elemMatch(conditionValue, attributeValue, savedGroups)
|
|
222
|
+
elif operator == "$size":
|
|
223
|
+
if not (type(attributeValue) is list):
|
|
224
|
+
return False
|
|
225
|
+
return evalConditionValue(conditionValue, len(attributeValue), savedGroups)
|
|
226
|
+
elif operator == "$all":
|
|
227
|
+
if not type(conditionValue) is list:
|
|
228
|
+
return False
|
|
229
|
+
return isInAll(conditionValue, attributeValue, savedGroups, insensitive=False)
|
|
230
|
+
elif operator == "$alli":
|
|
231
|
+
if not type(conditionValue) is list:
|
|
232
|
+
return False
|
|
233
|
+
return isInAll(conditionValue, attributeValue, savedGroups, insensitive=True)
|
|
234
|
+
elif operator == "$exists":
|
|
235
|
+
if not conditionValue:
|
|
236
|
+
return attributeValue is None
|
|
237
|
+
return attributeValue is not None
|
|
238
|
+
elif operator == "$type":
|
|
239
|
+
return bool(getType(attributeValue) == conditionValue)
|
|
240
|
+
elif operator == "$not":
|
|
241
|
+
return not evalConditionValue(conditionValue, attributeValue, savedGroups)
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
def paddedVersionString(input) -> str:
|
|
245
|
+
# If input is a number, convert to a string
|
|
246
|
+
if type(input) is int or type(input) is float:
|
|
247
|
+
input = str(input)
|
|
248
|
+
|
|
249
|
+
if not input or type(input) is not str:
|
|
250
|
+
input = "0"
|
|
251
|
+
|
|
252
|
+
# Remove build info and leading `v` if any
|
|
253
|
+
input = re.sub(r"(^v|\+.*$)", "", input)
|
|
254
|
+
# Split version into parts (both core version numbers and pre-release tags)
|
|
255
|
+
# "v1.2.3-rc.1+build123" -> ["1","2","3","rc","1"]
|
|
256
|
+
parts = re.split(r"[-.]", input)
|
|
257
|
+
# If it's SemVer without a pre-release, add `~` to the end
|
|
258
|
+
# ["1","0","0"] -> ["1","0","0","~"]
|
|
259
|
+
# "~" is the largest ASCII character, so this will make "1.0.0" greater than "1.0.0-beta" for example
|
|
260
|
+
if len(parts) == 3:
|
|
261
|
+
parts.append("~")
|
|
262
|
+
# Left pad each numeric part with spaces so string comparisons will work ("9">"10", but " 9"<"10")
|
|
263
|
+
# Then, join back together into a single string
|
|
264
|
+
return "-".join([v.rjust(5, " ") if re.match(r"^[0-9]+$", v) else v for v in parts])
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def isIn(conditionValue, attributeValue, insensitive: bool = False) -> bool:
|
|
268
|
+
if insensitive:
|
|
269
|
+
# Helper function to case-fold values (lowercase for strings)
|
|
270
|
+
def case_fold(val):
|
|
271
|
+
return val.lower() if type(val) is str else val
|
|
272
|
+
|
|
273
|
+
# Do an intersection if attribute is an array (insensitive)
|
|
274
|
+
if type(attributeValue) is list:
|
|
275
|
+
return any(
|
|
276
|
+
case_fold(el) == case_fold(exp)
|
|
277
|
+
for el in attributeValue
|
|
278
|
+
for exp in conditionValue
|
|
279
|
+
)
|
|
280
|
+
return any(case_fold(attributeValue) == case_fold(exp) for exp in conditionValue)
|
|
281
|
+
|
|
282
|
+
# Case-sensitive behavior (original)
|
|
283
|
+
if type(attributeValue) is list:
|
|
284
|
+
return bool(set(conditionValue) & set(attributeValue))
|
|
285
|
+
return attributeValue in conditionValue
|
|
286
|
+
|
|
287
|
+
def isInAll(conditionValue, attributeValue, savedGroups, insensitive: bool = False) -> bool:
|
|
288
|
+
"""Check if attributeValue (array) contains all elements in conditionValue"""
|
|
289
|
+
if not type(attributeValue) is list:
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
for cond in conditionValue:
|
|
293
|
+
passing = False
|
|
294
|
+
for attr in attributeValue:
|
|
295
|
+
if evalConditionValue(cond, attr, savedGroups, insensitive):
|
|
296
|
+
passing = True
|
|
297
|
+
break
|
|
298
|
+
if not passing:
|
|
299
|
+
return False
|
|
300
|
+
return True
|
|
301
|
+
|
|
302
|
+
def _getOrigHashValue(
|
|
303
|
+
eval_context: EvaluationContext,
|
|
304
|
+
attr: Optional[str] = "id",
|
|
305
|
+
fallbackAttr: Optional[str] = None
|
|
306
|
+
) -> Tuple[str, str]:
|
|
307
|
+
# attr = attr or "id" -- Fix for the flaky behavior of sticky bucket assignment
|
|
308
|
+
actual_attr: str = attr if attr is not None else ""
|
|
309
|
+
val = ""
|
|
310
|
+
|
|
311
|
+
if actual_attr in eval_context.user.attributes:
|
|
312
|
+
val = "" if eval_context.user.attributes[actual_attr] is None else eval_context.user.attributes[actual_attr]
|
|
313
|
+
|
|
314
|
+
# If no match, try fallback
|
|
315
|
+
if (not val or val == "") and fallbackAttr and eval_context.global_ctx.options.sticky_bucket_service:
|
|
316
|
+
if fallbackAttr in eval_context.user.attributes:
|
|
317
|
+
val = "" if eval_context.user.attributes[fallbackAttr] is None else eval_context.user.attributes[fallbackAttr]
|
|
318
|
+
|
|
319
|
+
if not val or val != "":
|
|
320
|
+
actual_attr = fallbackAttr
|
|
321
|
+
|
|
322
|
+
return (actual_attr, val)
|
|
323
|
+
|
|
324
|
+
def _getHashValue(eval_context: EvaluationContext, attr: Optional[str] = None, fallbackAttr: Optional[str] = None) -> Tuple[str, str]:
|
|
325
|
+
(attr, val) = _getOrigHashValue(attr=attr, fallbackAttr=fallbackAttr, eval_context=eval_context)
|
|
326
|
+
return (attr, str(val))
|
|
327
|
+
|
|
328
|
+
def _isIncludedInRollout(
|
|
329
|
+
seed: str,
|
|
330
|
+
eval_context: EvaluationContext,
|
|
331
|
+
hashAttribute: Optional[str] = None,
|
|
332
|
+
fallbackAttribute: Optional[str] = None,
|
|
333
|
+
range: Optional[Tuple[float, float]] = None,
|
|
334
|
+
coverage: Optional[float] = None,
|
|
335
|
+
hashVersion: Optional[int] = None
|
|
336
|
+
) -> bool:
|
|
337
|
+
if coverage is None and range is None:
|
|
338
|
+
return True
|
|
339
|
+
|
|
340
|
+
if coverage == 0 and range is None:
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
(_, hash_value) = _getHashValue(attr=hashAttribute, fallbackAttr=fallbackAttribute, eval_context=eval_context)
|
|
344
|
+
if hash_value == "":
|
|
345
|
+
return False
|
|
346
|
+
|
|
347
|
+
n = gbhash(seed, hash_value, hashVersion or 1)
|
|
348
|
+
if n is None:
|
|
349
|
+
return False
|
|
350
|
+
|
|
351
|
+
if range:
|
|
352
|
+
return inRange(n, range)
|
|
353
|
+
elif coverage is not None:
|
|
354
|
+
return n <= coverage
|
|
355
|
+
|
|
356
|
+
return True
|
|
357
|
+
|
|
358
|
+
def _isFilteredOut(filters: List[Filter], eval_context: EvaluationContext) -> bool:
|
|
359
|
+
for filter in filters:
|
|
360
|
+
(_, hash_value) = _getHashValue(attr=filter.get("attribute", "id"), eval_context=eval_context)
|
|
361
|
+
if hash_value == "":
|
|
362
|
+
return False
|
|
363
|
+
|
|
364
|
+
n = gbhash(filter.get("seed", ""), hash_value, filter.get("hashVersion", 2))
|
|
365
|
+
if n is None:
|
|
366
|
+
return False
|
|
367
|
+
|
|
368
|
+
filtered = False
|
|
369
|
+
for range in filter["ranges"]:
|
|
370
|
+
if inRange(n, range):
|
|
371
|
+
filtered = True
|
|
372
|
+
break
|
|
373
|
+
if not filtered:
|
|
374
|
+
return True
|
|
375
|
+
return False
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def fnv1a32(str: str) -> int:
|
|
379
|
+
hval = 0x811C9DC5
|
|
380
|
+
prime = 0x01000193
|
|
381
|
+
uint32_max = 2 ** 32
|
|
382
|
+
for s in str:
|
|
383
|
+
hval = hval ^ ord(s)
|
|
384
|
+
hval = (hval * prime) % uint32_max
|
|
385
|
+
return hval
|
|
386
|
+
|
|
387
|
+
def inNamespace(userId: str, namespace: Tuple[str, float, float]) -> bool:
|
|
388
|
+
n = gbhash("__" + namespace[0], userId, 1)
|
|
389
|
+
if n is None:
|
|
390
|
+
return False
|
|
391
|
+
return namespace[1] <= n < namespace[2]
|
|
392
|
+
|
|
393
|
+
def gbhash(seed: str, value: str, version: int) -> Optional[float]:
|
|
394
|
+
if version == 2:
|
|
395
|
+
n = fnv1a32(str(fnv1a32(seed + value)))
|
|
396
|
+
return (n % 10000) / 10000
|
|
397
|
+
if version == 1:
|
|
398
|
+
n = fnv1a32(value + seed)
|
|
399
|
+
return (n % 1000) / 1000
|
|
400
|
+
return None
|
|
401
|
+
|
|
402
|
+
def inRange(n: float, range: Tuple[float, float]) -> bool:
|
|
403
|
+
return range[0] <= n < range[1]
|
|
404
|
+
|
|
405
|
+
def chooseVariation(n: float, ranges: List[Tuple[float, float]]) -> int:
|
|
406
|
+
for i, r in enumerate(ranges):
|
|
407
|
+
if inRange(n, r):
|
|
408
|
+
return i
|
|
409
|
+
return -1
|
|
410
|
+
|
|
411
|
+
def getQueryStringOverride(id: str, url: str, numVariations: int) -> Optional[int]:
|
|
412
|
+
res = urlparse(url)
|
|
413
|
+
if not res.query:
|
|
414
|
+
return None
|
|
415
|
+
qs = parse_qs(res.query)
|
|
416
|
+
if id not in qs:
|
|
417
|
+
return None
|
|
418
|
+
variation = qs[id][0]
|
|
419
|
+
if variation is None or not variation.isdigit():
|
|
420
|
+
return None
|
|
421
|
+
varId = int(variation)
|
|
422
|
+
if varId < 0 or varId >= numVariations:
|
|
423
|
+
return None
|
|
424
|
+
return varId
|
|
425
|
+
|
|
426
|
+
def _urlIsValid(url: Optional[str], pattern: str) -> bool:
|
|
427
|
+
if not url: # it was self._url! Ignored the param passed in.
|
|
428
|
+
return False
|
|
429
|
+
|
|
430
|
+
try:
|
|
431
|
+
r = re.compile(pattern)
|
|
432
|
+
if r.search(url):
|
|
433
|
+
return True
|
|
434
|
+
|
|
435
|
+
pathOnly = re.sub(r"^[^/]*/", "/", re.sub(r"^https?:\/\/", "", url))
|
|
436
|
+
if r.search(pathOnly):
|
|
437
|
+
return True
|
|
438
|
+
return False
|
|
439
|
+
except Exception:
|
|
440
|
+
return True
|
|
441
|
+
|
|
442
|
+
def getEqualWeights(numVariations: int) -> List[float]:
|
|
443
|
+
if numVariations < 1:
|
|
444
|
+
return []
|
|
445
|
+
return [1 / numVariations for _ in range(numVariations)]
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def getBucketRanges(
|
|
449
|
+
numVariations: int, coverage: float = 1, weights: Optional[List[float]] = None
|
|
450
|
+
) -> List[Tuple[float, float]]:
|
|
451
|
+
if coverage < 0:
|
|
452
|
+
coverage = 0
|
|
453
|
+
if coverage > 1:
|
|
454
|
+
coverage = 1
|
|
455
|
+
if weights is None:
|
|
456
|
+
weights = getEqualWeights(numVariations)
|
|
457
|
+
if len(weights) != numVariations:
|
|
458
|
+
weights = getEqualWeights(numVariations)
|
|
459
|
+
if sum(weights) < 0.99 or sum(weights) > 1.01:
|
|
460
|
+
weights = getEqualWeights(numVariations)
|
|
461
|
+
|
|
462
|
+
cumulative: float = 0
|
|
463
|
+
ranges = []
|
|
464
|
+
for w in weights:
|
|
465
|
+
start = cumulative
|
|
466
|
+
cumulative += w
|
|
467
|
+
ranges.append((start, start + coverage * w))
|
|
468
|
+
|
|
469
|
+
return ranges
|
|
470
|
+
|
|
471
|
+
def eval_feature(
|
|
472
|
+
key: str,
|
|
473
|
+
evalContext: Optional[EvaluationContext] = None,
|
|
474
|
+
callback_subscription: Optional[Callable[[Experiment, Result], None]] = None,
|
|
475
|
+
tracking_cb: Optional[Callable[[Experiment, Result, UserContext], None]] = None
|
|
476
|
+
) -> FeatureResult:
|
|
477
|
+
"""Core feature evaluation logic as a standalone function"""
|
|
478
|
+
|
|
479
|
+
if evalContext is None:
|
|
480
|
+
raise ValueError("evalContext is required - eval_feature")
|
|
481
|
+
|
|
482
|
+
if key not in evalContext.global_ctx.features:
|
|
483
|
+
logger.warning("Unknown feature %s", key)
|
|
484
|
+
return FeatureResult(None, "unknownFeature")
|
|
485
|
+
|
|
486
|
+
if key in evalContext.stack.evaluated_features:
|
|
487
|
+
logger.warning("Cyclic prerequisite detected, stack: %s", evalContext.stack.evaluated_features)
|
|
488
|
+
return FeatureResult(None, "cyclicPrerequisite")
|
|
489
|
+
|
|
490
|
+
evalContext.stack.evaluated_features.add(key)
|
|
491
|
+
|
|
492
|
+
feature = evalContext.global_ctx.features[key]
|
|
493
|
+
|
|
494
|
+
evaluated_features = evalContext.stack.evaluated_features.copy()
|
|
495
|
+
|
|
496
|
+
for rule in feature.rules:
|
|
497
|
+
# Reset the stack for each rule
|
|
498
|
+
evalContext.stack.evaluated_features = evaluated_features.copy()
|
|
499
|
+
|
|
500
|
+
if (rule.parentConditions):
|
|
501
|
+
prereq_res = eval_prereqs(parentConditions=rule.parentConditions, evalContext=evalContext)
|
|
502
|
+
if prereq_res == "gate":
|
|
503
|
+
logger.debug("Top-level prerequisite failed, return None, feature %s", key)
|
|
504
|
+
return FeatureResult(None, "prerequisite")
|
|
505
|
+
if prereq_res == "cyclic":
|
|
506
|
+
# Warning already logged in this case
|
|
507
|
+
return FeatureResult(None, "cyclicPrerequisite")
|
|
508
|
+
if prereq_res == "fail":
|
|
509
|
+
logger.debug("Skip rule because of failing prerequisite, feature %s", key)
|
|
510
|
+
continue
|
|
511
|
+
|
|
512
|
+
if rule.condition:
|
|
513
|
+
if not evalCondition(evalContext.user.attributes, rule.condition, evalContext.global_ctx.saved_groups):
|
|
514
|
+
logger.debug(
|
|
515
|
+
"Skip rule because of failed condition, feature %s", key
|
|
516
|
+
)
|
|
517
|
+
continue
|
|
518
|
+
if rule.filters:
|
|
519
|
+
if _isFilteredOut(rule.filters, evalContext):
|
|
520
|
+
logger.debug(
|
|
521
|
+
"Skip rule because of filters/namespaces, feature %s", key
|
|
522
|
+
)
|
|
523
|
+
continue
|
|
524
|
+
if rule.force is not None:
|
|
525
|
+
if not _isIncludedInRollout(
|
|
526
|
+
seed=rule.seed or key,
|
|
527
|
+
hashAttribute=rule.hashAttribute,
|
|
528
|
+
fallbackAttribute=rule.fallbackAttribute,
|
|
529
|
+
range=rule.range,
|
|
530
|
+
coverage=rule.coverage,
|
|
531
|
+
hashVersion=rule.hashVersion,
|
|
532
|
+
eval_context=evalContext
|
|
533
|
+
):
|
|
534
|
+
logger.debug(
|
|
535
|
+
"Skip rule because user not included in percentage rollout, feature %s",
|
|
536
|
+
key,
|
|
537
|
+
)
|
|
538
|
+
continue
|
|
539
|
+
|
|
540
|
+
logger.debug("Force value from rule, feature %s", key)
|
|
541
|
+
return FeatureResult(rule.force, "force", ruleId=rule.id)
|
|
542
|
+
|
|
543
|
+
if rule.variations is None:
|
|
544
|
+
logger.warning("Skip invalid rule, feature %s", key)
|
|
545
|
+
continue
|
|
546
|
+
|
|
547
|
+
exp = Experiment(
|
|
548
|
+
key=rule.key or key,
|
|
549
|
+
variations=rule.variations,
|
|
550
|
+
coverage=rule.coverage,
|
|
551
|
+
weights=rule.weights,
|
|
552
|
+
hashAttribute=rule.hashAttribute,
|
|
553
|
+
fallbackAttribute=rule.fallbackAttribute,
|
|
554
|
+
namespace=rule.namespace,
|
|
555
|
+
hashVersion=rule.hashVersion,
|
|
556
|
+
meta=rule.meta,
|
|
557
|
+
ranges=rule.ranges,
|
|
558
|
+
name=rule.name,
|
|
559
|
+
phase=rule.phase,
|
|
560
|
+
seed=rule.seed,
|
|
561
|
+
filters=rule.filters,
|
|
562
|
+
condition=rule.condition,
|
|
563
|
+
disableStickyBucketing=rule.disableStickyBucketing,
|
|
564
|
+
bucketVersion=rule.bucketVersion,
|
|
565
|
+
minBucketVersion=rule.minBucketVersion,
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
result = run_experiment(experiment=exp, featureId=key, evalContext=evalContext, tracking_cb=tracking_cb)
|
|
569
|
+
|
|
570
|
+
if callback_subscription:
|
|
571
|
+
callback_subscription(exp, result)
|
|
572
|
+
|
|
573
|
+
if not result.inExperiment:
|
|
574
|
+
logger.debug(
|
|
575
|
+
"Skip rule because user not included in experiment, feature %s", key
|
|
576
|
+
)
|
|
577
|
+
continue
|
|
578
|
+
|
|
579
|
+
if result.passthrough:
|
|
580
|
+
logger.debug("Continue to next rule, feature %s", key)
|
|
581
|
+
continue
|
|
582
|
+
|
|
583
|
+
logger.debug("Assign value from experiment, feature %s", key)
|
|
584
|
+
return FeatureResult(
|
|
585
|
+
result.value, "experiment", exp, result, ruleId=rule.id
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
logger.debug("Use default value for feature %s", key)
|
|
589
|
+
return FeatureResult(feature.defaultValue, "defaultValue")
|
|
590
|
+
|
|
591
|
+
def eval_prereqs(parentConditions: List[dict], evalContext: EvaluationContext) -> str:
|
|
592
|
+
evaluated_features = evalContext.stack.evaluated_features.copy()
|
|
593
|
+
|
|
594
|
+
for parentCondition in parentConditions:
|
|
595
|
+
# Reset the stack in each iteration
|
|
596
|
+
evalContext.stack.evaluated_features = evaluated_features.copy()
|
|
597
|
+
|
|
598
|
+
parent_id = parentCondition.get("id")
|
|
599
|
+
if parent_id is None:
|
|
600
|
+
continue # Skip if no valid ID
|
|
601
|
+
|
|
602
|
+
parentRes = eval_feature(key=parent_id, evalContext=evalContext)
|
|
603
|
+
|
|
604
|
+
if parentRes.source == "cyclicPrerequisite":
|
|
605
|
+
return "cyclic"
|
|
606
|
+
|
|
607
|
+
parent_condition = parentCondition.get("condition")
|
|
608
|
+
if parent_condition is None:
|
|
609
|
+
continue # Skip if no valid condition
|
|
610
|
+
|
|
611
|
+
if not evalCondition({'value': parentRes.value}, parent_condition, evalContext.global_ctx.saved_groups):
|
|
612
|
+
if parentCondition.get("gate", False):
|
|
613
|
+
return "gate"
|
|
614
|
+
return "fail"
|
|
615
|
+
return "pass"
|
|
616
|
+
|
|
617
|
+
def _get_sticky_bucket_experiment_key(experiment_key: str, bucket_version: int = 0) -> str:
|
|
618
|
+
return experiment_key + "__" + str(bucket_version)
|
|
619
|
+
|
|
620
|
+
def _get_sticky_bucket_assignments(evalContext: EvaluationContext,
|
|
621
|
+
attr: Optional[str] = None,
|
|
622
|
+
fallback: Optional[str] = None) -> Dict[str, str]:
|
|
623
|
+
merged: Dict[str, str] = {}
|
|
624
|
+
|
|
625
|
+
# Search for docs stored for attribute(id)
|
|
626
|
+
_, hashValue = _getHashValue(attr=attr, eval_context=evalContext)
|
|
627
|
+
key = f"{attr}||{hashValue}"
|
|
628
|
+
if key in evalContext.user.sticky_bucket_assignment_docs:
|
|
629
|
+
merged = evalContext.user.sticky_bucket_assignment_docs[key].get("assignments", {})
|
|
630
|
+
|
|
631
|
+
# Search for docs stored for fallback attribute
|
|
632
|
+
if fallback:
|
|
633
|
+
_, hashValue = _getHashValue(fallbackAttr=fallback, eval_context=evalContext)
|
|
634
|
+
key = f"{fallback}||{hashValue}"
|
|
635
|
+
if key in evalContext.user.sticky_bucket_assignment_docs:
|
|
636
|
+
# Merge the fallback assignments, but don't overwrite existing ones
|
|
637
|
+
for k, v in evalContext.user.sticky_bucket_assignment_docs[key].get("assignments", {}).items():
|
|
638
|
+
if k not in merged:
|
|
639
|
+
merged[k] = v
|
|
640
|
+
|
|
641
|
+
return merged
|
|
642
|
+
|
|
643
|
+
def _is_blocked(
|
|
644
|
+
assignments: Dict[str, str],
|
|
645
|
+
experiment_key: str,
|
|
646
|
+
min_bucket_version: int
|
|
647
|
+
) -> bool:
|
|
648
|
+
if min_bucket_version > 0:
|
|
649
|
+
for i in range(min_bucket_version):
|
|
650
|
+
blocked_key = _get_sticky_bucket_experiment_key(experiment_key, i)
|
|
651
|
+
if blocked_key in assignments:
|
|
652
|
+
return True
|
|
653
|
+
return False
|
|
654
|
+
|
|
655
|
+
def _get_sticky_bucket_variation(
|
|
656
|
+
experiment_key: str,
|
|
657
|
+
evalContext: EvaluationContext,
|
|
658
|
+
bucket_version: Optional[int] = None,
|
|
659
|
+
min_bucket_version: Optional[int] = None,
|
|
660
|
+
meta: Optional[List[VariationMeta]] = None,
|
|
661
|
+
hash_attribute: Optional[str] = None,
|
|
662
|
+
fallback_attribute: Optional[str] = None,
|
|
663
|
+
) -> Dict[str, Any]:
|
|
664
|
+
bucket_version = bucket_version or 0
|
|
665
|
+
min_bucket_version = min_bucket_version or 0
|
|
666
|
+
meta = meta or []
|
|
667
|
+
|
|
668
|
+
id = _get_sticky_bucket_experiment_key(experiment_key, bucket_version)
|
|
669
|
+
|
|
670
|
+
assignments = _get_sticky_bucket_assignments(attr=hash_attribute, fallback=fallback_attribute, evalContext=evalContext)
|
|
671
|
+
if _is_blocked(assignments, experiment_key, min_bucket_version):
|
|
672
|
+
return {
|
|
673
|
+
'variation': -1,
|
|
674
|
+
'versionIsBlocked': True
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
variation_key = assignments.get(id, None)
|
|
678
|
+
if not variation_key:
|
|
679
|
+
return {
|
|
680
|
+
'variation': -1
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
# Find the key in meta
|
|
684
|
+
variation = next((i for i, v in enumerate(meta) if v.get("key") == variation_key), -1)
|
|
685
|
+
if variation < 0:
|
|
686
|
+
return {
|
|
687
|
+
'variation': -1
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return {'variation': variation}
|
|
691
|
+
|
|
692
|
+
def run_experiment(experiment: Experiment,
|
|
693
|
+
featureId: Optional[str] = None,
|
|
694
|
+
evalContext: Optional[EvaluationContext] = None,
|
|
695
|
+
tracking_cb: Optional[Callable[[Experiment, Result, UserContext], None]] = None
|
|
696
|
+
) -> Result:
|
|
697
|
+
if evalContext is None:
|
|
698
|
+
raise ValueError("evalContext is required - run_experiment")
|
|
699
|
+
# 1. If experiment has less than 2 variations, return immediately
|
|
700
|
+
if len(experiment.variations) < 2:
|
|
701
|
+
logger.warning(
|
|
702
|
+
"Experiment %s has less than 2 variations, skip", experiment.key
|
|
703
|
+
)
|
|
704
|
+
return _getExperimentResult(experiment=experiment, featureId=featureId, evalContext=evalContext)
|
|
705
|
+
# 2. If growthbook is disabled, return immediately
|
|
706
|
+
if not evalContext.global_ctx.options.enabled:
|
|
707
|
+
logger.debug(
|
|
708
|
+
"Skip experiment %s because GrowthBook is disabled", experiment.key
|
|
709
|
+
)
|
|
710
|
+
return _getExperimentResult(experiment=experiment, featureId=featureId, evalContext=evalContext)
|
|
711
|
+
# 2.5. If the experiment props have been overridden, merge them in
|
|
712
|
+
if evalContext.user.overrides.get(experiment.key, None):
|
|
713
|
+
experiment.update(evalContext.user.overrides[experiment.key])
|
|
714
|
+
# 3. If experiment is forced via a querystring in the url
|
|
715
|
+
qs = getQueryStringOverride(
|
|
716
|
+
experiment.key, evalContext.user.url, len(experiment.variations)
|
|
717
|
+
)
|
|
718
|
+
if qs is not None:
|
|
719
|
+
logger.debug(
|
|
720
|
+
"Force variation %d from URL querystring, experiment %s",
|
|
721
|
+
qs,
|
|
722
|
+
experiment.key,
|
|
723
|
+
)
|
|
724
|
+
return _getExperimentResult(experiment=experiment, variationId=qs, featureId=featureId, evalContext=evalContext)
|
|
725
|
+
# 4. If variation is forced in the context
|
|
726
|
+
if evalContext.user.forced_variations.get(experiment.key, None) is not None:
|
|
727
|
+
logger.debug(
|
|
728
|
+
"Force variation %d from GrowthBook context, experiment %s",
|
|
729
|
+
evalContext.user.forced_variations[experiment.key],
|
|
730
|
+
experiment.key,
|
|
731
|
+
)
|
|
732
|
+
return _getExperimentResult(
|
|
733
|
+
experiment=experiment, variationId=evalContext.user.forced_variations[experiment.key], featureId=featureId, evalContext=evalContext
|
|
734
|
+
)
|
|
735
|
+
# 5. If experiment is a draft or not active, return immediately
|
|
736
|
+
if experiment.status == "draft" or not experiment.active:
|
|
737
|
+
logger.debug("Experiment %s is not active, skip", experiment.key)
|
|
738
|
+
return _getExperimentResult(experiment=experiment, featureId=featureId, evalContext=evalContext)
|
|
739
|
+
|
|
740
|
+
# 6. Get the user hash attribute and value
|
|
741
|
+
(hashAttribute, hashValue) = _getHashValue(attr=experiment.hashAttribute, fallbackAttr=experiment.fallbackAttribute, eval_context=evalContext)
|
|
742
|
+
if not hashValue:
|
|
743
|
+
logger.debug(
|
|
744
|
+
"Skip experiment %s because user's hashAttribute value is empty",
|
|
745
|
+
experiment.key,
|
|
746
|
+
)
|
|
747
|
+
return _getExperimentResult(experiment=experiment, featureId=featureId, evalContext=evalContext)
|
|
748
|
+
|
|
749
|
+
assigned = -1
|
|
750
|
+
|
|
751
|
+
found_sticky_bucket = False
|
|
752
|
+
sticky_bucket_version_is_blocked = False
|
|
753
|
+
if evalContext.global_ctx.options.sticky_bucket_service and not experiment.disableStickyBucketing:
|
|
754
|
+
sticky_bucket = _get_sticky_bucket_variation(
|
|
755
|
+
experiment_key=experiment.key,
|
|
756
|
+
bucket_version=experiment.bucketVersion,
|
|
757
|
+
min_bucket_version=experiment.minBucketVersion,
|
|
758
|
+
meta=experiment.meta,
|
|
759
|
+
hash_attribute=experiment.hashAttribute,
|
|
760
|
+
fallback_attribute=experiment.fallbackAttribute,
|
|
761
|
+
evalContext=evalContext
|
|
762
|
+
)
|
|
763
|
+
found_sticky_bucket = sticky_bucket.get('variation', 0) >= 0
|
|
764
|
+
assigned = sticky_bucket.get('variation', 0)
|
|
765
|
+
sticky_bucket_version_is_blocked = sticky_bucket.get('versionIsBlocked', False)
|
|
766
|
+
|
|
767
|
+
if found_sticky_bucket:
|
|
768
|
+
logger.debug("Found sticky bucket for experiment %s, assigning sticky variation %s", experiment.key, assigned)
|
|
769
|
+
|
|
770
|
+
# Some checks are not needed if we already have a sticky bucket
|
|
771
|
+
if not found_sticky_bucket:
|
|
772
|
+
# 7. Filtered out / not in namespace
|
|
773
|
+
if experiment.filters:
|
|
774
|
+
if _isFilteredOut(experiment.filters, evalContext):
|
|
775
|
+
logger.debug(
|
|
776
|
+
"Skip experiment %s because of filters/namespaces", experiment.key
|
|
777
|
+
)
|
|
778
|
+
return _getExperimentResult(experiment=experiment, featureId=featureId, evalContext=evalContext)
|
|
779
|
+
elif experiment.namespace and not inNamespace(hashValue, experiment.namespace):
|
|
780
|
+
logger.debug("Skip experiment %s because of namespace", experiment.key)
|
|
781
|
+
return _getExperimentResult(experiment=experiment, featureId=featureId, evalContext=evalContext)
|
|
782
|
+
|
|
783
|
+
# 7.5. If experiment has an include property
|
|
784
|
+
if experiment.include:
|
|
785
|
+
try:
|
|
786
|
+
if not experiment.include():
|
|
787
|
+
logger.debug(
|
|
788
|
+
"Skip experiment %s because include() returned false",
|
|
789
|
+
experiment.key,
|
|
790
|
+
)
|
|
791
|
+
return _getExperimentResult(experiment=experiment, featureId=featureId, evalContext=evalContext)
|
|
792
|
+
except Exception:
|
|
793
|
+
logger.warning(
|
|
794
|
+
"Skip experiment %s because include() raised an Exception",
|
|
795
|
+
experiment.key,
|
|
796
|
+
)
|
|
797
|
+
return _getExperimentResult(experiment=experiment, featureId=featureId, evalContext=evalContext)
|
|
798
|
+
|
|
799
|
+
# 8. Exclude if condition is false
|
|
800
|
+
if experiment.condition and not evalCondition(
|
|
801
|
+
evalContext.user.attributes, experiment.condition, evalContext.global_ctx.saved_groups
|
|
802
|
+
):
|
|
803
|
+
logger.debug(
|
|
804
|
+
"Skip experiment %s because user failed the condition", experiment.key
|
|
805
|
+
)
|
|
806
|
+
return _getExperimentResult(experiment=experiment, featureId=featureId, evalContext=evalContext)
|
|
807
|
+
|
|
808
|
+
# 8.05 Exclude if parent conditions are not met
|
|
809
|
+
if (experiment.parentConditions):
|
|
810
|
+
prereq_res = eval_prereqs(parentConditions=experiment.parentConditions, evalContext=evalContext)
|
|
811
|
+
if prereq_res == "gate" or prereq_res == "fail":
|
|
812
|
+
logger.debug("Skip experiment %s because of failing prerequisite", experiment.key)
|
|
813
|
+
return _getExperimentResult(experiment=experiment, featureId=featureId, evalContext=evalContext)
|
|
814
|
+
if prereq_res == "cyclic":
|
|
815
|
+
logger.debug("Skip experiment %s because of cyclic prerequisite", experiment.key)
|
|
816
|
+
return _getExperimentResult(experiment=experiment, featureId=featureId, evalContext=evalContext)
|
|
817
|
+
|
|
818
|
+
# 8.1. Make sure user is in a matching group
|
|
819
|
+
if experiment.groups and len(experiment.groups):
|
|
820
|
+
expGroups = evalContext.user.groups or {}
|
|
821
|
+
matched = False
|
|
822
|
+
for group in experiment.groups:
|
|
823
|
+
if expGroups[group]:
|
|
824
|
+
matched = True
|
|
825
|
+
if not matched:
|
|
826
|
+
logger.debug(
|
|
827
|
+
"Skip experiment %s because user not in required group",
|
|
828
|
+
experiment.key,
|
|
829
|
+
)
|
|
830
|
+
return _getExperimentResult(experiment=experiment, featureId=featureId, evalContext=evalContext)
|
|
831
|
+
|
|
832
|
+
# The following apply even when in a sticky bucket
|
|
833
|
+
|
|
834
|
+
# 8.2. If experiment.url is set, see if it's valid
|
|
835
|
+
if experiment.url:
|
|
836
|
+
if not _urlIsValid(url=evalContext.global_ctx.options.url, pattern=experiment.url):
|
|
837
|
+
logger.debug(
|
|
838
|
+
"Skip experiment %s because current URL is not targeted",
|
|
839
|
+
experiment.key,
|
|
840
|
+
)
|
|
841
|
+
return _getExperimentResult(experiment=experiment, featureId=featureId, evalContext=evalContext)
|
|
842
|
+
|
|
843
|
+
# 9. Get bucket ranges and choose variation
|
|
844
|
+
n = gbhash(
|
|
845
|
+
experiment.seed or experiment.key, hashValue, experiment.hashVersion or 1
|
|
846
|
+
)
|
|
847
|
+
if n is None:
|
|
848
|
+
logger.warning(
|
|
849
|
+
"Skip experiment %s because of invalid hashVersion", experiment.key
|
|
850
|
+
)
|
|
851
|
+
return _getExperimentResult(experiment=experiment, featureId=featureId, evalContext=evalContext)
|
|
852
|
+
|
|
853
|
+
if not found_sticky_bucket:
|
|
854
|
+
c = experiment.coverage
|
|
855
|
+
ranges = experiment.ranges or getBucketRanges(
|
|
856
|
+
len(experiment.variations), c if c is not None else 1, experiment.weights
|
|
857
|
+
)
|
|
858
|
+
assigned = chooseVariation(n, ranges)
|
|
859
|
+
|
|
860
|
+
# Unenroll if any prior sticky buckets are blocked by version
|
|
861
|
+
if sticky_bucket_version_is_blocked:
|
|
862
|
+
logger.debug("Skip experiment %s because sticky bucket version is blocked", experiment.key)
|
|
863
|
+
return _getExperimentResult(experiment=experiment, featureId=featureId, stickyBucketUsed=True, evalContext=evalContext)
|
|
864
|
+
|
|
865
|
+
# 10. Return if not in experiment
|
|
866
|
+
if assigned < 0:
|
|
867
|
+
logger.debug(
|
|
868
|
+
"Skip experiment %s because user is not included in the rollout",
|
|
869
|
+
experiment.key,
|
|
870
|
+
)
|
|
871
|
+
return _getExperimentResult(experiment=experiment, featureId=featureId, evalContext=evalContext)
|
|
872
|
+
|
|
873
|
+
# 11. If experiment is forced, return immediately
|
|
874
|
+
if experiment.force is not None:
|
|
875
|
+
logger.debug(
|
|
876
|
+
"Force variation %d in experiment %s", experiment.force, experiment.key
|
|
877
|
+
)
|
|
878
|
+
return _getExperimentResult(
|
|
879
|
+
experiment=experiment, variationId=experiment.force, featureId=featureId, evalContext=evalContext
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
# 12. Exclude if in QA mode (global)
|
|
883
|
+
if evalContext.global_ctx.options.qa_mode:
|
|
884
|
+
logger.debug("Skip experiment %s because of QA Mode", experiment.key)
|
|
885
|
+
return _getExperimentResult(experiment=experiment, featureId=featureId, evalContext=evalContext)
|
|
886
|
+
|
|
887
|
+
# 12.1. Exclude if user has skip_all_experiments flag set
|
|
888
|
+
if evalContext.user.skip_all_experiments:
|
|
889
|
+
logger.debug("Skip experiment %s because user has skip_all_experiments flag set", experiment.key)
|
|
890
|
+
return _getExperimentResult(experiment=experiment, featureId=featureId, evalContext=evalContext)
|
|
891
|
+
|
|
892
|
+
# 12.5. If experiment is stopped, return immediately
|
|
893
|
+
if experiment.status == "stopped":
|
|
894
|
+
logger.debug("Skip experiment %s because it is stopped", experiment.key)
|
|
895
|
+
return _getExperimentResult(experiment=experiment, featureId=featureId, evalContext=evalContext)
|
|
896
|
+
|
|
897
|
+
# 13. Build the result object
|
|
898
|
+
result = _getExperimentResult(
|
|
899
|
+
experiment=experiment, variationId=assigned, hashUsed=True, featureId=featureId, bucket=n, stickyBucketUsed=found_sticky_bucket, evalContext=evalContext
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
# 13.5 Persist sticky bucket
|
|
903
|
+
if evalContext.global_ctx.options.sticky_bucket_service and not experiment.disableStickyBucketing:
|
|
904
|
+
assignment = {}
|
|
905
|
+
assignment[_get_sticky_bucket_experiment_key(
|
|
906
|
+
experiment.key,
|
|
907
|
+
experiment.bucketVersion
|
|
908
|
+
)] = result.key
|
|
909
|
+
|
|
910
|
+
data = _generate_sticky_bucket_assignment_doc(
|
|
911
|
+
attribute_name=hashAttribute,
|
|
912
|
+
attribute_value=hashValue,
|
|
913
|
+
assignments=assignment,
|
|
914
|
+
evalContext=evalContext
|
|
915
|
+
)
|
|
916
|
+
doc = data.get("doc", None)
|
|
917
|
+
if doc and data.get('changed', False):
|
|
918
|
+
if not evalContext.user.sticky_bucket_assignment_docs:
|
|
919
|
+
evalContext.user.sticky_bucket_assignment_docs = {}
|
|
920
|
+
evalContext.user.sticky_bucket_assignment_docs[data.get('key')] = doc
|
|
921
|
+
evalContext.global_ctx.options.sticky_bucket_service.save_assignments(doc)
|
|
922
|
+
|
|
923
|
+
# 14. Fire the tracking callback if set
|
|
924
|
+
if tracking_cb:
|
|
925
|
+
tracking_cb(experiment, result, evalContext.user)
|
|
926
|
+
|
|
927
|
+
# 15. Return the result
|
|
928
|
+
logger.debug("Assigned variation %d in experiment %s", assigned, experiment.key)
|
|
929
|
+
return result
|
|
930
|
+
|
|
931
|
+
def _generate_sticky_bucket_assignment_doc(attribute_name: str, attribute_value: str, assignments: dict, evalContext: EvaluationContext):
|
|
932
|
+
key = attribute_name + "||" + attribute_value
|
|
933
|
+
existing_assignments = evalContext.user.sticky_bucket_assignment_docs.get(key, {}).get("assignments", {})
|
|
934
|
+
|
|
935
|
+
new_assignments = {**existing_assignments, **assignments}
|
|
936
|
+
|
|
937
|
+
# Compare JSON strings to see if they have changed
|
|
938
|
+
existing_json = json.dumps(existing_assignments, sort_keys=True)
|
|
939
|
+
new_json = json.dumps(new_assignments, sort_keys=True)
|
|
940
|
+
changed = existing_json != new_json
|
|
941
|
+
|
|
942
|
+
return {
|
|
943
|
+
'key': key,
|
|
944
|
+
'doc': {
|
|
945
|
+
'attributeName': attribute_name,
|
|
946
|
+
'attributeValue': attribute_value,
|
|
947
|
+
'assignments': new_assignments
|
|
948
|
+
},
|
|
949
|
+
'changed': changed
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
def _getExperimentResult(
|
|
953
|
+
experiment: Experiment,
|
|
954
|
+
evalContext: EvaluationContext,
|
|
955
|
+
variationId: int = -1,
|
|
956
|
+
hashUsed: bool = False,
|
|
957
|
+
featureId: Optional[str] = None,
|
|
958
|
+
bucket: Optional[float] = None,
|
|
959
|
+
stickyBucketUsed: bool = False
|
|
960
|
+
) -> Result:
|
|
961
|
+
inExperiment = True
|
|
962
|
+
if variationId < 0 or variationId > len(experiment.variations) - 1:
|
|
963
|
+
variationId = 0
|
|
964
|
+
inExperiment = False
|
|
965
|
+
|
|
966
|
+
meta = None
|
|
967
|
+
if experiment.meta:
|
|
968
|
+
meta = experiment.meta[variationId]
|
|
969
|
+
|
|
970
|
+
(hashAttribute, hashValue) = _getOrigHashValue(attr=experiment.hashAttribute,
|
|
971
|
+
fallbackAttr=experiment.fallbackAttribute,
|
|
972
|
+
eval_context=evalContext)
|
|
973
|
+
|
|
974
|
+
return Result(
|
|
975
|
+
featureId=featureId,
|
|
976
|
+
inExperiment=inExperiment,
|
|
977
|
+
variationId=variationId,
|
|
978
|
+
value=experiment.variations[variationId],
|
|
979
|
+
hashUsed=hashUsed,
|
|
980
|
+
hashAttribute=hashAttribute,
|
|
981
|
+
hashValue=hashValue,
|
|
982
|
+
meta=meta,
|
|
983
|
+
bucket=bucket,
|
|
984
|
+
stickyBucketUsed=stickyBucketUsed
|
|
985
|
+
)
|