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