growthbook 1.4.5__tar.gz → 1.4.7__tar.gz
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-1.4.5/growthbook.egg-info → growthbook-1.4.7}/PKG-INFO +20 -2
- {growthbook-1.4.5 → growthbook-1.4.7}/README.md +18 -1
- {growthbook-1.4.5 → growthbook-1.4.7}/growthbook/__init__.py +1 -1
- {growthbook-1.4.5 → growthbook-1.4.7}/growthbook/common_types.py +59 -56
- {growthbook-1.4.5 → growthbook-1.4.7}/growthbook/core.py +34 -29
- {growthbook-1.4.5 → growthbook-1.4.7}/growthbook/growthbook.py +80 -65
- {growthbook-1.4.5 → growthbook-1.4.7}/growthbook/growthbook_client.py +6 -6
- {growthbook-1.4.5 → growthbook-1.4.7}/growthbook/plugins/growthbook_tracking.py +1 -1
- {growthbook-1.4.5 → growthbook-1.4.7}/growthbook/plugins/request_context.py +2 -1
- {growthbook-1.4.5 → growthbook-1.4.7/growthbook.egg-info}/PKG-INFO +20 -2
- {growthbook-1.4.5 → growthbook-1.4.7}/pyproject.toml +28 -0
- {growthbook-1.4.5 → growthbook-1.4.7}/setup.cfg +1 -1
- {growthbook-1.4.5 → growthbook-1.4.7}/setup.py +1 -0
- {growthbook-1.4.5 → growthbook-1.4.7}/tests/test_growthbook.py +69 -3
- {growthbook-1.4.5 → growthbook-1.4.7}/tests/test_growthbook_client.py +80 -3
- {growthbook-1.4.5 → growthbook-1.4.7}/LICENSE +0 -0
- {growthbook-1.4.5 → growthbook-1.4.7}/MANIFEST.in +0 -0
- {growthbook-1.4.5 → growthbook-1.4.7}/growthbook/plugins/__init__.py +0 -0
- {growthbook-1.4.5 → growthbook-1.4.7}/growthbook/plugins/base.py +0 -0
- {growthbook-1.4.5 → growthbook-1.4.7}/growthbook/py.typed +0 -0
- {growthbook-1.4.5 → growthbook-1.4.7}/growthbook.egg-info/SOURCES.txt +0 -0
- {growthbook-1.4.5 → growthbook-1.4.7}/growthbook.egg-info/dependency_links.txt +0 -0
- {growthbook-1.4.5 → growthbook-1.4.7}/growthbook.egg-info/requires.txt +0 -0
- {growthbook-1.4.5 → growthbook-1.4.7}/growthbook.egg-info/top_level.txt +0 -0
- {growthbook-1.4.5 → growthbook-1.4.7}/tests/conftest.py +0 -0
- {growthbook-1.4.5 → growthbook-1.4.7}/tests/test_plugins.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: growthbook
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.7
|
|
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
|
|
@@ -499,7 +518,6 @@ class MyStickyBucketService(AbstractStickyBucketService):
|
|
|
499
518
|
})
|
|
500
519
|
|
|
501
520
|
# Pass in an instance of this service to your GrowthBook constructor
|
|
502
|
-
|
|
503
521
|
gb = GrowthBook(
|
|
504
522
|
sticky_bucket_service = MyStickyBucketService()
|
|
505
523
|
)
|
|
@@ -21,6 +21,24 @@ Powerful Feature flagging and A/B testing for Python apps.
|
|
|
21
21
|
|
|
22
22
|
`pip install growthbook` (recommended) or copy `growthbook.py` into your project
|
|
23
23
|
|
|
24
|
+
## Type Checking Support
|
|
25
|
+
|
|
26
|
+
The GrowthBook Python SDK is fully typed and includes inline type hints for all public APIs. This enables:
|
|
27
|
+
|
|
28
|
+
- **Better IDE support** with autocomplete and inline documentation
|
|
29
|
+
- **Type safety** - catch bugs at development time with mypy or other type checkers
|
|
30
|
+
- **Better code documentation** - types serve as inline documentation
|
|
31
|
+
- **Safer refactoring** - type checkers will catch breaking changes
|
|
32
|
+
|
|
33
|
+
To use type checking with mypy:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install mypy
|
|
37
|
+
mypy your_code.py
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The SDK includes a `py.typed` marker file and is compliant with [PEP 561](https://www.python.org/dev/peps/pep-0561/).
|
|
41
|
+
|
|
24
42
|
## Quick Usage
|
|
25
43
|
|
|
26
44
|
```python
|
|
@@ -464,7 +482,6 @@ class MyStickyBucketService(AbstractStickyBucketService):
|
|
|
464
482
|
})
|
|
465
483
|
|
|
466
484
|
# Pass in an instance of this service to your GrowthBook constructor
|
|
467
|
-
|
|
468
485
|
gb = GrowthBook(
|
|
469
486
|
sticky_bucket_service = MyStickyBucketService()
|
|
470
487
|
)
|
|
@@ -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:
|
|
@@ -426,7 +429,7 @@ class Options:
|
|
|
426
429
|
sticky_bucket_service: Optional[AbstractStickyBucketService] = None
|
|
427
430
|
sticky_bucket_identifier_attributes: Optional[List[str]] = None
|
|
428
431
|
on_experiment_viewed: Optional[Callable[[Experiment, Result, Optional[UserContext]], None]] = None
|
|
429
|
-
on_feature_usage: Optional[Callable[[str, 'FeatureResult'], None]] = None
|
|
432
|
+
on_feature_usage: Optional[Callable[[str, 'FeatureResult', UserContext], None]] = None
|
|
430
433
|
tracking_plugins: Optional[List[Any]] = None
|
|
431
434
|
|
|
432
435
|
|
|
@@ -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
|
|
@@ -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
|
|
@@ -412,7 +413,7 @@ class FeatureRepository(object):
|
|
|
412
413
|
)
|
|
413
414
|
return None
|
|
414
415
|
decoded = json.loads(r.data.decode("utf-8"))
|
|
415
|
-
return decoded
|
|
416
|
+
return decoded # type: ignore[no-any-return]
|
|
416
417
|
except Exception:
|
|
417
418
|
logger.warning("Failed to decode feature JSON from GrowthBook API")
|
|
418
419
|
return None
|
|
@@ -426,7 +427,7 @@ class FeatureRepository(object):
|
|
|
426
427
|
logger.warning("Failed to fetch features, received status code %d", response.status)
|
|
427
428
|
return None
|
|
428
429
|
decoded = await response.json()
|
|
429
|
-
return decoded
|
|
430
|
+
return decoded # type: ignore[no-any-return]
|
|
430
431
|
except aiohttp.ClientError as e:
|
|
431
432
|
logger.warning(f"HTTP request failed: {e}")
|
|
432
433
|
return None
|
|
@@ -475,7 +476,7 @@ class FeatureRepository(object):
|
|
|
475
476
|
|
|
476
477
|
data = self.decrypt_response(decoded, decryption_key)
|
|
477
478
|
|
|
478
|
-
return data
|
|
479
|
+
return data # type: ignore[no-any-return]
|
|
479
480
|
|
|
480
481
|
async def _fetch_features_async(
|
|
481
482
|
self, api_host: str, client_key: str, decryption_key: str = ""
|
|
@@ -486,7 +487,7 @@ class FeatureRepository(object):
|
|
|
486
487
|
|
|
487
488
|
data = self.decrypt_response(decoded, decryption_key)
|
|
488
489
|
|
|
489
|
-
return data
|
|
490
|
+
return data # type: ignore[no-any-return]
|
|
490
491
|
|
|
491
492
|
|
|
492
493
|
def startAutoRefresh(self, api_host, client_key, cb, streaming_timeout=30):
|
|
@@ -503,6 +504,10 @@ class FeatureRepository(object):
|
|
|
503
504
|
|
|
504
505
|
def start_background_refresh(self, api_host: str, client_key: str, decryption_key: str, ttl: int = 600, refresh_interval: int = 300) -> None:
|
|
505
506
|
"""Start periodic background refresh task"""
|
|
507
|
+
|
|
508
|
+
if not client_key:
|
|
509
|
+
raise ValueError("Must specify `client_key` to refresh features")
|
|
510
|
+
|
|
506
511
|
with self._refresh_lock:
|
|
507
512
|
if self._refresh_thread is not None:
|
|
508
513
|
return # Already running
|
|
@@ -570,15 +575,16 @@ class GrowthBook(object):
|
|
|
570
575
|
client_key: str = "",
|
|
571
576
|
decryption_key: str = "",
|
|
572
577
|
cache_ttl: int = 600,
|
|
573
|
-
forced_variations:
|
|
574
|
-
sticky_bucket_service: AbstractStickyBucketService = None,
|
|
575
|
-
sticky_bucket_identifier_attributes: List[str] = None,
|
|
576
|
-
savedGroups:
|
|
578
|
+
forced_variations: Optional[Dict[str, Any]] = None,
|
|
579
|
+
sticky_bucket_service: Optional[AbstractStickyBucketService] = None,
|
|
580
|
+
sticky_bucket_identifier_attributes: Optional[List[str]] = None,
|
|
581
|
+
savedGroups: Optional[Dict[str, Any]] = None,
|
|
577
582
|
streaming: bool = False,
|
|
578
583
|
streaming_connection_timeout: int = 30,
|
|
579
584
|
stale_while_revalidate: bool = False,
|
|
580
585
|
stale_ttl: int = 300, # 5 minutes default
|
|
581
|
-
plugins: List = None,
|
|
586
|
+
plugins: Optional[List[Any]] = None,
|
|
587
|
+
skip_all_experiments: bool = False,
|
|
582
588
|
# Deprecated args
|
|
583
589
|
trackingCallback=None,
|
|
584
590
|
qaMode: bool = False,
|
|
@@ -591,7 +597,7 @@ class GrowthBook(object):
|
|
|
591
597
|
self._attributes = attributes
|
|
592
598
|
self._url = url
|
|
593
599
|
self._features: Dict[str, Feature] = {}
|
|
594
|
-
self._saved_groups = savedGroups
|
|
600
|
+
self._saved_groups = savedGroups if savedGroups is not None else {}
|
|
595
601
|
self._api_host = api_host
|
|
596
602
|
self._client_key = client_key
|
|
597
603
|
self._decryption_key = decryption_key
|
|
@@ -605,6 +611,7 @@ class GrowthBook(object):
|
|
|
605
611
|
self._qaMode = qa_mode or qaMode
|
|
606
612
|
self._trackingCallback = on_experiment_viewed or trackingCallback
|
|
607
613
|
self._featureUsageCallback = on_feature_usage
|
|
614
|
+
self._skip_all_experiments = skip_all_experiments
|
|
608
615
|
|
|
609
616
|
self._streaming = streaming
|
|
610
617
|
self._streaming_timeout = streaming_connection_timeout
|
|
@@ -615,15 +622,16 @@ class GrowthBook(object):
|
|
|
615
622
|
self._user = user
|
|
616
623
|
self._groups = groups
|
|
617
624
|
self._overrides = overrides
|
|
618
|
-
self._forcedVariations = forced_variations or forcedVariations
|
|
625
|
+
self._forcedVariations = (forced_variations if forced_variations is not None else forcedVariations) if forced_variations is not None or forcedVariations else {}
|
|
619
626
|
|
|
620
627
|
self._tracked: Dict[str, Any] = {}
|
|
621
628
|
self._assigned: Dict[str, Any] = {}
|
|
622
629
|
self._subscriptions: Set[Any] = set()
|
|
630
|
+
self._is_updating_features = False
|
|
623
631
|
|
|
624
632
|
# support plugins
|
|
625
|
-
self._plugins: List = plugins
|
|
626
|
-
self._initialized_plugins: List = []
|
|
633
|
+
self._plugins: List[Any] = plugins if plugins is not None else []
|
|
634
|
+
self._initialized_plugins: List[Any] = []
|
|
627
635
|
|
|
628
636
|
self._global_ctx = GlobalContext(
|
|
629
637
|
options=Options(
|
|
@@ -647,7 +655,8 @@ class GrowthBook(object):
|
|
|
647
655
|
groups=self._groups,
|
|
648
656
|
forced_variations=self._forcedVariations,
|
|
649
657
|
overrides=self._overrides,
|
|
650
|
-
sticky_bucket_assignment_docs=self._sticky_bucket_assignment_docs
|
|
658
|
+
sticky_bucket_assignment_docs=self._sticky_bucket_assignment_docs,
|
|
659
|
+
skip_all_experiments=self._skip_all_experiments
|
|
651
660
|
)
|
|
652
661
|
|
|
653
662
|
if features:
|
|
@@ -662,7 +671,7 @@ class GrowthBook(object):
|
|
|
662
671
|
if self._streaming:
|
|
663
672
|
self.load_features()
|
|
664
673
|
self.startAutoRefresh()
|
|
665
|
-
elif self._stale_while_revalidate
|
|
674
|
+
elif self._stale_while_revalidate:
|
|
666
675
|
# Start background refresh task for stale-while-revalidate
|
|
667
676
|
self.load_features() # Initial load
|
|
668
677
|
feature_repo.start_background_refresh(
|
|
@@ -678,7 +687,6 @@ class GrowthBook(object):
|
|
|
678
687
|
self._saved_groups = features_data["savedGroups"]
|
|
679
688
|
|
|
680
689
|
def load_features(self) -> None:
|
|
681
|
-
|
|
682
690
|
response = feature_repo.load_features(
|
|
683
691
|
self._api_host, self._client_key, self._decryption_key, self._cache_ttl
|
|
684
692
|
)
|
|
@@ -747,42 +755,47 @@ class GrowthBook(object):
|
|
|
747
755
|
except Exception as e:
|
|
748
756
|
logger.warning(f"Error stopping auto refresh: {e}")
|
|
749
757
|
|
|
750
|
-
# @deprecated, use set_features
|
|
751
758
|
def setFeatures(self, features: dict) -> None:
|
|
759
|
+
warnings.warn("setFeatures is deprecated, use set_features instead", DeprecationWarning)
|
|
752
760
|
return self.set_features(features)
|
|
753
761
|
|
|
754
762
|
def set_features(self, features: dict) -> None:
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
763
|
+
# Prevent infinite recursion during feature updates
|
|
764
|
+
self._is_updating_features = True
|
|
765
|
+
try:
|
|
766
|
+
self._features = {}
|
|
767
|
+
for key, feature in features.items():
|
|
768
|
+
if isinstance(feature, Feature):
|
|
769
|
+
self._features[key] = feature
|
|
770
|
+
else:
|
|
771
|
+
self._features[key] = Feature(
|
|
772
|
+
rules=feature.get("rules", []),
|
|
773
|
+
defaultValue=feature.get("defaultValue", None),
|
|
774
|
+
)
|
|
775
|
+
# Update the global context with the new features and saved groups
|
|
776
|
+
self._global_ctx.features = self._features
|
|
777
|
+
self._global_ctx.saved_groups = self._saved_groups
|
|
778
|
+
self.refresh_sticky_buckets()
|
|
779
|
+
finally:
|
|
780
|
+
self._is_updating_features = False
|
|
768
781
|
|
|
769
|
-
# @deprecated, use get_features
|
|
770
782
|
def getFeatures(self) -> Dict[str, Feature]:
|
|
783
|
+
warnings.warn("getFeatures is deprecated, use get_features instead", DeprecationWarning)
|
|
771
784
|
return self.get_features()
|
|
772
785
|
|
|
773
786
|
def get_features(self) -> Dict[str, Feature]:
|
|
774
787
|
return self._features
|
|
775
788
|
|
|
776
|
-
# @deprecated, use set_attributes
|
|
777
789
|
def setAttributes(self, attributes: dict) -> None:
|
|
790
|
+
warnings.warn("setAttributes is deprecated, use set_attributes instead", DeprecationWarning)
|
|
778
791
|
return self.set_attributes(attributes)
|
|
779
792
|
|
|
780
793
|
def set_attributes(self, attributes: dict) -> None:
|
|
781
794
|
self._attributes = attributes
|
|
782
795
|
self.refresh_sticky_buckets()
|
|
783
796
|
|
|
784
|
-
# @deprecated, use get_attributes
|
|
785
797
|
def getAttributes(self) -> dict:
|
|
798
|
+
warnings.warn("getAttributes is deprecated, use get_attributes instead", DeprecationWarning)
|
|
786
799
|
return self.get_attributes()
|
|
787
800
|
|
|
788
801
|
def get_attributes(self) -> dict:
|
|
@@ -835,35 +848,39 @@ class GrowthBook(object):
|
|
|
835
848
|
except Exception as e:
|
|
836
849
|
logger.warning(f"Error clearing internal state: {e}")
|
|
837
850
|
|
|
838
|
-
# @deprecated, use is_on
|
|
839
851
|
def isOn(self, key: str) -> bool:
|
|
852
|
+
warnings.warn("isOn is deprecated, use is_on instead", DeprecationWarning)
|
|
840
853
|
return self.is_on(key)
|
|
841
854
|
|
|
842
855
|
def is_on(self, key: str) -> bool:
|
|
843
|
-
return self.
|
|
856
|
+
return self.eval_feature(key).on
|
|
844
857
|
|
|
845
|
-
# @deprecated, use is_off
|
|
846
858
|
def isOff(self, key: str) -> bool:
|
|
859
|
+
warnings.warn("isOff is deprecated, use is_off instead", DeprecationWarning)
|
|
847
860
|
return self.is_off(key)
|
|
848
861
|
|
|
849
862
|
def is_off(self, key: str) -> bool:
|
|
850
|
-
return self.
|
|
863
|
+
return self.eval_feature(key).off
|
|
851
864
|
|
|
852
|
-
# @deprecated, use get_feature_value
|
|
853
865
|
def getFeatureValue(self, key: str, fallback):
|
|
866
|
+
warnings.warn("getFeatureValue is deprecated, use get_feature_value instead", DeprecationWarning)
|
|
854
867
|
return self.get_feature_value(key, fallback)
|
|
855
868
|
|
|
856
869
|
def get_feature_value(self, key: str, fallback):
|
|
857
870
|
res = self.eval_feature(key)
|
|
858
871
|
return res.value if res.value is not None else fallback
|
|
859
872
|
|
|
860
|
-
# @deprecated, use eval_feature
|
|
861
873
|
def evalFeature(self, key: str) -> FeatureResult:
|
|
874
|
+
warnings.warn("evalFeature is deprecated, use eval_feature instead", DeprecationWarning)
|
|
862
875
|
return self.eval_feature(key)
|
|
863
876
|
|
|
864
877
|
def _ensure_fresh_features(self) -> None:
|
|
865
878
|
"""Lazy refresh: Check cache expiry and refresh if needed, but only if client_key is provided"""
|
|
866
879
|
|
|
880
|
+
# Prevent infinite recursion when updating features (e.g., during sticky bucket refresh)
|
|
881
|
+
if self._is_updating_features:
|
|
882
|
+
return
|
|
883
|
+
|
|
867
884
|
if self._streaming or self._stale_while_revalidate or not self._client_key:
|
|
868
885
|
return # Skip cache checks - SSE or background refresh handles freshness
|
|
869
886
|
|
|
@@ -897,37 +914,35 @@ class GrowthBook(object):
|
|
|
897
914
|
# Call feature usage callback if provided
|
|
898
915
|
if self._featureUsageCallback:
|
|
899
916
|
try:
|
|
900
|
-
self._featureUsageCallback(key, result)
|
|
917
|
+
self._featureUsageCallback(key, result, self._user_ctx)
|
|
901
918
|
except Exception:
|
|
902
919
|
pass
|
|
903
920
|
return result
|
|
904
921
|
|
|
905
|
-
# @deprecated, use get_all_results
|
|
906
922
|
def getAllResults(self):
|
|
923
|
+
warnings.warn("getAllResults is deprecated, use get_all_results instead", DeprecationWarning)
|
|
907
924
|
return self.get_all_results()
|
|
908
925
|
|
|
909
926
|
def get_all_results(self):
|
|
910
927
|
return self._assigned.copy()
|
|
911
928
|
|
|
912
929
|
def _fireSubscriptions(self, experiment: Experiment, result: Result):
|
|
913
|
-
if experiment is None:
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
except Exception:
|
|
930
|
-
pass
|
|
930
|
+
if experiment is not None:
|
|
931
|
+
prev = self._assigned.get(experiment.key, None)
|
|
932
|
+
if (
|
|
933
|
+
not prev
|
|
934
|
+
or prev["result"].inExperiment != result.inExperiment
|
|
935
|
+
or prev["result"].variationId != result.variationId
|
|
936
|
+
):
|
|
937
|
+
self._assigned[experiment.key] = {
|
|
938
|
+
"experiment": experiment,
|
|
939
|
+
"result": result,
|
|
940
|
+
}
|
|
941
|
+
for cb in self._subscriptions:
|
|
942
|
+
try:
|
|
943
|
+
cb(experiment, result)
|
|
944
|
+
except Exception:
|
|
945
|
+
pass
|
|
931
946
|
|
|
932
947
|
def run(self, experiment: Experiment) -> Result:
|
|
933
948
|
# result = self._run(experiment)
|
|
@@ -956,8 +971,8 @@ class GrowthBook(object):
|
|
|
956
971
|
try:
|
|
957
972
|
self._trackingCallback(experiment=experiment, result=result, user_context=user_context)
|
|
958
973
|
self._tracked[key] = True
|
|
959
|
-
except Exception:
|
|
960
|
-
|
|
974
|
+
except Exception as e:
|
|
975
|
+
logger.exception(e)
|
|
961
976
|
|
|
962
977
|
def _derive_sticky_bucket_identifier_attributes(self) -> List[str]:
|
|
963
978
|
attributes = set()
|
|
@@ -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
|
|
@@ -503,7 +503,7 @@ class GrowthBookClient:
|
|
|
503
503
|
# Call feature usage callback if provided
|
|
504
504
|
if self.options.on_feature_usage:
|
|
505
505
|
try:
|
|
506
|
-
self.options.on_feature_usage(key, result)
|
|
506
|
+
self.options.on_feature_usage(key, result, user_context)
|
|
507
507
|
except Exception:
|
|
508
508
|
logger.exception("Error in feature usage callback")
|
|
509
509
|
return result
|
|
@@ -516,7 +516,7 @@ class GrowthBookClient:
|
|
|
516
516
|
# Call feature usage callback if provided
|
|
517
517
|
if self.options.on_feature_usage:
|
|
518
518
|
try:
|
|
519
|
-
self.options.on_feature_usage(key, result)
|
|
519
|
+
self.options.on_feature_usage(key, result, user_context)
|
|
520
520
|
except Exception:
|
|
521
521
|
logger.exception("Error in feature usage callback")
|
|
522
522
|
return result.on
|
|
@@ -529,7 +529,7 @@ class GrowthBookClient:
|
|
|
529
529
|
# Call feature usage callback if provided
|
|
530
530
|
if self.options.on_feature_usage:
|
|
531
531
|
try:
|
|
532
|
-
self.options.on_feature_usage(key, result)
|
|
532
|
+
self.options.on_feature_usage(key, result, user_context)
|
|
533
533
|
except Exception:
|
|
534
534
|
logger.exception("Error in feature usage callback")
|
|
535
535
|
return result.off
|
|
@@ -541,7 +541,7 @@ class GrowthBookClient:
|
|
|
541
541
|
# Call feature usage callback if provided
|
|
542
542
|
if self.options.on_feature_usage:
|
|
543
543
|
try:
|
|
544
|
-
self.options.on_feature_usage(key, result)
|
|
544
|
+
self.options.on_feature_usage(key, result, user_context)
|
|
545
545
|
except Exception:
|
|
546
546
|
logger.exception("Error in feature usage callback")
|
|
547
547
|
return result.value if result.value is not None else fallback
|
|
@@ -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.7
|
|
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
|
|
@@ -499,7 +518,6 @@ class MyStickyBucketService(AbstractStickyBucketService):
|
|
|
499
518
|
})
|
|
500
519
|
|
|
501
520
|
# Pass in an instance of this service to your GrowthBook constructor
|
|
502
|
-
|
|
503
521
|
gb = GrowthBook(
|
|
504
522
|
sticky_bucket_service = MyStickyBucketService()
|
|
505
523
|
)
|
|
@@ -5,6 +5,33 @@ asyncio_default_fixture_loop_scope = "function"
|
|
|
5
5
|
testpaths = ["tests"]
|
|
6
6
|
pythonpath = "."
|
|
7
7
|
|
|
8
|
+
[tool.mypy]
|
|
9
|
+
python_version = "3.9"
|
|
10
|
+
warn_return_any = true
|
|
11
|
+
warn_unused_configs = true
|
|
12
|
+
disallow_untyped_defs = false # Set to false initially, will enable gradually
|
|
13
|
+
disallow_incomplete_defs = false
|
|
14
|
+
check_untyped_defs = true
|
|
15
|
+
disallow_untyped_decorators = false
|
|
16
|
+
no_implicit_optional = true
|
|
17
|
+
warn_redundant_casts = true
|
|
18
|
+
warn_unused_ignores = true
|
|
19
|
+
warn_no_return = true
|
|
20
|
+
warn_unreachable = true
|
|
21
|
+
strict_equality = true
|
|
22
|
+
show_error_codes = true
|
|
23
|
+
pretty = true
|
|
24
|
+
|
|
25
|
+
# Per-module options
|
|
26
|
+
[[tool.mypy.overrides]]
|
|
27
|
+
module = "growthbook.*"
|
|
28
|
+
disallow_untyped_defs = false # Will enable after fixing all type hints
|
|
29
|
+
|
|
30
|
+
[[tool.mypy.overrides]]
|
|
31
|
+
module = "tests.*"
|
|
32
|
+
disallow_untyped_defs = false
|
|
33
|
+
check_untyped_defs = false
|
|
34
|
+
|
|
8
35
|
[project]
|
|
9
36
|
dynamic = ["version"]
|
|
10
37
|
name = "growthbook"
|
|
@@ -37,6 +64,7 @@ classifiers = [
|
|
|
37
64
|
"Programming Language :: Python :: 3.10",
|
|
38
65
|
"Programming Language :: Python :: 3.11",
|
|
39
66
|
"Programming Language :: Python :: 3.12",
|
|
67
|
+
"Typing :: Typed",
|
|
40
68
|
]
|
|
41
69
|
|
|
42
70
|
[build-system]
|
|
@@ -46,6 +46,7 @@ setup(
|
|
|
46
46
|
'Programming Language :: Python :: 3.10',
|
|
47
47
|
'Programming Language :: Python :: 3.11',
|
|
48
48
|
'Programming Language :: Python :: 3.12',
|
|
49
|
+
'Typing :: Typed',
|
|
49
50
|
],
|
|
50
51
|
description="Powerful Feature flagging and A/B testing for Python apps",
|
|
51
52
|
long_description=long_description,
|
|
@@ -219,8 +219,8 @@ def test_feature_usage_callback():
|
|
|
219
219
|
"""Test that feature usage callback is called correctly"""
|
|
220
220
|
calls = []
|
|
221
221
|
|
|
222
|
-
def feature_usage_cb(key, result):
|
|
223
|
-
calls.append([key, result])
|
|
222
|
+
def feature_usage_cb(key, result, user_context):
|
|
223
|
+
calls.append([key, result, user_context])
|
|
224
224
|
|
|
225
225
|
gb = GrowthBook(
|
|
226
226
|
attributes={"id": "1"},
|
|
@@ -243,12 +243,14 @@ def test_feature_usage_callback():
|
|
|
243
243
|
assert calls[0][0] == "feature-1"
|
|
244
244
|
assert calls[0][1].value is True
|
|
245
245
|
assert calls[0][1].source == "defaultValue"
|
|
246
|
+
assert calls[0][2].attributes == {"id": "1"}
|
|
246
247
|
|
|
247
248
|
# Test is_on
|
|
248
249
|
gb.is_on("feature-2")
|
|
249
250
|
assert len(calls) == 2
|
|
250
251
|
assert calls[1][0] == "feature-2"
|
|
251
252
|
assert calls[1][1].value is False
|
|
253
|
+
assert calls[1][2].attributes == {"id": "1"}
|
|
252
254
|
|
|
253
255
|
# Test get_feature_value
|
|
254
256
|
value = gb.get_feature_value("feature-3", "blue")
|
|
@@ -256,11 +258,13 @@ def test_feature_usage_callback():
|
|
|
256
258
|
assert calls[2][0] == "feature-3"
|
|
257
259
|
assert calls[2][1].value == "red"
|
|
258
260
|
assert value == "red"
|
|
261
|
+
assert calls[2][2].attributes == {"id": "1"}
|
|
259
262
|
|
|
260
263
|
# Test is_off
|
|
261
264
|
gb.is_off("feature-1")
|
|
262
265
|
assert len(calls) == 4
|
|
263
266
|
assert calls[3][0] == "feature-1"
|
|
267
|
+
assert calls[3][2].attributes == {"id": "1"}
|
|
264
268
|
|
|
265
269
|
# Calling same feature multiple times should trigger callback each time
|
|
266
270
|
gb.eval_feature("feature-1")
|
|
@@ -273,7 +277,7 @@ def test_feature_usage_callback():
|
|
|
273
277
|
def test_feature_usage_callback_error_handling():
|
|
274
278
|
"""Test that feature usage callback errors are handled gracefully"""
|
|
275
279
|
|
|
276
|
-
def failing_callback(key, result):
|
|
280
|
+
def failing_callback(key, result, user_context):
|
|
277
281
|
raise Exception("Callback error")
|
|
278
282
|
|
|
279
283
|
gb = GrowthBook(
|
|
@@ -318,6 +322,68 @@ def test_handles_weird_experiment_values():
|
|
|
318
322
|
gb.destroy()
|
|
319
323
|
|
|
320
324
|
|
|
325
|
+
def test_skip_all_experiments_flag():
|
|
326
|
+
"""Test that skip_all_experiments flag prevents users from being put into experiments"""
|
|
327
|
+
|
|
328
|
+
# Test with skip_all_experiments=True
|
|
329
|
+
gb_skip = GrowthBook(
|
|
330
|
+
attributes={"id": "1"},
|
|
331
|
+
skip_all_experiments=True,
|
|
332
|
+
features={
|
|
333
|
+
"feature-with-experiment": Feature(
|
|
334
|
+
defaultValue="control",
|
|
335
|
+
rules=[
|
|
336
|
+
FeatureRule(
|
|
337
|
+
key="exp-123",
|
|
338
|
+
variations=["control", "variation"],
|
|
339
|
+
weights=[0.5, 0.5]
|
|
340
|
+
)
|
|
341
|
+
]
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# User should NOT be in experiment due to skip_all_experiments flag
|
|
347
|
+
result = gb_skip.eval_feature("feature-with-experiment")
|
|
348
|
+
assert result.value == "control" # Should get default value
|
|
349
|
+
assert result.source == "defaultValue"
|
|
350
|
+
assert result.experiment is None # No experiment should be assigned
|
|
351
|
+
assert result.experimentResult is None
|
|
352
|
+
|
|
353
|
+
# Test running experiment directly
|
|
354
|
+
exp = Experiment(key="direct-exp", variations=["a", "b"])
|
|
355
|
+
exp_result = gb_skip.run(exp)
|
|
356
|
+
assert exp_result.inExperiment is False
|
|
357
|
+
assert exp_result.value == "a" # Should get first variation (control)
|
|
358
|
+
|
|
359
|
+
gb_skip.destroy()
|
|
360
|
+
|
|
361
|
+
# Test with skip_all_experiments=False (default behavior)
|
|
362
|
+
gb_normal = GrowthBook(
|
|
363
|
+
attributes={"id": "1"},
|
|
364
|
+
skip_all_experiments=False, # explicit False
|
|
365
|
+
features={
|
|
366
|
+
"feature-with-experiment": Feature(
|
|
367
|
+
defaultValue="control",
|
|
368
|
+
rules=[
|
|
369
|
+
FeatureRule(
|
|
370
|
+
key="exp-123",
|
|
371
|
+
variations=["control", "variation"],
|
|
372
|
+
weights=[0.5, 0.5]
|
|
373
|
+
)
|
|
374
|
+
]
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# User SHOULD be in experiment normally
|
|
380
|
+
result_normal = gb_normal.eval_feature("feature-with-experiment")
|
|
381
|
+
# With id="1", this user should be assigned a variation
|
|
382
|
+
assert result_normal.value in ["control", "variation"]
|
|
383
|
+
assert result_normal.source == "experiment"
|
|
384
|
+
|
|
385
|
+
gb_normal.destroy()
|
|
386
|
+
|
|
321
387
|
def test_force_variation():
|
|
322
388
|
gb = GrowthBook(attributes={"id": "6"})
|
|
323
389
|
exp = Experiment(key="forced-test", variations=[0, 1])
|
|
@@ -776,8 +776,8 @@ async def test_feature_usage_callback():
|
|
|
776
776
|
"""Test that feature usage callback is called correctly"""
|
|
777
777
|
calls = []
|
|
778
778
|
|
|
779
|
-
def feature_usage_cb(key, result):
|
|
780
|
-
calls.append([key, result])
|
|
779
|
+
def feature_usage_cb(key, result, user_context):
|
|
780
|
+
calls.append([key, result, user_context])
|
|
781
781
|
|
|
782
782
|
client = GrowthBookClient(Options(
|
|
783
783
|
api_host="https://localhost.growthbook.io",
|
|
@@ -820,12 +820,14 @@ async def test_feature_usage_callback():
|
|
|
820
820
|
assert calls[0][0] == "feature-1"
|
|
821
821
|
assert calls[0][1].value is True
|
|
822
822
|
assert calls[0][1].source == "defaultValue"
|
|
823
|
+
assert calls[0][2].attributes == {"id": "1"}
|
|
823
824
|
|
|
824
825
|
# Test is_on
|
|
825
826
|
await client.is_on("feature-2", user_context)
|
|
826
827
|
assert len(calls) == 2
|
|
827
828
|
assert calls[1][0] == "feature-2"
|
|
828
829
|
assert calls[1][1].value is False
|
|
830
|
+
assert calls[1][2].attributes == {"id": "1"}
|
|
829
831
|
|
|
830
832
|
# Test get_feature_value
|
|
831
833
|
value = await client.get_feature_value("feature-3", "blue", user_context)
|
|
@@ -833,11 +835,13 @@ async def test_feature_usage_callback():
|
|
|
833
835
|
assert calls[2][0] == "feature-3"
|
|
834
836
|
assert calls[2][1].value == "red"
|
|
835
837
|
assert value == "red"
|
|
838
|
+
assert calls[2][2].attributes == {"id": "1"}
|
|
836
839
|
|
|
837
840
|
# Test is_off
|
|
838
841
|
await client.is_off("feature-1", user_context)
|
|
839
842
|
assert len(calls) == 4
|
|
840
843
|
assert calls[3][0] == "feature-1"
|
|
844
|
+
assert calls[3][2].attributes == {"id": "1"}
|
|
841
845
|
|
|
842
846
|
# Calling same feature multiple times should trigger callback each time
|
|
843
847
|
await client.eval_feature("feature-1", user_context)
|
|
@@ -852,7 +856,7 @@ async def test_feature_usage_callback():
|
|
|
852
856
|
async def test_feature_usage_callback_error_handling():
|
|
853
857
|
"""Test that feature usage callback errors are handled gracefully"""
|
|
854
858
|
|
|
855
|
-
def failing_callback(key, result):
|
|
859
|
+
def failing_callback(key, result, user_context):
|
|
856
860
|
raise Exception("Callback error")
|
|
857
861
|
|
|
858
862
|
client = GrowthBookClient(Options(
|
|
@@ -891,5 +895,78 @@ async def test_feature_usage_callback_error_handling():
|
|
|
891
895
|
is_on = await client.is_on("feature-1", user_context)
|
|
892
896
|
assert is_on is True
|
|
893
897
|
|
|
898
|
+
finally:
|
|
899
|
+
await client.close()
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
@pytest.mark.asyncio
|
|
903
|
+
async def test_skip_all_experiments_flag():
|
|
904
|
+
"""Test that skip_all_experiments flag prevents users from being put into experiments"""
|
|
905
|
+
|
|
906
|
+
client = GrowthBookClient(Options(
|
|
907
|
+
api_host="https://localhost.growthbook.io",
|
|
908
|
+
client_key="test-key",
|
|
909
|
+
enabled=True
|
|
910
|
+
))
|
|
911
|
+
|
|
912
|
+
# User context WITH skip_all_experiments=True
|
|
913
|
+
user_context_skip = UserContext(
|
|
914
|
+
attributes={"id": "1"},
|
|
915
|
+
skip_all_experiments=True
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
# User context WITHOUT skip_all_experiments (normal behavior)
|
|
919
|
+
user_context_normal = UserContext(
|
|
920
|
+
attributes={"id": "1"},
|
|
921
|
+
skip_all_experiments=False
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
try:
|
|
925
|
+
# Set up mocks for feature repository
|
|
926
|
+
mock_features = {
|
|
927
|
+
"features": {
|
|
928
|
+
"feature-with-experiment": {
|
|
929
|
+
"defaultValue": "control",
|
|
930
|
+
"rules": [
|
|
931
|
+
{
|
|
932
|
+
"key": "exp-123",
|
|
933
|
+
"variations": ["control", "variation"],
|
|
934
|
+
"weights": [0.5, 0.5]
|
|
935
|
+
}
|
|
936
|
+
]
|
|
937
|
+
}
|
|
938
|
+
},
|
|
939
|
+
"savedGroups": {}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
with patch('growthbook.FeatureRepository.load_features_async',
|
|
943
|
+
new_callable=AsyncMock, return_value=mock_features), \
|
|
944
|
+
patch('growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh',
|
|
945
|
+
new_callable=AsyncMock), \
|
|
946
|
+
patch('growthbook.growthbook_client.EnhancedFeatureRepository.stop_refresh',
|
|
947
|
+
new_callable=AsyncMock):
|
|
948
|
+
|
|
949
|
+
# Initialize client
|
|
950
|
+
await client.initialize()
|
|
951
|
+
|
|
952
|
+
# Test with skip_all_experiments=True
|
|
953
|
+
result_skip = await client.eval_feature("feature-with-experiment", user_context_skip)
|
|
954
|
+
assert result_skip.value == "control" # Should get default value
|
|
955
|
+
assert result_skip.source == "defaultValue"
|
|
956
|
+
assert result_skip.experiment is None
|
|
957
|
+
assert result_skip.experimentResult is None
|
|
958
|
+
|
|
959
|
+
# Test direct experiment run with skip_all_experiments=True
|
|
960
|
+
exp = Experiment(key="direct-exp", variations=["a", "b"])
|
|
961
|
+
exp_result_skip = await client.run(exp, user_context_skip)
|
|
962
|
+
assert exp_result_skip.inExperiment is False
|
|
963
|
+
assert exp_result_skip.value == "a" # Should get first variation
|
|
964
|
+
|
|
965
|
+
# Test with skip_all_experiments=False (normal)
|
|
966
|
+
result_normal = await client.eval_feature("feature-with-experiment", user_context_normal)
|
|
967
|
+
# User should be assigned to a variation
|
|
968
|
+
assert result_normal.value in ["control", "variation"]
|
|
969
|
+
assert result_normal.source == "experiment"
|
|
970
|
+
|
|
894
971
|
finally:
|
|
895
972
|
await client.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|