growthbook 1.4.6__py2.py3-none-any.whl → 1.4.8__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 +1 -1
- growthbook/common_types.py +58 -55
- growthbook/core.py +34 -29
- growthbook/growthbook.py +159 -58
- growthbook/growthbook_client.py +2 -2
- growthbook/plugins/growthbook_tracking.py +1 -1
- growthbook/plugins/request_context.py +2 -1
- {growthbook-1.4.6.dist-info → growthbook-1.4.8.dist-info}/METADATA +20 -1
- growthbook-1.4.8.dist-info/RECORD +15 -0
- growthbook-1.4.6.dist-info/RECORD +0 -15
- {growthbook-1.4.6.dist-info → growthbook-1.4.8.dist-info}/WHEEL +0 -0
- {growthbook-1.4.6.dist-info → growthbook-1.4.8.dist-info}/licenses/LICENSE +0 -0
- {growthbook-1.4.6.dist-info → growthbook-1.4.8.dist-info}/top_level.txt +0 -0
growthbook/__init__.py
CHANGED
growthbook/common_types.py
CHANGED
|
@@ -29,30 +29,30 @@ class Experiment(object):
|
|
|
29
29
|
def __init__(
|
|
30
30
|
self,
|
|
31
31
|
key: str,
|
|
32
|
-
variations:
|
|
33
|
-
weights: List[float] = None,
|
|
32
|
+
variations: List[Any],
|
|
33
|
+
weights: Optional[List[float]] = None,
|
|
34
34
|
active: bool = True,
|
|
35
35
|
status: str = "running",
|
|
36
|
-
coverage:
|
|
37
|
-
condition:
|
|
38
|
-
namespace: Tuple[str, float, float] = None,
|
|
36
|
+
coverage: Optional[float] = None,
|
|
37
|
+
condition: Optional[Dict[str, Any]] = None,
|
|
38
|
+
namespace: Optional[Tuple[str, float, float]] = None,
|
|
39
39
|
url: str = "",
|
|
40
|
-
include=None,
|
|
41
|
-
groups:
|
|
42
|
-
force: int = None,
|
|
40
|
+
include: Optional[Any] = None,
|
|
41
|
+
groups: Optional[List[Any]] = None,
|
|
42
|
+
force: Optional[int] = None,
|
|
43
43
|
hashAttribute: str = "id",
|
|
44
|
-
fallbackAttribute: str = None,
|
|
45
|
-
hashVersion: int = None,
|
|
46
|
-
ranges: List[Tuple[float, float]] = None,
|
|
47
|
-
meta: List[VariationMeta] = None,
|
|
48
|
-
filters: List[Filter] = None,
|
|
49
|
-
seed: str = None,
|
|
50
|
-
name: str = None,
|
|
51
|
-
phase: str = None,
|
|
44
|
+
fallbackAttribute: Optional[str] = None,
|
|
45
|
+
hashVersion: Optional[int] = None,
|
|
46
|
+
ranges: Optional[List[Tuple[float, float]]] = None,
|
|
47
|
+
meta: Optional[List[VariationMeta]] = None,
|
|
48
|
+
filters: Optional[List[Filter]] = None,
|
|
49
|
+
seed: Optional[str] = None,
|
|
50
|
+
name: Optional[str] = None,
|
|
51
|
+
phase: Optional[str] = None,
|
|
52
52
|
disableStickyBucketing: bool = False,
|
|
53
|
-
bucketVersion: int = None,
|
|
54
|
-
minBucketVersion: int = None,
|
|
55
|
-
parentConditions: List[
|
|
53
|
+
bucketVersion: Optional[int] = None,
|
|
54
|
+
minBucketVersion: Optional[int] = None,
|
|
55
|
+
parentConditions: Optional[List[Dict[str, Any]]] = None,
|
|
56
56
|
) -> None:
|
|
57
57
|
self.key = key
|
|
58
58
|
self.variations = variations
|
|
@@ -85,8 +85,8 @@ class Experiment(object):
|
|
|
85
85
|
self.include = include
|
|
86
86
|
self.groups = groups
|
|
87
87
|
|
|
88
|
-
def to_dict(self):
|
|
89
|
-
obj = {
|
|
88
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
89
|
+
obj: Dict[str, Any] = {
|
|
90
90
|
"key": self.key,
|
|
91
91
|
"variations": self.variations,
|
|
92
92
|
"weights": self.weights,
|
|
@@ -118,7 +118,7 @@ class Experiment(object):
|
|
|
118
118
|
|
|
119
119
|
return obj
|
|
120
120
|
|
|
121
|
-
def update(self, data:
|
|
121
|
+
def update(self, data: Dict[str, Any]) -> None:
|
|
122
122
|
weights = data.get("weights", None)
|
|
123
123
|
status = data.get("status", None)
|
|
124
124
|
coverage = data.get("coverage", None)
|
|
@@ -145,13 +145,13 @@ class Result(object):
|
|
|
145
145
|
self,
|
|
146
146
|
variationId: int,
|
|
147
147
|
inExperiment: bool,
|
|
148
|
-
value,
|
|
148
|
+
value: Any,
|
|
149
149
|
hashUsed: bool,
|
|
150
150
|
hashAttribute: str,
|
|
151
151
|
hashValue: str,
|
|
152
152
|
featureId: Optional[str],
|
|
153
|
-
meta: VariationMeta = None,
|
|
154
|
-
bucket: float = None,
|
|
153
|
+
meta: Optional[VariationMeta] = None,
|
|
154
|
+
bucket: Optional[float] = None,
|
|
155
155
|
stickyBucketUsed: bool = False,
|
|
156
156
|
) -> None:
|
|
157
157
|
self.variationId = variationId
|
|
@@ -176,8 +176,8 @@ class Result(object):
|
|
|
176
176
|
if "passthrough" in meta:
|
|
177
177
|
self.passthrough = meta["passthrough"]
|
|
178
178
|
|
|
179
|
-
def to_dict(self) ->
|
|
180
|
-
obj = {
|
|
179
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
180
|
+
obj: Dict[str, Any] = {
|
|
181
181
|
"featureId": self.featureId,
|
|
182
182
|
"variationId": self.variationId,
|
|
183
183
|
"inExperiment": self.inExperiment,
|
|
@@ -201,11 +201,11 @@ class Result(object):
|
|
|
201
201
|
class FeatureResult(object):
|
|
202
202
|
def __init__(
|
|
203
203
|
self,
|
|
204
|
-
value,
|
|
204
|
+
value: Any,
|
|
205
205
|
source: str,
|
|
206
|
-
experiment: Experiment = None,
|
|
207
|
-
experimentResult: Result = None,
|
|
208
|
-
ruleId: str = None,
|
|
206
|
+
experiment: Optional[Experiment] = None,
|
|
207
|
+
experimentResult: Optional[Result] = None,
|
|
208
|
+
ruleId: Optional[str] = None,
|
|
209
209
|
) -> None:
|
|
210
210
|
self.value = value
|
|
211
211
|
self.source = source
|
|
@@ -215,8 +215,8 @@ class FeatureResult(object):
|
|
|
215
215
|
self.on = bool(value)
|
|
216
216
|
self.off = not bool(value)
|
|
217
217
|
|
|
218
|
-
def to_dict(self) ->
|
|
219
|
-
data = {
|
|
218
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
219
|
+
data: Dict[str, Any] = {
|
|
220
220
|
"value": self.value,
|
|
221
221
|
"source": self.source,
|
|
222
222
|
"on": self.on,
|
|
@@ -231,7 +231,9 @@ class FeatureResult(object):
|
|
|
231
231
|
return data
|
|
232
232
|
|
|
233
233
|
class Feature(object):
|
|
234
|
-
def __init__(self, defaultValue=None, rules:
|
|
234
|
+
def __init__(self, defaultValue: Any = None, rules: Optional[List[Any]] = None) -> None:
|
|
235
|
+
if rules is None:
|
|
236
|
+
rules = []
|
|
235
237
|
self.defaultValue = defaultValue
|
|
236
238
|
self.rules: List[FeatureRule] = []
|
|
237
239
|
for rule in rules:
|
|
@@ -263,7 +265,7 @@ class Feature(object):
|
|
|
263
265
|
parentConditions=rule.get("parentConditions", None),
|
|
264
266
|
))
|
|
265
267
|
|
|
266
|
-
def to_dict(self) ->
|
|
268
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
267
269
|
return {
|
|
268
270
|
"defaultValue": self.defaultValue,
|
|
269
271
|
"rules": [rule.to_dict() for rule in self.rules],
|
|
@@ -272,28 +274,28 @@ class Feature(object):
|
|
|
272
274
|
class FeatureRule(object):
|
|
273
275
|
def __init__(
|
|
274
276
|
self,
|
|
275
|
-
id: str = None,
|
|
277
|
+
id: Optional[str] = None,
|
|
276
278
|
key: str = "",
|
|
277
|
-
variations:
|
|
278
|
-
weights: List[float] = None,
|
|
279
|
-
coverage:
|
|
280
|
-
condition:
|
|
281
|
-
namespace: Tuple[str, float, float] = None,
|
|
282
|
-
force=None,
|
|
279
|
+
variations: Optional[List[Any]] = None,
|
|
280
|
+
weights: Optional[List[float]] = None,
|
|
281
|
+
coverage: Optional[float] = None,
|
|
282
|
+
condition: Optional[Dict[str, Any]] = None,
|
|
283
|
+
namespace: Optional[Tuple[str, float, float]] = None,
|
|
284
|
+
force: Optional[Any] = None,
|
|
283
285
|
hashAttribute: str = "id",
|
|
284
|
-
fallbackAttribute: str = None,
|
|
285
|
-
hashVersion: int = None,
|
|
286
|
-
range: Tuple[float, float] = None,
|
|
287
|
-
ranges: List[Tuple[float, float]] = None,
|
|
288
|
-
meta: List[VariationMeta] = None,
|
|
289
|
-
filters: List[Filter] = None,
|
|
290
|
-
seed: str = None,
|
|
291
|
-
name: str = None,
|
|
292
|
-
phase: str = None,
|
|
286
|
+
fallbackAttribute: Optional[str] = None,
|
|
287
|
+
hashVersion: Optional[int] = None,
|
|
288
|
+
range: Optional[Tuple[float, float]] = None,
|
|
289
|
+
ranges: Optional[List[Tuple[float, float]]] = None,
|
|
290
|
+
meta: Optional[List[VariationMeta]] = None,
|
|
291
|
+
filters: Optional[List[Filter]] = None,
|
|
292
|
+
seed: Optional[str] = None,
|
|
293
|
+
name: Optional[str] = None,
|
|
294
|
+
phase: Optional[str] = None,
|
|
293
295
|
disableStickyBucketing: bool = False,
|
|
294
|
-
bucketVersion: int = None,
|
|
295
|
-
minBucketVersion: int = None,
|
|
296
|
-
parentConditions: List[
|
|
296
|
+
bucketVersion: Optional[int] = None,
|
|
297
|
+
minBucketVersion: Optional[int] = None,
|
|
298
|
+
parentConditions: Optional[List[Dict[str, Any]]] = None,
|
|
297
299
|
) -> None:
|
|
298
300
|
|
|
299
301
|
if disableStickyBucketing:
|
|
@@ -322,7 +324,7 @@ class FeatureRule(object):
|
|
|
322
324
|
self.minBucketVersion = minBucketVersion or 0
|
|
323
325
|
self.parentConditions = parentConditions
|
|
324
326
|
|
|
325
|
-
def to_dict(self) ->
|
|
327
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
326
328
|
data: Dict[str, Any] = {}
|
|
327
329
|
if self.id:
|
|
328
330
|
data["id"] = self.id
|
|
@@ -410,6 +412,7 @@ class UserContext:
|
|
|
410
412
|
forced_variations: Dict[str, Any] = field(default_factory=dict)
|
|
411
413
|
overrides: Dict[str, Any] = field(default_factory=dict)
|
|
412
414
|
sticky_bucket_assignment_docs: Dict[str, Any] = field(default_factory=dict)
|
|
415
|
+
skip_all_experiments: bool = False
|
|
413
416
|
|
|
414
417
|
@dataclass
|
|
415
418
|
class Options:
|
growthbook/core.py
CHANGED
|
@@ -9,7 +9,7 @@ from .common_types import EvaluationContext, FeatureResult, Experiment, Filter,
|
|
|
9
9
|
|
|
10
10
|
logger = logging.getLogger("growthbook.core")
|
|
11
11
|
|
|
12
|
-
def evalCondition(attributes:
|
|
12
|
+
def evalCondition(attributes: Dict[str, Any], condition: Dict[str, Any], savedGroups: Optional[Dict[str, Any]] = None) -> bool:
|
|
13
13
|
for key, value in condition.items():
|
|
14
14
|
if key == "$or":
|
|
15
15
|
if not evalOr(attributes, value, savedGroups):
|
|
@@ -28,7 +28,7 @@ def evalCondition(attributes: dict, condition: dict, savedGroups: dict = None) -
|
|
|
28
28
|
|
|
29
29
|
return True
|
|
30
30
|
|
|
31
|
-
def evalOr(attributes, conditions, savedGroups) -> bool:
|
|
31
|
+
def evalOr(attributes: Dict[str, Any], conditions: List[Any], savedGroups: Optional[Dict[str, Any]]) -> bool:
|
|
32
32
|
if len(conditions) == 0:
|
|
33
33
|
return True
|
|
34
34
|
|
|
@@ -38,13 +38,13 @@ def evalOr(attributes, conditions, savedGroups) -> bool:
|
|
|
38
38
|
return False
|
|
39
39
|
|
|
40
40
|
|
|
41
|
-
def evalAnd(attributes, conditions, savedGroups) -> bool:
|
|
41
|
+
def evalAnd(attributes: Dict[str, Any], conditions: List[Any], savedGroups: Optional[Dict[str, Any]]) -> bool:
|
|
42
42
|
for condition in conditions:
|
|
43
43
|
if not evalCondition(attributes, condition, savedGroups):
|
|
44
44
|
return False
|
|
45
45
|
return True
|
|
46
46
|
|
|
47
|
-
def isOperatorObject(obj) -> bool:
|
|
47
|
+
def isOperatorObject(obj: Any) -> bool:
|
|
48
48
|
for key in obj.keys():
|
|
49
49
|
if key[0] != "$":
|
|
50
50
|
return False
|
|
@@ -82,7 +82,7 @@ def evalConditionValue(conditionValue, attributeValue, savedGroups) -> bool:
|
|
|
82
82
|
if not evalOperatorCondition(key, attributeValue, value, savedGroups):
|
|
83
83
|
return False
|
|
84
84
|
return True
|
|
85
|
-
return conditionValue == attributeValue
|
|
85
|
+
return bool(conditionValue == attributeValue)
|
|
86
86
|
|
|
87
87
|
def elemMatch(condition, attributeValue, savedGroups) -> bool:
|
|
88
88
|
if not type(attributeValue) is list:
|
|
@@ -208,7 +208,7 @@ def evalOperatorCondition(operator, attributeValue, conditionValue, savedGroups)
|
|
|
208
208
|
return attributeValue is None
|
|
209
209
|
return attributeValue is not None
|
|
210
210
|
elif operator == "$type":
|
|
211
|
-
return getType(attributeValue) == conditionValue
|
|
211
|
+
return bool(getType(attributeValue) == conditionValue)
|
|
212
212
|
elif operator == "$not":
|
|
213
213
|
return not evalConditionValue(conditionValue, attributeValue, savedGroups)
|
|
214
214
|
return False
|
|
@@ -263,18 +263,18 @@ def _getOrigHashValue(
|
|
|
263
263
|
|
|
264
264
|
return (actual_attr, val)
|
|
265
265
|
|
|
266
|
-
def _getHashValue(eval_context: EvaluationContext, attr: str = None, fallbackAttr: str = None) -> Tuple[str, str]:
|
|
266
|
+
def _getHashValue(eval_context: EvaluationContext, attr: Optional[str] = None, fallbackAttr: Optional[str] = None) -> Tuple[str, str]:
|
|
267
267
|
(attr, val) = _getOrigHashValue(attr=attr, fallbackAttr=fallbackAttr, eval_context=eval_context)
|
|
268
268
|
return (attr, str(val))
|
|
269
269
|
|
|
270
270
|
def _isIncludedInRollout(
|
|
271
271
|
seed: str,
|
|
272
272
|
eval_context: EvaluationContext,
|
|
273
|
-
hashAttribute: str = None,
|
|
274
|
-
fallbackAttribute: str = None,
|
|
275
|
-
range: Tuple[float, float] = None,
|
|
276
|
-
coverage: float = None,
|
|
277
|
-
hashVersion: int = None
|
|
273
|
+
hashAttribute: Optional[str] = None,
|
|
274
|
+
fallbackAttribute: Optional[str] = None,
|
|
275
|
+
range: Optional[Tuple[float, float]] = None,
|
|
276
|
+
coverage: Optional[float] = None,
|
|
277
|
+
hashVersion: Optional[int] = None
|
|
278
278
|
) -> bool:
|
|
279
279
|
if coverage is None and range is None:
|
|
280
280
|
return True
|
|
@@ -388,7 +388,7 @@ def getEqualWeights(numVariations: int) -> List[float]:
|
|
|
388
388
|
|
|
389
389
|
|
|
390
390
|
def getBucketRanges(
|
|
391
|
-
numVariations: int, coverage: float = 1, weights: List[float] = None
|
|
391
|
+
numVariations: int, coverage: float = 1, weights: Optional[List[float]] = None
|
|
392
392
|
) -> List[Tuple[float, float]]:
|
|
393
393
|
if coverage < 0:
|
|
394
394
|
coverage = 0
|
|
@@ -412,9 +412,9 @@ def getBucketRanges(
|
|
|
412
412
|
|
|
413
413
|
def eval_feature(
|
|
414
414
|
key: str,
|
|
415
|
-
evalContext: EvaluationContext = None,
|
|
416
|
-
callback_subscription: Callable[[Experiment, Result], None] = None,
|
|
417
|
-
tracking_cb: Callable[[Experiment, Result, UserContext], None] = None
|
|
415
|
+
evalContext: Optional[EvaluationContext] = None,
|
|
416
|
+
callback_subscription: Optional[Callable[[Experiment, Result], None]] = None,
|
|
417
|
+
tracking_cb: Optional[Callable[[Experiment, Result, UserContext], None]] = None
|
|
418
418
|
) -> FeatureResult:
|
|
419
419
|
"""Core feature evaluation logic as a standalone function"""
|
|
420
420
|
|
|
@@ -560,8 +560,8 @@ def _get_sticky_bucket_experiment_key(experiment_key: str, bucket_version: int =
|
|
|
560
560
|
return experiment_key + "__" + str(bucket_version)
|
|
561
561
|
|
|
562
562
|
def _get_sticky_bucket_assignments(evalContext: EvaluationContext,
|
|
563
|
-
attr: str = None,
|
|
564
|
-
fallback: str = None) -> Dict[str, str]:
|
|
563
|
+
attr: Optional[str] = None,
|
|
564
|
+
fallback: Optional[str] = None) -> Dict[str, str]:
|
|
565
565
|
merged: Dict[str, str] = {}
|
|
566
566
|
|
|
567
567
|
# Search for docs stored for attribute(id)
|
|
@@ -597,12 +597,12 @@ def _is_blocked(
|
|
|
597
597
|
def _get_sticky_bucket_variation(
|
|
598
598
|
experiment_key: str,
|
|
599
599
|
evalContext: EvaluationContext,
|
|
600
|
-
bucket_version: int = None,
|
|
601
|
-
min_bucket_version: int = None,
|
|
602
|
-
meta: List[VariationMeta] = None,
|
|
603
|
-
hash_attribute: str = None,
|
|
604
|
-
fallback_attribute: str = None,
|
|
605
|
-
) ->
|
|
600
|
+
bucket_version: Optional[int] = None,
|
|
601
|
+
min_bucket_version: Optional[int] = None,
|
|
602
|
+
meta: Optional[List[VariationMeta]] = None,
|
|
603
|
+
hash_attribute: Optional[str] = None,
|
|
604
|
+
fallback_attribute: Optional[str] = None,
|
|
605
|
+
) -> Dict[str, Any]:
|
|
606
606
|
bucket_version = bucket_version or 0
|
|
607
607
|
min_bucket_version = min_bucket_version or 0
|
|
608
608
|
meta = meta or []
|
|
@@ -633,8 +633,8 @@ def _get_sticky_bucket_variation(
|
|
|
633
633
|
|
|
634
634
|
def run_experiment(experiment: Experiment,
|
|
635
635
|
featureId: Optional[str] = None,
|
|
636
|
-
evalContext: EvaluationContext = None,
|
|
637
|
-
tracking_cb: Callable[[Experiment, Result, UserContext], None] = None
|
|
636
|
+
evalContext: Optional[EvaluationContext] = None,
|
|
637
|
+
tracking_cb: Optional[Callable[[Experiment, Result, UserContext], None]] = None
|
|
638
638
|
) -> Result:
|
|
639
639
|
if evalContext is None:
|
|
640
640
|
raise ValueError("evalContext is required - run_experiment")
|
|
@@ -821,11 +821,16 @@ def run_experiment(experiment: Experiment,
|
|
|
821
821
|
experiment=experiment, variationId=experiment.force, featureId=featureId, evalContext=evalContext
|
|
822
822
|
)
|
|
823
823
|
|
|
824
|
-
# 12. Exclude if in QA mode
|
|
824
|
+
# 12. Exclude if in QA mode (global)
|
|
825
825
|
if evalContext.global_ctx.options.qa_mode:
|
|
826
826
|
logger.debug("Skip experiment %s because of QA Mode", experiment.key)
|
|
827
827
|
return _getExperimentResult(experiment=experiment, featureId=featureId, evalContext=evalContext)
|
|
828
828
|
|
|
829
|
+
# 12.1. Exclude if user has skip_all_experiments flag set
|
|
830
|
+
if evalContext.user.skip_all_experiments:
|
|
831
|
+
logger.debug("Skip experiment %s because user has skip_all_experiments flag set", experiment.key)
|
|
832
|
+
return _getExperimentResult(experiment=experiment, featureId=featureId, evalContext=evalContext)
|
|
833
|
+
|
|
829
834
|
# 12.5. If experiment is stopped, return immediately
|
|
830
835
|
if experiment.status == "stopped":
|
|
831
836
|
logger.debug("Skip experiment %s because it is stopped", experiment.key)
|
|
@@ -891,8 +896,8 @@ def _getExperimentResult(
|
|
|
891
896
|
evalContext: EvaluationContext,
|
|
892
897
|
variationId: int = -1,
|
|
893
898
|
hashUsed: bool = False,
|
|
894
|
-
featureId: str = None,
|
|
895
|
-
bucket: float = None,
|
|
899
|
+
featureId: Optional[str] = None,
|
|
900
|
+
bucket: Optional[float] = None,
|
|
896
901
|
stickyBucketUsed: bool = False
|
|
897
902
|
) -> Result:
|
|
898
903
|
inExperiment = True
|
growthbook/growthbook.py
CHANGED
|
@@ -9,6 +9,7 @@ import sys
|
|
|
9
9
|
import json
|
|
10
10
|
import threading
|
|
11
11
|
import logging
|
|
12
|
+
import warnings
|
|
12
13
|
|
|
13
14
|
from abc import ABC, abstractmethod
|
|
14
15
|
from typing import Optional, Any, Set, Tuple, List, Dict, Callable
|
|
@@ -204,14 +205,14 @@ class SSEClient:
|
|
|
204
205
|
break
|
|
205
206
|
except (ClientConnectorError, ClientPayloadError) as e:
|
|
206
207
|
logger.error(f"Streaming error: {e}")
|
|
207
|
-
if not self.is_running:
|
|
208
|
-
break
|
|
209
208
|
await self._wait_for_reconnect()
|
|
209
|
+
if not self.is_running:
|
|
210
|
+
break # type: ignore[unreachable]
|
|
210
211
|
except TimeoutError:
|
|
211
212
|
logger.warning(f"Streaming connection timed out after {self.timeout} seconds.")
|
|
212
|
-
if not self.is_running:
|
|
213
|
-
break
|
|
214
213
|
await self._wait_for_reconnect()
|
|
214
|
+
if not self.is_running:
|
|
215
|
+
break # type: ignore[unreachable]
|
|
215
216
|
except asyncio.CancelledError:
|
|
216
217
|
logger.debug("SSE session cancelled")
|
|
217
218
|
break
|
|
@@ -322,6 +323,10 @@ class SSEClient:
|
|
|
322
323
|
except Exception as e:
|
|
323
324
|
logger.warning(f"Error during SSE task cleanup: {e}")
|
|
324
325
|
|
|
326
|
+
from collections import OrderedDict
|
|
327
|
+
|
|
328
|
+
# ... (imports)
|
|
329
|
+
|
|
325
330
|
class FeatureRepository(object):
|
|
326
331
|
def __init__(self) -> None:
|
|
327
332
|
self.cache: AbstractFeatureCache = InMemoryFeatureCache()
|
|
@@ -333,6 +338,12 @@ class FeatureRepository(object):
|
|
|
333
338
|
self._refresh_thread: Optional[threading.Thread] = None
|
|
334
339
|
self._refresh_stop_event = threading.Event()
|
|
335
340
|
self._refresh_lock = threading.Lock()
|
|
341
|
+
|
|
342
|
+
# ETag cache for bandwidth optimization
|
|
343
|
+
# Using OrderedDict for LRU cache (max 100 entries)
|
|
344
|
+
self._etag_cache: OrderedDict[str, Tuple[str, Dict[str, Any]]] = OrderedDict()
|
|
345
|
+
self._max_etag_entries = 100
|
|
346
|
+
self._etag_lock = threading.Lock()
|
|
336
347
|
|
|
337
348
|
def set_cache(self, cache: AbstractFeatureCache) -> None:
|
|
338
349
|
self.cache = cache
|
|
@@ -399,34 +410,124 @@ class FeatureRepository(object):
|
|
|
399
410
|
return cached
|
|
400
411
|
|
|
401
412
|
# Perform the GET request (separate method for easy mocking)
|
|
402
|
-
def _get(self, url: str):
|
|
413
|
+
def _get(self, url: str, headers: Optional[Dict[str, str]] = None):
|
|
403
414
|
self.http = self.http or PoolManager()
|
|
404
|
-
return self.http.request("GET", url)
|
|
405
|
-
|
|
415
|
+
return self.http.request("GET", url, headers=headers or {})
|
|
416
|
+
|
|
406
417
|
def _fetch_and_decode(self, api_host: str, client_key: str) -> Optional[Dict]:
|
|
418
|
+
url = self._get_features_url(api_host, client_key)
|
|
419
|
+
headers: Dict[str, str] = {}
|
|
420
|
+
|
|
421
|
+
# Check if we have a cached ETag for this URL
|
|
422
|
+
cached_etag = None
|
|
423
|
+
cached_data = None
|
|
424
|
+
with self._etag_lock:
|
|
425
|
+
if url in self._etag_cache:
|
|
426
|
+
# Move to end (mark as recently used)
|
|
427
|
+
self._etag_cache.move_to_end(url)
|
|
428
|
+
cached_etag, cached_data = self._etag_cache[url]
|
|
429
|
+
headers['If-None-Match'] = cached_etag
|
|
430
|
+
logger.debug(f"Using cached ETag for request: {cached_etag[:20]}...")
|
|
431
|
+
else:
|
|
432
|
+
logger.debug(f"No ETag cache found for URL: {url}")
|
|
433
|
+
|
|
407
434
|
try:
|
|
408
|
-
r = self._get(
|
|
435
|
+
r = self._get(url, headers)
|
|
436
|
+
|
|
437
|
+
# Handle 304 Not Modified - content hasn't changed
|
|
438
|
+
if r.status == 304:
|
|
439
|
+
logger.debug(f"ETag match! Server returned 304 Not Modified - using cached data (saved bandwidth)")
|
|
440
|
+
if cached_data is not None:
|
|
441
|
+
logger.debug(f"Returning cached response ({len(str(cached_data))} bytes)")
|
|
442
|
+
return cached_data
|
|
443
|
+
else:
|
|
444
|
+
logger.warning("Received 304 but no cached data available")
|
|
445
|
+
return None
|
|
446
|
+
|
|
409
447
|
if r.status >= 400:
|
|
410
448
|
logger.warning(
|
|
411
449
|
"Failed to fetch features, received status code %d", r.status
|
|
412
450
|
)
|
|
413
451
|
return None
|
|
452
|
+
|
|
414
453
|
decoded = json.loads(r.data.decode("utf-8"))
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
454
|
+
|
|
455
|
+
# Store the new ETag if present
|
|
456
|
+
response_etag = r.headers.get('ETag')
|
|
457
|
+
if response_etag:
|
|
458
|
+
with self._etag_lock:
|
|
459
|
+
self._etag_cache[url] = (response_etag, decoded)
|
|
460
|
+
# Enforce max size
|
|
461
|
+
if len(self._etag_cache) > self._max_etag_entries:
|
|
462
|
+
self._etag_cache.popitem(last=False)
|
|
463
|
+
|
|
464
|
+
if cached_etag:
|
|
465
|
+
logger.debug(f"ETag updated: {cached_etag[:20]}... -> {response_etag[:20]}...")
|
|
466
|
+
else:
|
|
467
|
+
logger.debug(f"New ETag cached: {response_etag[:20]}... ({len(str(decoded))} bytes)")
|
|
468
|
+
logger.debug(f"ETag cache now contains {len(self._etag_cache)} entries")
|
|
469
|
+
else:
|
|
470
|
+
logger.debug("No ETag header in response")
|
|
471
|
+
|
|
472
|
+
return decoded # type: ignore[no-any-return]
|
|
473
|
+
except Exception as e:
|
|
474
|
+
logger.warning(f"Failed to decode feature JSON from GrowthBook API: {e}")
|
|
418
475
|
return None
|
|
419
476
|
|
|
420
477
|
async def _fetch_and_decode_async(self, api_host: str, client_key: str) -> Optional[Dict]:
|
|
478
|
+
url = self._get_features_url(api_host, client_key)
|
|
479
|
+
headers: Dict[str, str] = {}
|
|
480
|
+
|
|
481
|
+
# Check if we have a cached ETag for this URL
|
|
482
|
+
cached_etag = None
|
|
483
|
+
cached_data = None
|
|
484
|
+
with self._etag_lock:
|
|
485
|
+
if url in self._etag_cache:
|
|
486
|
+
# Move to end (mark as recently used)
|
|
487
|
+
self._etag_cache.move_to_end(url)
|
|
488
|
+
cached_etag, cached_data = self._etag_cache[url]
|
|
489
|
+
headers['If-None-Match'] = cached_etag
|
|
490
|
+
logger.debug(f"[Async] Using cached ETag for request: {cached_etag[:20]}...")
|
|
491
|
+
else:
|
|
492
|
+
logger.debug(f"[Async] No ETag cache found for URL: {url}")
|
|
493
|
+
|
|
421
494
|
try:
|
|
422
|
-
url = self._get_features_url(api_host, client_key)
|
|
423
495
|
async with aiohttp.ClientSession() as session:
|
|
424
|
-
async with session.get(url) as response:
|
|
496
|
+
async with session.get(url, headers=headers) as response:
|
|
497
|
+
# Handle 304 Not Modified - content hasn't changed
|
|
498
|
+
if response.status == 304:
|
|
499
|
+
logger.debug(f"[Async] ETag match! Server returned 304 Not Modified - using cached data (saved bandwidth)")
|
|
500
|
+
if cached_data is not None:
|
|
501
|
+
logger.debug(f"[Async] Returning cached response ({len(str(cached_data))} bytes)")
|
|
502
|
+
return cached_data
|
|
503
|
+
else:
|
|
504
|
+
logger.warning("[Async] Received 304 but no cached data available")
|
|
505
|
+
return None
|
|
506
|
+
|
|
425
507
|
if response.status >= 400:
|
|
426
508
|
logger.warning("Failed to fetch features, received status code %d", response.status)
|
|
427
509
|
return None
|
|
510
|
+
|
|
428
511
|
decoded = await response.json()
|
|
429
|
-
|
|
512
|
+
|
|
513
|
+
# Store the new ETag if present
|
|
514
|
+
response_etag = response.headers.get('ETag')
|
|
515
|
+
if response_etag:
|
|
516
|
+
with self._etag_lock:
|
|
517
|
+
self._etag_cache[url] = (response_etag, decoded)
|
|
518
|
+
# Enforce max size
|
|
519
|
+
if len(self._etag_cache) > self._max_etag_entries:
|
|
520
|
+
self._etag_cache.popitem(last=False)
|
|
521
|
+
|
|
522
|
+
if cached_etag:
|
|
523
|
+
logger.debug(f"[Async] ETag updated: {cached_etag[:20]}... -> {response_etag[:20]}...")
|
|
524
|
+
else:
|
|
525
|
+
logger.debug(f"[Async] New ETag cached: {response_etag[:20]}... ({len(str(decoded))} bytes)")
|
|
526
|
+
logger.debug(f"[Async] ETag cache now contains {len(self._etag_cache)} entries")
|
|
527
|
+
else:
|
|
528
|
+
logger.debug("[Async] No ETag header in response")
|
|
529
|
+
|
|
530
|
+
return decoded # type: ignore[no-any-return]
|
|
430
531
|
except aiohttp.ClientError as e:
|
|
431
532
|
logger.warning(f"HTTP request failed: {e}")
|
|
432
533
|
return None
|
|
@@ -475,7 +576,7 @@ class FeatureRepository(object):
|
|
|
475
576
|
|
|
476
577
|
data = self.decrypt_response(decoded, decryption_key)
|
|
477
578
|
|
|
478
|
-
return data
|
|
579
|
+
return data # type: ignore[no-any-return]
|
|
479
580
|
|
|
480
581
|
async def _fetch_features_async(
|
|
481
582
|
self, api_host: str, client_key: str, decryption_key: str = ""
|
|
@@ -486,7 +587,7 @@ class FeatureRepository(object):
|
|
|
486
587
|
|
|
487
588
|
data = self.decrypt_response(decoded, decryption_key)
|
|
488
589
|
|
|
489
|
-
return data
|
|
590
|
+
return data # type: ignore[no-any-return]
|
|
490
591
|
|
|
491
592
|
|
|
492
593
|
def startAutoRefresh(self, api_host, client_key, cb, streaming_timeout=30):
|
|
@@ -574,15 +675,16 @@ class GrowthBook(object):
|
|
|
574
675
|
client_key: str = "",
|
|
575
676
|
decryption_key: str = "",
|
|
576
677
|
cache_ttl: int = 600,
|
|
577
|
-
forced_variations:
|
|
578
|
-
sticky_bucket_service: AbstractStickyBucketService = None,
|
|
579
|
-
sticky_bucket_identifier_attributes: List[str] = None,
|
|
580
|
-
savedGroups:
|
|
678
|
+
forced_variations: Optional[Dict[str, Any]] = None,
|
|
679
|
+
sticky_bucket_service: Optional[AbstractStickyBucketService] = None,
|
|
680
|
+
sticky_bucket_identifier_attributes: Optional[List[str]] = None,
|
|
681
|
+
savedGroups: Optional[Dict[str, Any]] = None,
|
|
581
682
|
streaming: bool = False,
|
|
582
683
|
streaming_connection_timeout: int = 30,
|
|
583
684
|
stale_while_revalidate: bool = False,
|
|
584
685
|
stale_ttl: int = 300, # 5 minutes default
|
|
585
|
-
plugins: List = None,
|
|
686
|
+
plugins: Optional[List[Any]] = None,
|
|
687
|
+
skip_all_experiments: bool = False,
|
|
586
688
|
# Deprecated args
|
|
587
689
|
trackingCallback=None,
|
|
588
690
|
qaMode: bool = False,
|
|
@@ -595,7 +697,7 @@ class GrowthBook(object):
|
|
|
595
697
|
self._attributes = attributes
|
|
596
698
|
self._url = url
|
|
597
699
|
self._features: Dict[str, Feature] = {}
|
|
598
|
-
self._saved_groups = savedGroups
|
|
700
|
+
self._saved_groups = savedGroups if savedGroups is not None else {}
|
|
599
701
|
self._api_host = api_host
|
|
600
702
|
self._client_key = client_key
|
|
601
703
|
self._decryption_key = decryption_key
|
|
@@ -609,6 +711,7 @@ class GrowthBook(object):
|
|
|
609
711
|
self._qaMode = qa_mode or qaMode
|
|
610
712
|
self._trackingCallback = on_experiment_viewed or trackingCallback
|
|
611
713
|
self._featureUsageCallback = on_feature_usage
|
|
714
|
+
self._skip_all_experiments = skip_all_experiments
|
|
612
715
|
|
|
613
716
|
self._streaming = streaming
|
|
614
717
|
self._streaming_timeout = streaming_connection_timeout
|
|
@@ -619,7 +722,7 @@ class GrowthBook(object):
|
|
|
619
722
|
self._user = user
|
|
620
723
|
self._groups = groups
|
|
621
724
|
self._overrides = overrides
|
|
622
|
-
self._forcedVariations = forced_variations or forcedVariations
|
|
725
|
+
self._forcedVariations = (forced_variations if forced_variations is not None else forcedVariations) if forced_variations is not None or forcedVariations else {}
|
|
623
726
|
|
|
624
727
|
self._tracked: Dict[str, Any] = {}
|
|
625
728
|
self._assigned: Dict[str, Any] = {}
|
|
@@ -627,8 +730,8 @@ class GrowthBook(object):
|
|
|
627
730
|
self._is_updating_features = False
|
|
628
731
|
|
|
629
732
|
# support plugins
|
|
630
|
-
self._plugins: List = plugins
|
|
631
|
-
self._initialized_plugins: List = []
|
|
733
|
+
self._plugins: List[Any] = plugins if plugins is not None else []
|
|
734
|
+
self._initialized_plugins: List[Any] = []
|
|
632
735
|
|
|
633
736
|
self._global_ctx = GlobalContext(
|
|
634
737
|
options=Options(
|
|
@@ -652,7 +755,8 @@ class GrowthBook(object):
|
|
|
652
755
|
groups=self._groups,
|
|
653
756
|
forced_variations=self._forcedVariations,
|
|
654
757
|
overrides=self._overrides,
|
|
655
|
-
sticky_bucket_assignment_docs=self._sticky_bucket_assignment_docs
|
|
758
|
+
sticky_bucket_assignment_docs=self._sticky_bucket_assignment_docs,
|
|
759
|
+
skip_all_experiments=self._skip_all_experiments
|
|
656
760
|
)
|
|
657
761
|
|
|
658
762
|
if features:
|
|
@@ -683,7 +787,6 @@ class GrowthBook(object):
|
|
|
683
787
|
self._saved_groups = features_data["savedGroups"]
|
|
684
788
|
|
|
685
789
|
def load_features(self) -> None:
|
|
686
|
-
|
|
687
790
|
response = feature_repo.load_features(
|
|
688
791
|
self._api_host, self._client_key, self._decryption_key, self._cache_ttl
|
|
689
792
|
)
|
|
@@ -752,8 +855,8 @@ class GrowthBook(object):
|
|
|
752
855
|
except Exception as e:
|
|
753
856
|
logger.warning(f"Error stopping auto refresh: {e}")
|
|
754
857
|
|
|
755
|
-
# @deprecated, use set_features
|
|
756
858
|
def setFeatures(self, features: dict) -> None:
|
|
859
|
+
warnings.warn("setFeatures is deprecated, use set_features instead", DeprecationWarning)
|
|
757
860
|
return self.set_features(features)
|
|
758
861
|
|
|
759
862
|
def set_features(self, features: dict) -> None:
|
|
@@ -776,23 +879,23 @@ class GrowthBook(object):
|
|
|
776
879
|
finally:
|
|
777
880
|
self._is_updating_features = False
|
|
778
881
|
|
|
779
|
-
# @deprecated, use get_features
|
|
780
882
|
def getFeatures(self) -> Dict[str, Feature]:
|
|
883
|
+
warnings.warn("getFeatures is deprecated, use get_features instead", DeprecationWarning)
|
|
781
884
|
return self.get_features()
|
|
782
885
|
|
|
783
886
|
def get_features(self) -> Dict[str, Feature]:
|
|
784
887
|
return self._features
|
|
785
888
|
|
|
786
|
-
# @deprecated, use set_attributes
|
|
787
889
|
def setAttributes(self, attributes: dict) -> None:
|
|
890
|
+
warnings.warn("setAttributes is deprecated, use set_attributes instead", DeprecationWarning)
|
|
788
891
|
return self.set_attributes(attributes)
|
|
789
892
|
|
|
790
893
|
def set_attributes(self, attributes: dict) -> None:
|
|
791
894
|
self._attributes = attributes
|
|
792
895
|
self.refresh_sticky_buckets()
|
|
793
896
|
|
|
794
|
-
# @deprecated, use get_attributes
|
|
795
897
|
def getAttributes(self) -> dict:
|
|
898
|
+
warnings.warn("getAttributes is deprecated, use get_attributes instead", DeprecationWarning)
|
|
796
899
|
return self.get_attributes()
|
|
797
900
|
|
|
798
901
|
def get_attributes(self) -> dict:
|
|
@@ -845,30 +948,30 @@ class GrowthBook(object):
|
|
|
845
948
|
except Exception as e:
|
|
846
949
|
logger.warning(f"Error clearing internal state: {e}")
|
|
847
950
|
|
|
848
|
-
# @deprecated, use is_on
|
|
849
951
|
def isOn(self, key: str) -> bool:
|
|
952
|
+
warnings.warn("isOn is deprecated, use is_on instead", DeprecationWarning)
|
|
850
953
|
return self.is_on(key)
|
|
851
954
|
|
|
852
955
|
def is_on(self, key: str) -> bool:
|
|
853
|
-
return self.
|
|
956
|
+
return self.eval_feature(key).on
|
|
854
957
|
|
|
855
|
-
# @deprecated, use is_off
|
|
856
958
|
def isOff(self, key: str) -> bool:
|
|
959
|
+
warnings.warn("isOff is deprecated, use is_off instead", DeprecationWarning)
|
|
857
960
|
return self.is_off(key)
|
|
858
961
|
|
|
859
962
|
def is_off(self, key: str) -> bool:
|
|
860
|
-
return self.
|
|
963
|
+
return self.eval_feature(key).off
|
|
861
964
|
|
|
862
|
-
# @deprecated, use get_feature_value
|
|
863
965
|
def getFeatureValue(self, key: str, fallback):
|
|
966
|
+
warnings.warn("getFeatureValue is deprecated, use get_feature_value instead", DeprecationWarning)
|
|
864
967
|
return self.get_feature_value(key, fallback)
|
|
865
968
|
|
|
866
969
|
def get_feature_value(self, key: str, fallback):
|
|
867
970
|
res = self.eval_feature(key)
|
|
868
971
|
return res.value if res.value is not None else fallback
|
|
869
972
|
|
|
870
|
-
# @deprecated, use eval_feature
|
|
871
973
|
def evalFeature(self, key: str) -> FeatureResult:
|
|
974
|
+
warnings.warn("evalFeature is deprecated, use eval_feature instead", DeprecationWarning)
|
|
872
975
|
return self.eval_feature(key)
|
|
873
976
|
|
|
874
977
|
def _ensure_fresh_features(self) -> None:
|
|
@@ -916,32 +1019,30 @@ class GrowthBook(object):
|
|
|
916
1019
|
pass
|
|
917
1020
|
return result
|
|
918
1021
|
|
|
919
|
-
# @deprecated, use get_all_results
|
|
920
1022
|
def getAllResults(self):
|
|
1023
|
+
warnings.warn("getAllResults is deprecated, use get_all_results instead", DeprecationWarning)
|
|
921
1024
|
return self.get_all_results()
|
|
922
1025
|
|
|
923
1026
|
def get_all_results(self):
|
|
924
1027
|
return self._assigned.copy()
|
|
925
1028
|
|
|
926
1029
|
def _fireSubscriptions(self, experiment: Experiment, result: Result):
|
|
927
|
-
if experiment is None:
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
except Exception:
|
|
944
|
-
pass
|
|
1030
|
+
if experiment is not None:
|
|
1031
|
+
prev = self._assigned.get(experiment.key, None)
|
|
1032
|
+
if (
|
|
1033
|
+
not prev
|
|
1034
|
+
or prev["result"].inExperiment != result.inExperiment
|
|
1035
|
+
or prev["result"].variationId != result.variationId
|
|
1036
|
+
):
|
|
1037
|
+
self._assigned[experiment.key] = {
|
|
1038
|
+
"experiment": experiment,
|
|
1039
|
+
"result": result,
|
|
1040
|
+
}
|
|
1041
|
+
for cb in self._subscriptions:
|
|
1042
|
+
try:
|
|
1043
|
+
cb(experiment, result)
|
|
1044
|
+
except Exception:
|
|
1045
|
+
pass
|
|
945
1046
|
|
|
946
1047
|
def run(self, experiment: Experiment) -> Result:
|
|
947
1048
|
# result = self._run(experiment)
|
|
@@ -970,8 +1071,8 @@ class GrowthBook(object):
|
|
|
970
1071
|
try:
|
|
971
1072
|
self._trackingCallback(experiment=experiment, result=result, user_context=user_context)
|
|
972
1073
|
self._tracked[key] = True
|
|
973
|
-
except Exception:
|
|
974
|
-
|
|
1074
|
+
except Exception as e:
|
|
1075
|
+
logger.exception(e)
|
|
975
1076
|
|
|
976
1077
|
def _derive_sticky_bucket_identifier_attributes(self) -> List[str]:
|
|
977
1078
|
attributes = set()
|
growthbook/growthbook_client.py
CHANGED
|
@@ -81,7 +81,7 @@ class WeakRefWrapper:
|
|
|
81
81
|
class FeatureCache:
|
|
82
82
|
"""Thread-safe feature cache"""
|
|
83
83
|
def __init__(self):
|
|
84
|
-
self._cache = {
|
|
84
|
+
self._cache: Dict[str, Dict[str, Any]] = {
|
|
85
85
|
'features': {},
|
|
86
86
|
'savedGroups': {}
|
|
87
87
|
}
|
|
@@ -114,7 +114,7 @@ class EnhancedFeatureRepository(FeatureRepository, metaclass=SingletonMeta):
|
|
|
114
114
|
self._backoff = BackoffStrategy()
|
|
115
115
|
self._feature_cache = FeatureCache()
|
|
116
116
|
self._callbacks: List[Callable[[Dict[str, Any]], Awaitable[None]]] = []
|
|
117
|
-
self._last_successful_refresh = None
|
|
117
|
+
self._last_successful_refresh: Optional[datetime] = None
|
|
118
118
|
self._refresh_in_progress = asyncio.Lock()
|
|
119
119
|
|
|
120
120
|
@asynccontextmanager
|
|
@@ -164,9 +164,10 @@ class RequestContextPlugin(GrowthBookPlugin):
|
|
|
164
164
|
|
|
165
165
|
# 3. Try thread-local storage
|
|
166
166
|
import threading
|
|
167
|
+
from typing import cast
|
|
167
168
|
thread_local = getattr(threading.current_thread(), 'gb_request_context', None)
|
|
168
169
|
if thread_local:
|
|
169
|
-
return thread_local
|
|
170
|
+
return cast(Dict[str, Any], thread_local)
|
|
170
171
|
|
|
171
172
|
return None
|
|
172
173
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: growthbook
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.8
|
|
4
4
|
Summary: Powerful Feature flagging and A/B testing for Python apps
|
|
5
5
|
Home-page: https://github.com/growthbook/growthbook-python
|
|
6
6
|
Author: GrowthBook
|
|
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.10
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.11
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Typing :: Typed
|
|
21
22
|
Requires-Python: >=3.7
|
|
22
23
|
Description-Content-Type: text/markdown
|
|
23
24
|
License-File: LICENSE
|
|
@@ -56,6 +57,24 @@ Powerful Feature flagging and A/B testing for Python apps.
|
|
|
56
57
|
|
|
57
58
|
`pip install growthbook` (recommended) or copy `growthbook.py` into your project
|
|
58
59
|
|
|
60
|
+
## Type Checking Support
|
|
61
|
+
|
|
62
|
+
The GrowthBook Python SDK is fully typed and includes inline type hints for all public APIs. This enables:
|
|
63
|
+
|
|
64
|
+
- **Better IDE support** with autocomplete and inline documentation
|
|
65
|
+
- **Type safety** - catch bugs at development time with mypy or other type checkers
|
|
66
|
+
- **Better code documentation** - types serve as inline documentation
|
|
67
|
+
- **Safer refactoring** - type checkers will catch breaking changes
|
|
68
|
+
|
|
69
|
+
To use type checking with mypy:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install mypy
|
|
73
|
+
mypy your_code.py
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The SDK includes a `py.typed` marker file and is compliant with [PEP 561](https://www.python.org/dev/peps/pep-0561/).
|
|
77
|
+
|
|
59
78
|
## Quick Usage
|
|
60
79
|
|
|
61
80
|
```python
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
growthbook/__init__.py,sha256=BwGsRsv5njoTII6ZRvPJZg96aPrOC4s0KFJbGbWI5m0,444
|
|
2
|
+
growthbook/common_types.py,sha256=YKUmmYfzgrzLQ7kp2IPLc8QBA-B0QbnbF5viekNiTpw,15703
|
|
3
|
+
growthbook/core.py,sha256=C1Nes_AiEuu6ypPghKuIeM2F22XUsLK1KrLW0xDwLYU,35963
|
|
4
|
+
growthbook/growthbook.py,sha256=CzBahR87qYySyWT2-JLmmWtb70Xr9PtknVorPtfHftw,46479
|
|
5
|
+
growthbook/growthbook_client.py,sha256=ZzdeNZ1a9N78ISbj2BKN9Xmyt05VlPVNjMyh9E1eA0E,24679
|
|
6
|
+
growthbook/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
growthbook/plugins/__init__.py,sha256=y2eAV1sA041XWcftBVTDH0t-ggy9r2C5oKRYRF6XR6s,602
|
|
8
|
+
growthbook/plugins/base.py,sha256=PWBXUBj62hi25Y5Eif9WmEWagWdkwGXHi2dMtn44bo8,3637
|
|
9
|
+
growthbook/plugins/growthbook_tracking.py,sha256=lWO9ErUSrnqhcpWLp03XIrh45-BdBssdmLDVvaGvulY,11317
|
|
10
|
+
growthbook/plugins/request_context.py,sha256=WzoGxalxPfrsN3RzfkvVYaUGat1A3N4AErnaS9IZ48Y,13005
|
|
11
|
+
growthbook-1.4.8.dist-info/licenses/LICENSE,sha256=D-TcBckB0dTPUlNJ8jBiTIJIj1ekHLB1CY7HJtJKhMY,1069
|
|
12
|
+
growthbook-1.4.8.dist-info/METADATA,sha256=ogrwnsEpsEZfwGsT3WtnMNbxecVH8abUWlXA2svIQOc,22726
|
|
13
|
+
growthbook-1.4.8.dist-info/WHEEL,sha256=JNWh1Fm1UdwIQV075glCn4MVuCRs0sotJIq-J6rbxCU,109
|
|
14
|
+
growthbook-1.4.8.dist-info/top_level.txt,sha256=dzfRQFGYejCIUstRSrrRVTMlxf7pBqASTI5S8gGRlXw,11
|
|
15
|
+
growthbook-1.4.8.dist-info/RECORD,,
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
growthbook/__init__.py,sha256=1B2U7uOmsNvvEQ3DQ2hLuvALy6pqnJAduUUIXFbFbso,444
|
|
2
|
-
growthbook/common_types.py,sha256=OMfssahxLjvCGuGsWa75G6JMmu5xH1hVrK0pCL1ArMU,14972
|
|
3
|
-
growthbook/core.py,sha256=n9nwna26iZTY48LIvQqu5N_RrE35X0wlRBhq0-Qdb-s,35241
|
|
4
|
-
growthbook/growthbook.py,sha256=8U6UmxHAdLDCsUi3CBFTvK4nBchs9-ukXwd_bZAiXsY,40333
|
|
5
|
-
growthbook/growthbook_client.py,sha256=igD6T9MP9rfYzS1Dk0wBJ3fgfNzJxDQZ_bm5SS8XO7I,24632
|
|
6
|
-
growthbook/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
-
growthbook/plugins/__init__.py,sha256=y2eAV1sA041XWcftBVTDH0t-ggy9r2C5oKRYRF6XR6s,602
|
|
8
|
-
growthbook/plugins/base.py,sha256=PWBXUBj62hi25Y5Eif9WmEWagWdkwGXHi2dMtn44bo8,3637
|
|
9
|
-
growthbook/plugins/growthbook_tracking.py,sha256=FvPFOuKF_xKjmTX8x_hzMlHrrL-68Y2ZPw1Hfl2_ilQ,11333
|
|
10
|
-
growthbook/plugins/request_context.py,sha256=O5FJDrjJR5u0rx3ENGO9cOsKMHd9e0l0Nvdb1PHfmm8,12951
|
|
11
|
-
growthbook-1.4.6.dist-info/licenses/LICENSE,sha256=D-TcBckB0dTPUlNJ8jBiTIJIj1ekHLB1CY7HJtJKhMY,1069
|
|
12
|
-
growthbook-1.4.6.dist-info/METADATA,sha256=q04J95e-Kgf9yssVQCfG_JJZHGHWsTDndDwZm1c74eg,22073
|
|
13
|
-
growthbook-1.4.6.dist-info/WHEEL,sha256=JNWh1Fm1UdwIQV075glCn4MVuCRs0sotJIq-J6rbxCU,109
|
|
14
|
-
growthbook-1.4.6.dist-info/top_level.txt,sha256=dzfRQFGYejCIUstRSrrRVTMlxf7pBqASTI5S8gGRlXw,11
|
|
15
|
-
growthbook-1.4.6.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|