growthbook 1.4.6__tar.gz → 1.4.8__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.
Files changed (27) hide show
  1. {growthbook-1.4.6/growthbook.egg-info → growthbook-1.4.8}/PKG-INFO +20 -1
  2. {growthbook-1.4.6 → growthbook-1.4.8}/README.md +18 -0
  3. {growthbook-1.4.6 → growthbook-1.4.8}/growthbook/__init__.py +1 -1
  4. {growthbook-1.4.6 → growthbook-1.4.8}/growthbook/common_types.py +58 -55
  5. {growthbook-1.4.6 → growthbook-1.4.8}/growthbook/core.py +34 -29
  6. {growthbook-1.4.6 → growthbook-1.4.8}/growthbook/growthbook.py +159 -58
  7. {growthbook-1.4.6 → growthbook-1.4.8}/growthbook/growthbook_client.py +2 -2
  8. {growthbook-1.4.6 → growthbook-1.4.8}/growthbook/plugins/growthbook_tracking.py +1 -1
  9. {growthbook-1.4.6 → growthbook-1.4.8}/growthbook/plugins/request_context.py +2 -1
  10. {growthbook-1.4.6 → growthbook-1.4.8/growthbook.egg-info}/PKG-INFO +20 -1
  11. {growthbook-1.4.6 → growthbook-1.4.8}/growthbook.egg-info/SOURCES.txt +1 -0
  12. {growthbook-1.4.6 → growthbook-1.4.8}/pyproject.toml +28 -0
  13. {growthbook-1.4.6 → growthbook-1.4.8}/setup.cfg +1 -1
  14. {growthbook-1.4.6 → growthbook-1.4.8}/setup.py +1 -0
  15. growthbook-1.4.8/tests/test_etag.py +345 -0
  16. {growthbook-1.4.6 → growthbook-1.4.8}/tests/test_growthbook.py +83 -5
  17. {growthbook-1.4.6 → growthbook-1.4.8}/tests/test_growthbook_client.py +73 -0
  18. {growthbook-1.4.6 → growthbook-1.4.8}/LICENSE +0 -0
  19. {growthbook-1.4.6 → growthbook-1.4.8}/MANIFEST.in +0 -0
  20. {growthbook-1.4.6 → growthbook-1.4.8}/growthbook/plugins/__init__.py +0 -0
  21. {growthbook-1.4.6 → growthbook-1.4.8}/growthbook/plugins/base.py +0 -0
  22. {growthbook-1.4.6 → growthbook-1.4.8}/growthbook/py.typed +0 -0
  23. {growthbook-1.4.6 → growthbook-1.4.8}/growthbook.egg-info/dependency_links.txt +0 -0
  24. {growthbook-1.4.6 → growthbook-1.4.8}/growthbook.egg-info/requires.txt +0 -0
  25. {growthbook-1.4.6 → growthbook-1.4.8}/growthbook.egg-info/top_level.txt +0 -0
  26. {growthbook-1.4.6 → growthbook-1.4.8}/tests/conftest.py +0 -0
  27. {growthbook-1.4.6 → growthbook-1.4.8}/tests/test_plugins.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: growthbook
3
- Version: 1.4.6
3
+ Version: 1.4.8
4
4
  Summary: Powerful Feature flagging and A/B testing for Python apps
5
5
  Home-page: https://github.com/growthbook/growthbook-python
6
6
  Author: GrowthBook
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.9
18
18
  Classifier: Programming Language :: Python :: 3.10
19
19
  Classifier: Programming Language :: Python :: 3.11
20
20
  Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Typing :: Typed
21
22
  Requires-Python: >=3.7
22
23
  Description-Content-Type: text/markdown
23
24
  License-File: LICENSE
@@ -56,6 +57,24 @@ Powerful Feature flagging and A/B testing for Python apps.
56
57
 
57
58
  `pip install growthbook` (recommended) or copy `growthbook.py` into your project
58
59
 
60
+ ## Type Checking Support
61
+
62
+ The GrowthBook Python SDK is fully typed and includes inline type hints for all public APIs. This enables:
63
+
64
+ - **Better IDE support** with autocomplete and inline documentation
65
+ - **Type safety** - catch bugs at development time with mypy or other type checkers
66
+ - **Better code documentation** - types serve as inline documentation
67
+ - **Safer refactoring** - type checkers will catch breaking changes
68
+
69
+ To use type checking with mypy:
70
+
71
+ ```bash
72
+ pip install mypy
73
+ mypy your_code.py
74
+ ```
75
+
76
+ The SDK includes a `py.typed` marker file and is compliant with [PEP 561](https://www.python.org/dev/peps/pep-0561/).
77
+
59
78
  ## Quick Usage
60
79
 
61
80
  ```python
@@ -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
@@ -18,5 +18,5 @@ from .plugins import (
18
18
  )
19
19
 
20
20
  # x-release-please-start-version
21
- __version__ = "1.4.6"
21
+ __version__ = "1.4.8"
22
22
  # x-release-please-end
@@ -29,30 +29,30 @@ class Experiment(object):
29
29
  def __init__(
30
30
  self,
31
31
  key: str,
32
- variations: list,
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: int = None,
37
- condition: dict = None,
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: list = None,
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[dict] = None,
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: dict) -> None:
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) -> dict:
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) -> dict:
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: list = []) -> None:
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) -> dict:
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: list = None,
278
- weights: List[float] = None,
279
- coverage: int = None,
280
- condition: dict = None,
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[dict] = None,
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) -> dict:
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: dict, condition: dict, savedGroups: dict = None) -> bool:
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
- ) -> dict:
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