growthbook 1.4.5__py2.py3-none-any.whl → 1.4.7__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
growthbook/__init__.py CHANGED
@@ -18,5 +18,5 @@ from .plugins import (
18
18
  )
19
19
 
20
20
  # x-release-please-start-version
21
- __version__ = "1.4.5"
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:
@@ -426,7 +429,7 @@ class Options:
426
429
  sticky_bucket_service: Optional[AbstractStickyBucketService] = None
427
430
  sticky_bucket_identifier_attributes: Optional[List[str]] = None
428
431
  on_experiment_viewed: Optional[Callable[[Experiment, Result, Optional[UserContext]], None]] = None
429
- on_feature_usage: Optional[Callable[[str, 'FeatureResult'], None]] = None
432
+ on_feature_usage: Optional[Callable[[str, 'FeatureResult', UserContext], None]] = None
430
433
  tracking_plugins: Optional[List[Any]] = None
431
434
 
432
435
 
growthbook/core.py CHANGED
@@ -9,7 +9,7 @@ from .common_types import EvaluationContext, FeatureResult, Experiment, Filter,
9
9
 
10
10
  logger = logging.getLogger("growthbook.core")
11
11
 
12
- def evalCondition(attributes: 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
growthbook/growthbook.py CHANGED
@@ -9,6 +9,7 @@ import sys
9
9
  import json
10
10
  import threading
11
11
  import logging
12
+ import warnings
12
13
 
13
14
  from abc import ABC, abstractmethod
14
15
  from typing import Optional, Any, Set, Tuple, List, Dict, Callable
@@ -204,14 +205,14 @@ class SSEClient:
204
205
  break
205
206
  except (ClientConnectorError, ClientPayloadError) as e:
206
207
  logger.error(f"Streaming error: {e}")
207
- if not self.is_running:
208
- break
209
208
  await self._wait_for_reconnect()
209
+ if not self.is_running:
210
+ break # type: ignore[unreachable]
210
211
  except TimeoutError:
211
212
  logger.warning(f"Streaming connection timed out after {self.timeout} seconds.")
212
- if not self.is_running:
213
- break
214
213
  await self._wait_for_reconnect()
214
+ if not self.is_running:
215
+ break # type: ignore[unreachable]
215
216
  except asyncio.CancelledError:
216
217
  logger.debug("SSE session cancelled")
217
218
  break
@@ -412,7 +413,7 @@ class FeatureRepository(object):
412
413
  )
413
414
  return None
414
415
  decoded = json.loads(r.data.decode("utf-8"))
415
- return decoded
416
+ return decoded # type: ignore[no-any-return]
416
417
  except Exception:
417
418
  logger.warning("Failed to decode feature JSON from GrowthBook API")
418
419
  return None
@@ -426,7 +427,7 @@ class FeatureRepository(object):
426
427
  logger.warning("Failed to fetch features, received status code %d", response.status)
427
428
  return None
428
429
  decoded = await response.json()
429
- return decoded
430
+ return decoded # type: ignore[no-any-return]
430
431
  except aiohttp.ClientError as e:
431
432
  logger.warning(f"HTTP request failed: {e}")
432
433
  return None
@@ -475,7 +476,7 @@ class FeatureRepository(object):
475
476
 
476
477
  data = self.decrypt_response(decoded, decryption_key)
477
478
 
478
- return data
479
+ return data # type: ignore[no-any-return]
479
480
 
480
481
  async def _fetch_features_async(
481
482
  self, api_host: str, client_key: str, decryption_key: str = ""
@@ -486,7 +487,7 @@ class FeatureRepository(object):
486
487
 
487
488
  data = self.decrypt_response(decoded, decryption_key)
488
489
 
489
- return data
490
+ return data # type: ignore[no-any-return]
490
491
 
491
492
 
492
493
  def startAutoRefresh(self, api_host, client_key, cb, streaming_timeout=30):
@@ -503,6 +504,10 @@ class FeatureRepository(object):
503
504
 
504
505
  def start_background_refresh(self, api_host: str, client_key: str, decryption_key: str, ttl: int = 600, refresh_interval: int = 300) -> None:
505
506
  """Start periodic background refresh task"""
507
+
508
+ if not client_key:
509
+ raise ValueError("Must specify `client_key` to refresh features")
510
+
506
511
  with self._refresh_lock:
507
512
  if self._refresh_thread is not None:
508
513
  return # Already running
@@ -570,15 +575,16 @@ class GrowthBook(object):
570
575
  client_key: str = "",
571
576
  decryption_key: str = "",
572
577
  cache_ttl: int = 600,
573
- forced_variations: dict = {},
574
- sticky_bucket_service: AbstractStickyBucketService = None,
575
- sticky_bucket_identifier_attributes: List[str] = None,
576
- 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,
577
582
  streaming: bool = False,
578
583
  streaming_connection_timeout: int = 30,
579
584
  stale_while_revalidate: bool = False,
580
585
  stale_ttl: int = 300, # 5 minutes default
581
- plugins: List = None,
586
+ plugins: Optional[List[Any]] = None,
587
+ skip_all_experiments: bool = False,
582
588
  # Deprecated args
583
589
  trackingCallback=None,
584
590
  qaMode: bool = False,
@@ -591,7 +597,7 @@ class GrowthBook(object):
591
597
  self._attributes = attributes
592
598
  self._url = url
593
599
  self._features: Dict[str, Feature] = {}
594
- self._saved_groups = savedGroups
600
+ self._saved_groups = savedGroups if savedGroups is not None else {}
595
601
  self._api_host = api_host
596
602
  self._client_key = client_key
597
603
  self._decryption_key = decryption_key
@@ -605,6 +611,7 @@ class GrowthBook(object):
605
611
  self._qaMode = qa_mode or qaMode
606
612
  self._trackingCallback = on_experiment_viewed or trackingCallback
607
613
  self._featureUsageCallback = on_feature_usage
614
+ self._skip_all_experiments = skip_all_experiments
608
615
 
609
616
  self._streaming = streaming
610
617
  self._streaming_timeout = streaming_connection_timeout
@@ -615,15 +622,16 @@ class GrowthBook(object):
615
622
  self._user = user
616
623
  self._groups = groups
617
624
  self._overrides = overrides
618
- self._forcedVariations = forced_variations or forcedVariations
625
+ self._forcedVariations = (forced_variations if forced_variations is not None else forcedVariations) if forced_variations is not None or forcedVariations else {}
619
626
 
620
627
  self._tracked: Dict[str, Any] = {}
621
628
  self._assigned: Dict[str, Any] = {}
622
629
  self._subscriptions: Set[Any] = set()
630
+ self._is_updating_features = False
623
631
 
624
632
  # support plugins
625
- self._plugins: List = plugins or []
626
- self._initialized_plugins: List = []
633
+ self._plugins: List[Any] = plugins if plugins is not None else []
634
+ self._initialized_plugins: List[Any] = []
627
635
 
628
636
  self._global_ctx = GlobalContext(
629
637
  options=Options(
@@ -647,7 +655,8 @@ class GrowthBook(object):
647
655
  groups=self._groups,
648
656
  forced_variations=self._forcedVariations,
649
657
  overrides=self._overrides,
650
- sticky_bucket_assignment_docs=self._sticky_bucket_assignment_docs
658
+ sticky_bucket_assignment_docs=self._sticky_bucket_assignment_docs,
659
+ skip_all_experiments=self._skip_all_experiments
651
660
  )
652
661
 
653
662
  if features:
@@ -662,7 +671,7 @@ class GrowthBook(object):
662
671
  if self._streaming:
663
672
  self.load_features()
664
673
  self.startAutoRefresh()
665
- elif self._stale_while_revalidate and self._client_key:
674
+ elif self._stale_while_revalidate:
666
675
  # Start background refresh task for stale-while-revalidate
667
676
  self.load_features() # Initial load
668
677
  feature_repo.start_background_refresh(
@@ -678,7 +687,6 @@ class GrowthBook(object):
678
687
  self._saved_groups = features_data["savedGroups"]
679
688
 
680
689
  def load_features(self) -> None:
681
-
682
690
  response = feature_repo.load_features(
683
691
  self._api_host, self._client_key, self._decryption_key, self._cache_ttl
684
692
  )
@@ -747,42 +755,47 @@ class GrowthBook(object):
747
755
  except Exception as e:
748
756
  logger.warning(f"Error stopping auto refresh: {e}")
749
757
 
750
- # @deprecated, use set_features
751
758
  def setFeatures(self, features: dict) -> None:
759
+ warnings.warn("setFeatures is deprecated, use set_features instead", DeprecationWarning)
752
760
  return self.set_features(features)
753
761
 
754
762
  def set_features(self, features: dict) -> None:
755
- self._features = {}
756
- for key, feature in features.items():
757
- if isinstance(feature, Feature):
758
- self._features[key] = feature
759
- else:
760
- self._features[key] = Feature(
761
- rules=feature.get("rules", []),
762
- defaultValue=feature.get("defaultValue", None),
763
- )
764
- # Update the global context with the new features and saved groups
765
- self._global_ctx.features = self._features
766
- self._global_ctx.saved_groups = self._saved_groups
767
- self.refresh_sticky_buckets()
763
+ # Prevent infinite recursion during feature updates
764
+ self._is_updating_features = True
765
+ try:
766
+ self._features = {}
767
+ for key, feature in features.items():
768
+ if isinstance(feature, Feature):
769
+ self._features[key] = feature
770
+ else:
771
+ self._features[key] = Feature(
772
+ rules=feature.get("rules", []),
773
+ defaultValue=feature.get("defaultValue", None),
774
+ )
775
+ # Update the global context with the new features and saved groups
776
+ self._global_ctx.features = self._features
777
+ self._global_ctx.saved_groups = self._saved_groups
778
+ self.refresh_sticky_buckets()
779
+ finally:
780
+ self._is_updating_features = False
768
781
 
769
- # @deprecated, use get_features
770
782
  def getFeatures(self) -> Dict[str, Feature]:
783
+ warnings.warn("getFeatures is deprecated, use get_features instead", DeprecationWarning)
771
784
  return self.get_features()
772
785
 
773
786
  def get_features(self) -> Dict[str, Feature]:
774
787
  return self._features
775
788
 
776
- # @deprecated, use set_attributes
777
789
  def setAttributes(self, attributes: dict) -> None:
790
+ warnings.warn("setAttributes is deprecated, use set_attributes instead", DeprecationWarning)
778
791
  return self.set_attributes(attributes)
779
792
 
780
793
  def set_attributes(self, attributes: dict) -> None:
781
794
  self._attributes = attributes
782
795
  self.refresh_sticky_buckets()
783
796
 
784
- # @deprecated, use get_attributes
785
797
  def getAttributes(self) -> dict:
798
+ warnings.warn("getAttributes is deprecated, use get_attributes instead", DeprecationWarning)
786
799
  return self.get_attributes()
787
800
 
788
801
  def get_attributes(self) -> dict:
@@ -835,35 +848,39 @@ class GrowthBook(object):
835
848
  except Exception as e:
836
849
  logger.warning(f"Error clearing internal state: {e}")
837
850
 
838
- # @deprecated, use is_on
839
851
  def isOn(self, key: str) -> bool:
852
+ warnings.warn("isOn is deprecated, use is_on instead", DeprecationWarning)
840
853
  return self.is_on(key)
841
854
 
842
855
  def is_on(self, key: str) -> bool:
843
- return self.evalFeature(key).on
856
+ return self.eval_feature(key).on
844
857
 
845
- # @deprecated, use is_off
846
858
  def isOff(self, key: str) -> bool:
859
+ warnings.warn("isOff is deprecated, use is_off instead", DeprecationWarning)
847
860
  return self.is_off(key)
848
861
 
849
862
  def is_off(self, key: str) -> bool:
850
- return self.evalFeature(key).off
863
+ return self.eval_feature(key).off
851
864
 
852
- # @deprecated, use get_feature_value
853
865
  def getFeatureValue(self, key: str, fallback):
866
+ warnings.warn("getFeatureValue is deprecated, use get_feature_value instead", DeprecationWarning)
854
867
  return self.get_feature_value(key, fallback)
855
868
 
856
869
  def get_feature_value(self, key: str, fallback):
857
870
  res = self.eval_feature(key)
858
871
  return res.value if res.value is not None else fallback
859
872
 
860
- # @deprecated, use eval_feature
861
873
  def evalFeature(self, key: str) -> FeatureResult:
874
+ warnings.warn("evalFeature is deprecated, use eval_feature instead", DeprecationWarning)
862
875
  return self.eval_feature(key)
863
876
 
864
877
  def _ensure_fresh_features(self) -> None:
865
878
  """Lazy refresh: Check cache expiry and refresh if needed, but only if client_key is provided"""
866
879
 
880
+ # Prevent infinite recursion when updating features (e.g., during sticky bucket refresh)
881
+ if self._is_updating_features:
882
+ return
883
+
867
884
  if self._streaming or self._stale_while_revalidate or not self._client_key:
868
885
  return # Skip cache checks - SSE or background refresh handles freshness
869
886
 
@@ -897,37 +914,35 @@ class GrowthBook(object):
897
914
  # Call feature usage callback if provided
898
915
  if self._featureUsageCallback:
899
916
  try:
900
- self._featureUsageCallback(key, result)
917
+ self._featureUsageCallback(key, result, self._user_ctx)
901
918
  except Exception:
902
919
  pass
903
920
  return result
904
921
 
905
- # @deprecated, use get_all_results
906
922
  def getAllResults(self):
923
+ warnings.warn("getAllResults is deprecated, use get_all_results instead", DeprecationWarning)
907
924
  return self.get_all_results()
908
925
 
909
926
  def get_all_results(self):
910
927
  return self._assigned.copy()
911
928
 
912
929
  def _fireSubscriptions(self, experiment: Experiment, result: Result):
913
- if experiment is None:
914
- return
915
-
916
- prev = self._assigned.get(experiment.key, None)
917
- if (
918
- not prev
919
- or prev["result"].inExperiment != result.inExperiment
920
- or prev["result"].variationId != result.variationId
921
- ):
922
- self._assigned[experiment.key] = {
923
- "experiment": experiment,
924
- "result": result,
925
- }
926
- for cb in self._subscriptions:
927
- try:
928
- cb(experiment, result)
929
- except Exception:
930
- pass
930
+ if experiment is not None:
931
+ prev = self._assigned.get(experiment.key, None)
932
+ if (
933
+ not prev
934
+ or prev["result"].inExperiment != result.inExperiment
935
+ or prev["result"].variationId != result.variationId
936
+ ):
937
+ self._assigned[experiment.key] = {
938
+ "experiment": experiment,
939
+ "result": result,
940
+ }
941
+ for cb in self._subscriptions:
942
+ try:
943
+ cb(experiment, result)
944
+ except Exception:
945
+ pass
931
946
 
932
947
  def run(self, experiment: Experiment) -> Result:
933
948
  # result = self._run(experiment)
@@ -956,8 +971,8 @@ class GrowthBook(object):
956
971
  try:
957
972
  self._trackingCallback(experiment=experiment, result=result, user_context=user_context)
958
973
  self._tracked[key] = True
959
- except Exception:
960
- pass
974
+ except Exception as e:
975
+ logger.exception(e)
961
976
 
962
977
  def _derive_sticky_bucket_identifier_attributes(self) -> List[str]:
963
978
  attributes = set()
@@ -81,7 +81,7 @@ class WeakRefWrapper:
81
81
  class FeatureCache:
82
82
  """Thread-safe feature cache"""
83
83
  def __init__(self):
84
- self._cache = {
84
+ self._cache: Dict[str, Dict[str, Any]] = {
85
85
  'features': {},
86
86
  'savedGroups': {}
87
87
  }
@@ -114,7 +114,7 @@ class EnhancedFeatureRepository(FeatureRepository, metaclass=SingletonMeta):
114
114
  self._backoff = BackoffStrategy()
115
115
  self._feature_cache = FeatureCache()
116
116
  self._callbacks: List[Callable[[Dict[str, Any]], Awaitable[None]]] = []
117
- self._last_successful_refresh = None
117
+ self._last_successful_refresh: Optional[datetime] = None
118
118
  self._refresh_in_progress = asyncio.Lock()
119
119
 
120
120
  @asynccontextmanager
@@ -503,7 +503,7 @@ class GrowthBookClient:
503
503
  # Call feature usage callback if provided
504
504
  if self.options.on_feature_usage:
505
505
  try:
506
- self.options.on_feature_usage(key, result)
506
+ self.options.on_feature_usage(key, result, user_context)
507
507
  except Exception:
508
508
  logger.exception("Error in feature usage callback")
509
509
  return result
@@ -516,7 +516,7 @@ class GrowthBookClient:
516
516
  # Call feature usage callback if provided
517
517
  if self.options.on_feature_usage:
518
518
  try:
519
- self.options.on_feature_usage(key, result)
519
+ self.options.on_feature_usage(key, result, user_context)
520
520
  except Exception:
521
521
  logger.exception("Error in feature usage callback")
522
522
  return result.on
@@ -529,7 +529,7 @@ class GrowthBookClient:
529
529
  # Call feature usage callback if provided
530
530
  if self.options.on_feature_usage:
531
531
  try:
532
- self.options.on_feature_usage(key, result)
532
+ self.options.on_feature_usage(key, result, user_context)
533
533
  except Exception:
534
534
  logger.exception("Error in feature usage callback")
535
535
  return result.off
@@ -541,7 +541,7 @@ class GrowthBookClient:
541
541
  # Call feature usage callback if provided
542
542
  if self.options.on_feature_usage:
543
543
  try:
544
- self.options.on_feature_usage(key, result)
544
+ self.options.on_feature_usage(key, result, user_context)
545
545
  except Exception:
546
546
  logger.exception("Error in feature usage callback")
547
547
  return result.value if result.value is not None else fallback
@@ -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.5
3
+ Version: 1.4.7
4
4
  Summary: Powerful Feature flagging and A/B testing for Python apps
5
5
  Home-page: https://github.com/growthbook/growthbook-python
6
6
  Author: GrowthBook
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.9
18
18
  Classifier: Programming Language :: Python :: 3.10
19
19
  Classifier: Programming Language :: Python :: 3.11
20
20
  Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Typing :: Typed
21
22
  Requires-Python: >=3.7
22
23
  Description-Content-Type: text/markdown
23
24
  License-File: LICENSE
@@ -56,6 +57,24 @@ Powerful Feature flagging and A/B testing for Python apps.
56
57
 
57
58
  `pip install growthbook` (recommended) or copy `growthbook.py` into your project
58
59
 
60
+ ## Type Checking Support
61
+
62
+ The GrowthBook Python SDK is fully typed and includes inline type hints for all public APIs. This enables:
63
+
64
+ - **Better IDE support** with autocomplete and inline documentation
65
+ - **Type safety** - catch bugs at development time with mypy or other type checkers
66
+ - **Better code documentation** - types serve as inline documentation
67
+ - **Safer refactoring** - type checkers will catch breaking changes
68
+
69
+ To use type checking with mypy:
70
+
71
+ ```bash
72
+ pip install mypy
73
+ mypy your_code.py
74
+ ```
75
+
76
+ The SDK includes a `py.typed` marker file and is compliant with [PEP 561](https://www.python.org/dev/peps/pep-0561/).
77
+
59
78
  ## Quick Usage
60
79
 
61
80
  ```python
@@ -499,7 +518,6 @@ class MyStickyBucketService(AbstractStickyBucketService):
499
518
  })
500
519
 
501
520
  # Pass in an instance of this service to your GrowthBook constructor
502
-
503
521
  gb = GrowthBook(
504
522
  sticky_bucket_service = MyStickyBucketService()
505
523
  )
@@ -0,0 +1,15 @@
1
+ growthbook/__init__.py,sha256=7k-g5BJSG0vZIYGhL7zsqJaTX2CiBNpWcYJ7N6XwRnU,444
2
+ growthbook/common_types.py,sha256=YKUmmYfzgrzLQ7kp2IPLc8QBA-B0QbnbF5viekNiTpw,15703
3
+ growthbook/core.py,sha256=C1Nes_AiEuu6ypPghKuIeM2F22XUsLK1KrLW0xDwLYU,35963
4
+ growthbook/growthbook.py,sha256=TiQ1zsEsBEErKpeqExFm53yXsZjF5mZKPXT_2lFBXdw,41544
5
+ growthbook/growthbook_client.py,sha256=ZzdeNZ1a9N78ISbj2BKN9Xmyt05VlPVNjMyh9E1eA0E,24679
6
+ growthbook/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ growthbook/plugins/__init__.py,sha256=y2eAV1sA041XWcftBVTDH0t-ggy9r2C5oKRYRF6XR6s,602
8
+ growthbook/plugins/base.py,sha256=PWBXUBj62hi25Y5Eif9WmEWagWdkwGXHi2dMtn44bo8,3637
9
+ growthbook/plugins/growthbook_tracking.py,sha256=lWO9ErUSrnqhcpWLp03XIrh45-BdBssdmLDVvaGvulY,11317
10
+ growthbook/plugins/request_context.py,sha256=WzoGxalxPfrsN3RzfkvVYaUGat1A3N4AErnaS9IZ48Y,13005
11
+ growthbook-1.4.7.dist-info/licenses/LICENSE,sha256=D-TcBckB0dTPUlNJ8jBiTIJIj1ekHLB1CY7HJtJKhMY,1069
12
+ growthbook-1.4.7.dist-info/METADATA,sha256=MuP_A9PoMrrHoV__8meCwZTsbh3wVZa2AyxietxD6bs,22726
13
+ growthbook-1.4.7.dist-info/WHEEL,sha256=JNWh1Fm1UdwIQV075glCn4MVuCRs0sotJIq-J6rbxCU,109
14
+ growthbook-1.4.7.dist-info/top_level.txt,sha256=dzfRQFGYejCIUstRSrrRVTMlxf7pBqASTI5S8gGRlXw,11
15
+ growthbook-1.4.7.dist-info/RECORD,,
@@ -1,15 +0,0 @@
1
- growthbook/__init__.py,sha256=JLdue4592q3iH1ck1qG9Fbv-p8i07SJo-tUJhGCf60s,444
2
- growthbook/common_types.py,sha256=KYA9rmWRMde2JnsUjygsiaJ1q-KZakDdzPAtUOnrKyY,14959
3
- growthbook/core.py,sha256=n9nwna26iZTY48LIvQqu5N_RrE35X0wlRBhq0-Qdb-s,35241
4
- growthbook/growthbook.py,sha256=xjuX-q8oPsLqP0kWBZqy7LpoTkrgjMZCOWjtpUsQR1o,39793
5
- growthbook/growthbook_client.py,sha256=kfdc2NGhdmsXxaVIm0CBlNCMkwicfPpbDZ10nZSPd7w,24576
6
- growthbook/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- growthbook/plugins/__init__.py,sha256=y2eAV1sA041XWcftBVTDH0t-ggy9r2C5oKRYRF6XR6s,602
8
- growthbook/plugins/base.py,sha256=PWBXUBj62hi25Y5Eif9WmEWagWdkwGXHi2dMtn44bo8,3637
9
- growthbook/plugins/growthbook_tracking.py,sha256=FvPFOuKF_xKjmTX8x_hzMlHrrL-68Y2ZPw1Hfl2_ilQ,11333
10
- growthbook/plugins/request_context.py,sha256=O5FJDrjJR5u0rx3ENGO9cOsKMHd9e0l0Nvdb1PHfmm8,12951
11
- growthbook-1.4.5.dist-info/licenses/LICENSE,sha256=D-TcBckB0dTPUlNJ8jBiTIJIj1ekHLB1CY7HJtJKhMY,1069
12
- growthbook-1.4.5.dist-info/METADATA,sha256=8KBWZ908qYYku0GqZCeZ1KNS5YLPxs7FXZVl6wesLWM,22074
13
- growthbook-1.4.5.dist-info/WHEEL,sha256=JNWh1Fm1UdwIQV075glCn4MVuCRs0sotJIq-J6rbxCU,109
14
- growthbook-1.4.5.dist-info/top_level.txt,sha256=dzfRQFGYejCIUstRSrrRVTMlxf7pBqASTI5S8gGRlXw,11
15
- growthbook-1.4.5.dist-info/RECORD,,