growthbook 1.4.6__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.6/growthbook.egg-info → growthbook-1.4.7}/PKG-INFO +20 -1
- {growthbook-1.4.6 → growthbook-1.4.7}/README.md +18 -0
- {growthbook-1.4.6 → growthbook-1.4.7}/growthbook/__init__.py +1 -1
- {growthbook-1.4.6 → growthbook-1.4.7}/growthbook/common_types.py +58 -55
- {growthbook-1.4.6 → growthbook-1.4.7}/growthbook/core.py +34 -29
- {growthbook-1.4.6 → growthbook-1.4.7}/growthbook/growthbook.py +51 -50
- {growthbook-1.4.6 → growthbook-1.4.7}/growthbook/growthbook_client.py +2 -2
- {growthbook-1.4.6 → growthbook-1.4.7}/growthbook/plugins/growthbook_tracking.py +1 -1
- {growthbook-1.4.6 → growthbook-1.4.7}/growthbook/plugins/request_context.py +2 -1
- {growthbook-1.4.6 → growthbook-1.4.7/growthbook.egg-info}/PKG-INFO +20 -1
- {growthbook-1.4.6 → growthbook-1.4.7}/pyproject.toml +28 -0
- {growthbook-1.4.6 → growthbook-1.4.7}/setup.cfg +1 -1
- {growthbook-1.4.6 → growthbook-1.4.7}/setup.py +1 -0
- {growthbook-1.4.6 → growthbook-1.4.7}/tests/test_growthbook.py +62 -0
- {growthbook-1.4.6 → growthbook-1.4.7}/tests/test_growthbook_client.py +73 -0
- {growthbook-1.4.6 → growthbook-1.4.7}/LICENSE +0 -0
- {growthbook-1.4.6 → growthbook-1.4.7}/MANIFEST.in +0 -0
- {growthbook-1.4.6 → growthbook-1.4.7}/growthbook/plugins/__init__.py +0 -0
- {growthbook-1.4.6 → growthbook-1.4.7}/growthbook/plugins/base.py +0 -0
- {growthbook-1.4.6 → growthbook-1.4.7}/growthbook/py.typed +0 -0
- {growthbook-1.4.6 → growthbook-1.4.7}/growthbook.egg-info/SOURCES.txt +0 -0
- {growthbook-1.4.6 → growthbook-1.4.7}/growthbook.egg-info/dependency_links.txt +0 -0
- {growthbook-1.4.6 → growthbook-1.4.7}/growthbook.egg-info/requires.txt +0 -0
- {growthbook-1.4.6 → growthbook-1.4.7}/growthbook.egg-info/top_level.txt +0 -0
- {growthbook-1.4.6 → growthbook-1.4.7}/tests/conftest.py +0 -0
- {growthbook-1.4.6 → 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
|
|
@@ -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
|
|
@@ -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:
|
|
@@ -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):
|
|
@@ -574,15 +575,16 @@ class GrowthBook(object):
|
|
|
574
575
|
client_key: str = "",
|
|
575
576
|
decryption_key: str = "",
|
|
576
577
|
cache_ttl: int = 600,
|
|
577
|
-
forced_variations:
|
|
578
|
-
sticky_bucket_service: AbstractStickyBucketService = None,
|
|
579
|
-
sticky_bucket_identifier_attributes: List[str] = None,
|
|
580
|
-
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,
|
|
581
582
|
streaming: bool = False,
|
|
582
583
|
streaming_connection_timeout: int = 30,
|
|
583
584
|
stale_while_revalidate: bool = False,
|
|
584
585
|
stale_ttl: int = 300, # 5 minutes default
|
|
585
|
-
plugins: List = None,
|
|
586
|
+
plugins: Optional[List[Any]] = None,
|
|
587
|
+
skip_all_experiments: bool = False,
|
|
586
588
|
# Deprecated args
|
|
587
589
|
trackingCallback=None,
|
|
588
590
|
qaMode: bool = False,
|
|
@@ -595,7 +597,7 @@ class GrowthBook(object):
|
|
|
595
597
|
self._attributes = attributes
|
|
596
598
|
self._url = url
|
|
597
599
|
self._features: Dict[str, Feature] = {}
|
|
598
|
-
self._saved_groups = savedGroups
|
|
600
|
+
self._saved_groups = savedGroups if savedGroups is not None else {}
|
|
599
601
|
self._api_host = api_host
|
|
600
602
|
self._client_key = client_key
|
|
601
603
|
self._decryption_key = decryption_key
|
|
@@ -609,6 +611,7 @@ class GrowthBook(object):
|
|
|
609
611
|
self._qaMode = qa_mode or qaMode
|
|
610
612
|
self._trackingCallback = on_experiment_viewed or trackingCallback
|
|
611
613
|
self._featureUsageCallback = on_feature_usage
|
|
614
|
+
self._skip_all_experiments = skip_all_experiments
|
|
612
615
|
|
|
613
616
|
self._streaming = streaming
|
|
614
617
|
self._streaming_timeout = streaming_connection_timeout
|
|
@@ -619,7 +622,7 @@ class GrowthBook(object):
|
|
|
619
622
|
self._user = user
|
|
620
623
|
self._groups = groups
|
|
621
624
|
self._overrides = overrides
|
|
622
|
-
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 {}
|
|
623
626
|
|
|
624
627
|
self._tracked: Dict[str, Any] = {}
|
|
625
628
|
self._assigned: Dict[str, Any] = {}
|
|
@@ -627,8 +630,8 @@ class GrowthBook(object):
|
|
|
627
630
|
self._is_updating_features = False
|
|
628
631
|
|
|
629
632
|
# support plugins
|
|
630
|
-
self._plugins: List = plugins
|
|
631
|
-
self._initialized_plugins: List = []
|
|
633
|
+
self._plugins: List[Any] = plugins if plugins is not None else []
|
|
634
|
+
self._initialized_plugins: List[Any] = []
|
|
632
635
|
|
|
633
636
|
self._global_ctx = GlobalContext(
|
|
634
637
|
options=Options(
|
|
@@ -652,7 +655,8 @@ class GrowthBook(object):
|
|
|
652
655
|
groups=self._groups,
|
|
653
656
|
forced_variations=self._forcedVariations,
|
|
654
657
|
overrides=self._overrides,
|
|
655
|
-
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
|
|
656
660
|
)
|
|
657
661
|
|
|
658
662
|
if features:
|
|
@@ -683,7 +687,6 @@ class GrowthBook(object):
|
|
|
683
687
|
self._saved_groups = features_data["savedGroups"]
|
|
684
688
|
|
|
685
689
|
def load_features(self) -> None:
|
|
686
|
-
|
|
687
690
|
response = feature_repo.load_features(
|
|
688
691
|
self._api_host, self._client_key, self._decryption_key, self._cache_ttl
|
|
689
692
|
)
|
|
@@ -752,8 +755,8 @@ class GrowthBook(object):
|
|
|
752
755
|
except Exception as e:
|
|
753
756
|
logger.warning(f"Error stopping auto refresh: {e}")
|
|
754
757
|
|
|
755
|
-
# @deprecated, use set_features
|
|
756
758
|
def setFeatures(self, features: dict) -> None:
|
|
759
|
+
warnings.warn("setFeatures is deprecated, use set_features instead", DeprecationWarning)
|
|
757
760
|
return self.set_features(features)
|
|
758
761
|
|
|
759
762
|
def set_features(self, features: dict) -> None:
|
|
@@ -776,23 +779,23 @@ class GrowthBook(object):
|
|
|
776
779
|
finally:
|
|
777
780
|
self._is_updating_features = False
|
|
778
781
|
|
|
779
|
-
# @deprecated, use get_features
|
|
780
782
|
def getFeatures(self) -> Dict[str, Feature]:
|
|
783
|
+
warnings.warn("getFeatures is deprecated, use get_features instead", DeprecationWarning)
|
|
781
784
|
return self.get_features()
|
|
782
785
|
|
|
783
786
|
def get_features(self) -> Dict[str, Feature]:
|
|
784
787
|
return self._features
|
|
785
788
|
|
|
786
|
-
# @deprecated, use set_attributes
|
|
787
789
|
def setAttributes(self, attributes: dict) -> None:
|
|
790
|
+
warnings.warn("setAttributes is deprecated, use set_attributes instead", DeprecationWarning)
|
|
788
791
|
return self.set_attributes(attributes)
|
|
789
792
|
|
|
790
793
|
def set_attributes(self, attributes: dict) -> None:
|
|
791
794
|
self._attributes = attributes
|
|
792
795
|
self.refresh_sticky_buckets()
|
|
793
796
|
|
|
794
|
-
# @deprecated, use get_attributes
|
|
795
797
|
def getAttributes(self) -> dict:
|
|
798
|
+
warnings.warn("getAttributes is deprecated, use get_attributes instead", DeprecationWarning)
|
|
796
799
|
return self.get_attributes()
|
|
797
800
|
|
|
798
801
|
def get_attributes(self) -> dict:
|
|
@@ -845,30 +848,30 @@ class GrowthBook(object):
|
|
|
845
848
|
except Exception as e:
|
|
846
849
|
logger.warning(f"Error clearing internal state: {e}")
|
|
847
850
|
|
|
848
|
-
# @deprecated, use is_on
|
|
849
851
|
def isOn(self, key: str) -> bool:
|
|
852
|
+
warnings.warn("isOn is deprecated, use is_on instead", DeprecationWarning)
|
|
850
853
|
return self.is_on(key)
|
|
851
854
|
|
|
852
855
|
def is_on(self, key: str) -> bool:
|
|
853
|
-
return self.
|
|
856
|
+
return self.eval_feature(key).on
|
|
854
857
|
|
|
855
|
-
# @deprecated, use is_off
|
|
856
858
|
def isOff(self, key: str) -> bool:
|
|
859
|
+
warnings.warn("isOff is deprecated, use is_off instead", DeprecationWarning)
|
|
857
860
|
return self.is_off(key)
|
|
858
861
|
|
|
859
862
|
def is_off(self, key: str) -> bool:
|
|
860
|
-
return self.
|
|
863
|
+
return self.eval_feature(key).off
|
|
861
864
|
|
|
862
|
-
# @deprecated, use get_feature_value
|
|
863
865
|
def getFeatureValue(self, key: str, fallback):
|
|
866
|
+
warnings.warn("getFeatureValue is deprecated, use get_feature_value instead", DeprecationWarning)
|
|
864
867
|
return self.get_feature_value(key, fallback)
|
|
865
868
|
|
|
866
869
|
def get_feature_value(self, key: str, fallback):
|
|
867
870
|
res = self.eval_feature(key)
|
|
868
871
|
return res.value if res.value is not None else fallback
|
|
869
872
|
|
|
870
|
-
# @deprecated, use eval_feature
|
|
871
873
|
def evalFeature(self, key: str) -> FeatureResult:
|
|
874
|
+
warnings.warn("evalFeature is deprecated, use eval_feature instead", DeprecationWarning)
|
|
872
875
|
return self.eval_feature(key)
|
|
873
876
|
|
|
874
877
|
def _ensure_fresh_features(self) -> None:
|
|
@@ -916,32 +919,30 @@ class GrowthBook(object):
|
|
|
916
919
|
pass
|
|
917
920
|
return result
|
|
918
921
|
|
|
919
|
-
# @deprecated, use get_all_results
|
|
920
922
|
def getAllResults(self):
|
|
923
|
+
warnings.warn("getAllResults is deprecated, use get_all_results instead", DeprecationWarning)
|
|
921
924
|
return self.get_all_results()
|
|
922
925
|
|
|
923
926
|
def get_all_results(self):
|
|
924
927
|
return self._assigned.copy()
|
|
925
928
|
|
|
926
929
|
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
|
|
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
|
|
945
946
|
|
|
946
947
|
def run(self, experiment: Experiment) -> Result:
|
|
947
948
|
# result = self._run(experiment)
|
|
@@ -970,8 +971,8 @@ class GrowthBook(object):
|
|
|
970
971
|
try:
|
|
971
972
|
self._trackingCallback(experiment=experiment, result=result, user_context=user_context)
|
|
972
973
|
self._tracked[key] = True
|
|
973
|
-
except Exception:
|
|
974
|
-
|
|
974
|
+
except Exception as e:
|
|
975
|
+
logger.exception(e)
|
|
975
976
|
|
|
976
977
|
def _derive_sticky_bucket_identifier_attributes(self) -> List[str]:
|
|
977
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
|
|
@@ -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
|
|
@@ -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,
|
|
@@ -322,6 +322,68 @@ def test_handles_weird_experiment_values():
|
|
|
322
322
|
gb.destroy()
|
|
323
323
|
|
|
324
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
|
+
|
|
325
387
|
def test_force_variation():
|
|
326
388
|
gb = GrowthBook(attributes={"id": "6"})
|
|
327
389
|
exp = Experiment(key="forced-test", variations=[0, 1])
|
|
@@ -895,5 +895,78 @@ async def test_feature_usage_callback_error_handling():
|
|
|
895
895
|
is_on = await client.is_on("feature-1", user_context)
|
|
896
896
|
assert is_on is True
|
|
897
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
|
+
|
|
898
971
|
finally:
|
|
899
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
|