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.
Files changed (26) hide show
  1. {growthbook-1.4.6/growthbook.egg-info → growthbook-1.4.7}/PKG-INFO +20 -1
  2. {growthbook-1.4.6 → growthbook-1.4.7}/README.md +18 -0
  3. {growthbook-1.4.6 → growthbook-1.4.7}/growthbook/__init__.py +1 -1
  4. {growthbook-1.4.6 → growthbook-1.4.7}/growthbook/common_types.py +58 -55
  5. {growthbook-1.4.6 → growthbook-1.4.7}/growthbook/core.py +34 -29
  6. {growthbook-1.4.6 → growthbook-1.4.7}/growthbook/growthbook.py +51 -50
  7. {growthbook-1.4.6 → growthbook-1.4.7}/growthbook/growthbook_client.py +2 -2
  8. {growthbook-1.4.6 → growthbook-1.4.7}/growthbook/plugins/growthbook_tracking.py +1 -1
  9. {growthbook-1.4.6 → growthbook-1.4.7}/growthbook/plugins/request_context.py +2 -1
  10. {growthbook-1.4.6 → growthbook-1.4.7/growthbook.egg-info}/PKG-INFO +20 -1
  11. {growthbook-1.4.6 → growthbook-1.4.7}/pyproject.toml +28 -0
  12. {growthbook-1.4.6 → growthbook-1.4.7}/setup.cfg +1 -1
  13. {growthbook-1.4.6 → growthbook-1.4.7}/setup.py +1 -0
  14. {growthbook-1.4.6 → growthbook-1.4.7}/tests/test_growthbook.py +62 -0
  15. {growthbook-1.4.6 → growthbook-1.4.7}/tests/test_growthbook_client.py +73 -0
  16. {growthbook-1.4.6 → growthbook-1.4.7}/LICENSE +0 -0
  17. {growthbook-1.4.6 → growthbook-1.4.7}/MANIFEST.in +0 -0
  18. {growthbook-1.4.6 → growthbook-1.4.7}/growthbook/plugins/__init__.py +0 -0
  19. {growthbook-1.4.6 → growthbook-1.4.7}/growthbook/plugins/base.py +0 -0
  20. {growthbook-1.4.6 → growthbook-1.4.7}/growthbook/py.typed +0 -0
  21. {growthbook-1.4.6 → growthbook-1.4.7}/growthbook.egg-info/SOURCES.txt +0 -0
  22. {growthbook-1.4.6 → growthbook-1.4.7}/growthbook.egg-info/dependency_links.txt +0 -0
  23. {growthbook-1.4.6 → growthbook-1.4.7}/growthbook.egg-info/requires.txt +0 -0
  24. {growthbook-1.4.6 → growthbook-1.4.7}/growthbook.egg-info/top_level.txt +0 -0
  25. {growthbook-1.4.6 → growthbook-1.4.7}/tests/conftest.py +0 -0
  26. {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.6
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
@@ -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.7"
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
@@ -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: dict = {},
578
- sticky_bucket_service: AbstractStickyBucketService = None,
579
- sticky_bucket_identifier_attributes: List[str] = None,
580
- savedGroups: dict = {},
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 or []
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.evalFeature(key).on
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.evalFeature(key).off
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
- return
929
-
930
- prev = self._assigned.get(experiment.key, None)
931
- if (
932
- not prev
933
- or prev["result"].inExperiment != result.inExperiment
934
- or prev["result"].variationId != result.variationId
935
- ):
936
- self._assigned[experiment.key] = {
937
- "experiment": experiment,
938
- "result": result,
939
- }
940
- for cb in self._subscriptions:
941
- try:
942
- cb(experiment, result)
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
- pass
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
@@ -6,7 +6,7 @@ from typing import Dict, Any, Optional, List, Callable, TYPE_CHECKING
6
6
  from .base import GrowthBookPlugin
7
7
 
8
8
  if TYPE_CHECKING:
9
- import requests # type: ignore
9
+ import requests
10
10
  else:
11
11
  try:
12
12
  import requests # type: ignore
@@ -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.6
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]
@@ -1,5 +1,5 @@
1
1
  [bumpversion]
2
- current_version = 1.4.6
2
+ current_version = 1.4.7
3
3
  commit = True
4
4
  tag = True
5
5
 
@@ -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