growthbook 1.4.6__py2.py3-none-any.whl → 1.4.8__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.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:
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
@@ -322,6 +323,10 @@ class SSEClient:
322
323
  except Exception as e:
323
324
  logger.warning(f"Error during SSE task cleanup: {e}")
324
325
 
326
+ from collections import OrderedDict
327
+
328
+ # ... (imports)
329
+
325
330
  class FeatureRepository(object):
326
331
  def __init__(self) -> None:
327
332
  self.cache: AbstractFeatureCache = InMemoryFeatureCache()
@@ -333,6 +338,12 @@ class FeatureRepository(object):
333
338
  self._refresh_thread: Optional[threading.Thread] = None
334
339
  self._refresh_stop_event = threading.Event()
335
340
  self._refresh_lock = threading.Lock()
341
+
342
+ # ETag cache for bandwidth optimization
343
+ # Using OrderedDict for LRU cache (max 100 entries)
344
+ self._etag_cache: OrderedDict[str, Tuple[str, Dict[str, Any]]] = OrderedDict()
345
+ self._max_etag_entries = 100
346
+ self._etag_lock = threading.Lock()
336
347
 
337
348
  def set_cache(self, cache: AbstractFeatureCache) -> None:
338
349
  self.cache = cache
@@ -399,34 +410,124 @@ class FeatureRepository(object):
399
410
  return cached
400
411
 
401
412
  # Perform the GET request (separate method for easy mocking)
402
- def _get(self, url: str):
413
+ def _get(self, url: str, headers: Optional[Dict[str, str]] = None):
403
414
  self.http = self.http or PoolManager()
404
- return self.http.request("GET", url)
405
-
415
+ return self.http.request("GET", url, headers=headers or {})
416
+
406
417
  def _fetch_and_decode(self, api_host: str, client_key: str) -> Optional[Dict]:
418
+ url = self._get_features_url(api_host, client_key)
419
+ headers: Dict[str, str] = {}
420
+
421
+ # Check if we have a cached ETag for this URL
422
+ cached_etag = None
423
+ cached_data = None
424
+ with self._etag_lock:
425
+ if url in self._etag_cache:
426
+ # Move to end (mark as recently used)
427
+ self._etag_cache.move_to_end(url)
428
+ cached_etag, cached_data = self._etag_cache[url]
429
+ headers['If-None-Match'] = cached_etag
430
+ logger.debug(f"Using cached ETag for request: {cached_etag[:20]}...")
431
+ else:
432
+ logger.debug(f"No ETag cache found for URL: {url}")
433
+
407
434
  try:
408
- r = self._get(self._get_features_url(api_host, client_key))
435
+ r = self._get(url, headers)
436
+
437
+ # Handle 304 Not Modified - content hasn't changed
438
+ if r.status == 304:
439
+ logger.debug(f"ETag match! Server returned 304 Not Modified - using cached data (saved bandwidth)")
440
+ if cached_data is not None:
441
+ logger.debug(f"Returning cached response ({len(str(cached_data))} bytes)")
442
+ return cached_data
443
+ else:
444
+ logger.warning("Received 304 but no cached data available")
445
+ return None
446
+
409
447
  if r.status >= 400:
410
448
  logger.warning(
411
449
  "Failed to fetch features, received status code %d", r.status
412
450
  )
413
451
  return None
452
+
414
453
  decoded = json.loads(r.data.decode("utf-8"))
415
- return decoded
416
- except Exception:
417
- logger.warning("Failed to decode feature JSON from GrowthBook API")
454
+
455
+ # Store the new ETag if present
456
+ response_etag = r.headers.get('ETag')
457
+ if response_etag:
458
+ with self._etag_lock:
459
+ self._etag_cache[url] = (response_etag, decoded)
460
+ # Enforce max size
461
+ if len(self._etag_cache) > self._max_etag_entries:
462
+ self._etag_cache.popitem(last=False)
463
+
464
+ if cached_etag:
465
+ logger.debug(f"ETag updated: {cached_etag[:20]}... -> {response_etag[:20]}...")
466
+ else:
467
+ logger.debug(f"New ETag cached: {response_etag[:20]}... ({len(str(decoded))} bytes)")
468
+ logger.debug(f"ETag cache now contains {len(self._etag_cache)} entries")
469
+ else:
470
+ logger.debug("No ETag header in response")
471
+
472
+ return decoded # type: ignore[no-any-return]
473
+ except Exception as e:
474
+ logger.warning(f"Failed to decode feature JSON from GrowthBook API: {e}")
418
475
  return None
419
476
 
420
477
  async def _fetch_and_decode_async(self, api_host: str, client_key: str) -> Optional[Dict]:
478
+ url = self._get_features_url(api_host, client_key)
479
+ headers: Dict[str, str] = {}
480
+
481
+ # Check if we have a cached ETag for this URL
482
+ cached_etag = None
483
+ cached_data = None
484
+ with self._etag_lock:
485
+ if url in self._etag_cache:
486
+ # Move to end (mark as recently used)
487
+ self._etag_cache.move_to_end(url)
488
+ cached_etag, cached_data = self._etag_cache[url]
489
+ headers['If-None-Match'] = cached_etag
490
+ logger.debug(f"[Async] Using cached ETag for request: {cached_etag[:20]}...")
491
+ else:
492
+ logger.debug(f"[Async] No ETag cache found for URL: {url}")
493
+
421
494
  try:
422
- url = self._get_features_url(api_host, client_key)
423
495
  async with aiohttp.ClientSession() as session:
424
- async with session.get(url) as response:
496
+ async with session.get(url, headers=headers) as response:
497
+ # Handle 304 Not Modified - content hasn't changed
498
+ if response.status == 304:
499
+ logger.debug(f"[Async] ETag match! Server returned 304 Not Modified - using cached data (saved bandwidth)")
500
+ if cached_data is not None:
501
+ logger.debug(f"[Async] Returning cached response ({len(str(cached_data))} bytes)")
502
+ return cached_data
503
+ else:
504
+ logger.warning("[Async] Received 304 but no cached data available")
505
+ return None
506
+
425
507
  if response.status >= 400:
426
508
  logger.warning("Failed to fetch features, received status code %d", response.status)
427
509
  return None
510
+
428
511
  decoded = await response.json()
429
- return decoded
512
+
513
+ # Store the new ETag if present
514
+ response_etag = response.headers.get('ETag')
515
+ if response_etag:
516
+ with self._etag_lock:
517
+ self._etag_cache[url] = (response_etag, decoded)
518
+ # Enforce max size
519
+ if len(self._etag_cache) > self._max_etag_entries:
520
+ self._etag_cache.popitem(last=False)
521
+
522
+ if cached_etag:
523
+ logger.debug(f"[Async] ETag updated: {cached_etag[:20]}... -> {response_etag[:20]}...")
524
+ else:
525
+ logger.debug(f"[Async] New ETag cached: {response_etag[:20]}... ({len(str(decoded))} bytes)")
526
+ logger.debug(f"[Async] ETag cache now contains {len(self._etag_cache)} entries")
527
+ else:
528
+ logger.debug("[Async] No ETag header in response")
529
+
530
+ return decoded # type: ignore[no-any-return]
430
531
  except aiohttp.ClientError as e:
431
532
  logger.warning(f"HTTP request failed: {e}")
432
533
  return None
@@ -475,7 +576,7 @@ class FeatureRepository(object):
475
576
 
476
577
  data = self.decrypt_response(decoded, decryption_key)
477
578
 
478
- return data
579
+ return data # type: ignore[no-any-return]
479
580
 
480
581
  async def _fetch_features_async(
481
582
  self, api_host: str, client_key: str, decryption_key: str = ""
@@ -486,7 +587,7 @@ class FeatureRepository(object):
486
587
 
487
588
  data = self.decrypt_response(decoded, decryption_key)
488
589
 
489
- return data
590
+ return data # type: ignore[no-any-return]
490
591
 
491
592
 
492
593
  def startAutoRefresh(self, api_host, client_key, cb, streaming_timeout=30):
@@ -574,15 +675,16 @@ class GrowthBook(object):
574
675
  client_key: str = "",
575
676
  decryption_key: str = "",
576
677
  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 = {},
678
+ forced_variations: Optional[Dict[str, Any]] = None,
679
+ sticky_bucket_service: Optional[AbstractStickyBucketService] = None,
680
+ sticky_bucket_identifier_attributes: Optional[List[str]] = None,
681
+ savedGroups: Optional[Dict[str, Any]] = None,
581
682
  streaming: bool = False,
582
683
  streaming_connection_timeout: int = 30,
583
684
  stale_while_revalidate: bool = False,
584
685
  stale_ttl: int = 300, # 5 minutes default
585
- plugins: List = None,
686
+ plugins: Optional[List[Any]] = None,
687
+ skip_all_experiments: bool = False,
586
688
  # Deprecated args
587
689
  trackingCallback=None,
588
690
  qaMode: bool = False,
@@ -595,7 +697,7 @@ class GrowthBook(object):
595
697
  self._attributes = attributes
596
698
  self._url = url
597
699
  self._features: Dict[str, Feature] = {}
598
- self._saved_groups = savedGroups
700
+ self._saved_groups = savedGroups if savedGroups is not None else {}
599
701
  self._api_host = api_host
600
702
  self._client_key = client_key
601
703
  self._decryption_key = decryption_key
@@ -609,6 +711,7 @@ class GrowthBook(object):
609
711
  self._qaMode = qa_mode or qaMode
610
712
  self._trackingCallback = on_experiment_viewed or trackingCallback
611
713
  self._featureUsageCallback = on_feature_usage
714
+ self._skip_all_experiments = skip_all_experiments
612
715
 
613
716
  self._streaming = streaming
614
717
  self._streaming_timeout = streaming_connection_timeout
@@ -619,7 +722,7 @@ class GrowthBook(object):
619
722
  self._user = user
620
723
  self._groups = groups
621
724
  self._overrides = overrides
622
- self._forcedVariations = forced_variations or forcedVariations
725
+ self._forcedVariations = (forced_variations if forced_variations is not None else forcedVariations) if forced_variations is not None or forcedVariations else {}
623
726
 
624
727
  self._tracked: Dict[str, Any] = {}
625
728
  self._assigned: Dict[str, Any] = {}
@@ -627,8 +730,8 @@ class GrowthBook(object):
627
730
  self._is_updating_features = False
628
731
 
629
732
  # support plugins
630
- self._plugins: List = plugins or []
631
- self._initialized_plugins: List = []
733
+ self._plugins: List[Any] = plugins if plugins is not None else []
734
+ self._initialized_plugins: List[Any] = []
632
735
 
633
736
  self._global_ctx = GlobalContext(
634
737
  options=Options(
@@ -652,7 +755,8 @@ class GrowthBook(object):
652
755
  groups=self._groups,
653
756
  forced_variations=self._forcedVariations,
654
757
  overrides=self._overrides,
655
- sticky_bucket_assignment_docs=self._sticky_bucket_assignment_docs
758
+ sticky_bucket_assignment_docs=self._sticky_bucket_assignment_docs,
759
+ skip_all_experiments=self._skip_all_experiments
656
760
  )
657
761
 
658
762
  if features:
@@ -683,7 +787,6 @@ class GrowthBook(object):
683
787
  self._saved_groups = features_data["savedGroups"]
684
788
 
685
789
  def load_features(self) -> None:
686
-
687
790
  response = feature_repo.load_features(
688
791
  self._api_host, self._client_key, self._decryption_key, self._cache_ttl
689
792
  )
@@ -752,8 +855,8 @@ class GrowthBook(object):
752
855
  except Exception as e:
753
856
  logger.warning(f"Error stopping auto refresh: {e}")
754
857
 
755
- # @deprecated, use set_features
756
858
  def setFeatures(self, features: dict) -> None:
859
+ warnings.warn("setFeatures is deprecated, use set_features instead", DeprecationWarning)
757
860
  return self.set_features(features)
758
861
 
759
862
  def set_features(self, features: dict) -> None:
@@ -776,23 +879,23 @@ class GrowthBook(object):
776
879
  finally:
777
880
  self._is_updating_features = False
778
881
 
779
- # @deprecated, use get_features
780
882
  def getFeatures(self) -> Dict[str, Feature]:
883
+ warnings.warn("getFeatures is deprecated, use get_features instead", DeprecationWarning)
781
884
  return self.get_features()
782
885
 
783
886
  def get_features(self) -> Dict[str, Feature]:
784
887
  return self._features
785
888
 
786
- # @deprecated, use set_attributes
787
889
  def setAttributes(self, attributes: dict) -> None:
890
+ warnings.warn("setAttributes is deprecated, use set_attributes instead", DeprecationWarning)
788
891
  return self.set_attributes(attributes)
789
892
 
790
893
  def set_attributes(self, attributes: dict) -> None:
791
894
  self._attributes = attributes
792
895
  self.refresh_sticky_buckets()
793
896
 
794
- # @deprecated, use get_attributes
795
897
  def getAttributes(self) -> dict:
898
+ warnings.warn("getAttributes is deprecated, use get_attributes instead", DeprecationWarning)
796
899
  return self.get_attributes()
797
900
 
798
901
  def get_attributes(self) -> dict:
@@ -845,30 +948,30 @@ class GrowthBook(object):
845
948
  except Exception as e:
846
949
  logger.warning(f"Error clearing internal state: {e}")
847
950
 
848
- # @deprecated, use is_on
849
951
  def isOn(self, key: str) -> bool:
952
+ warnings.warn("isOn is deprecated, use is_on instead", DeprecationWarning)
850
953
  return self.is_on(key)
851
954
 
852
955
  def is_on(self, key: str) -> bool:
853
- return self.evalFeature(key).on
956
+ return self.eval_feature(key).on
854
957
 
855
- # @deprecated, use is_off
856
958
  def isOff(self, key: str) -> bool:
959
+ warnings.warn("isOff is deprecated, use is_off instead", DeprecationWarning)
857
960
  return self.is_off(key)
858
961
 
859
962
  def is_off(self, key: str) -> bool:
860
- return self.evalFeature(key).off
963
+ return self.eval_feature(key).off
861
964
 
862
- # @deprecated, use get_feature_value
863
965
  def getFeatureValue(self, key: str, fallback):
966
+ warnings.warn("getFeatureValue is deprecated, use get_feature_value instead", DeprecationWarning)
864
967
  return self.get_feature_value(key, fallback)
865
968
 
866
969
  def get_feature_value(self, key: str, fallback):
867
970
  res = self.eval_feature(key)
868
971
  return res.value if res.value is not None else fallback
869
972
 
870
- # @deprecated, use eval_feature
871
973
  def evalFeature(self, key: str) -> FeatureResult:
974
+ warnings.warn("evalFeature is deprecated, use eval_feature instead", DeprecationWarning)
872
975
  return self.eval_feature(key)
873
976
 
874
977
  def _ensure_fresh_features(self) -> None:
@@ -916,32 +1019,30 @@ class GrowthBook(object):
916
1019
  pass
917
1020
  return result
918
1021
 
919
- # @deprecated, use get_all_results
920
1022
  def getAllResults(self):
1023
+ warnings.warn("getAllResults is deprecated, use get_all_results instead", DeprecationWarning)
921
1024
  return self.get_all_results()
922
1025
 
923
1026
  def get_all_results(self):
924
1027
  return self._assigned.copy()
925
1028
 
926
1029
  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
1030
+ if experiment is not None:
1031
+ prev = self._assigned.get(experiment.key, None)
1032
+ if (
1033
+ not prev
1034
+ or prev["result"].inExperiment != result.inExperiment
1035
+ or prev["result"].variationId != result.variationId
1036
+ ):
1037
+ self._assigned[experiment.key] = {
1038
+ "experiment": experiment,
1039
+ "result": result,
1040
+ }
1041
+ for cb in self._subscriptions:
1042
+ try:
1043
+ cb(experiment, result)
1044
+ except Exception:
1045
+ pass
945
1046
 
946
1047
  def run(self, experiment: Experiment) -> Result:
947
1048
  # result = self._run(experiment)
@@ -970,8 +1071,8 @@ class GrowthBook(object):
970
1071
  try:
971
1072
  self._trackingCallback(experiment=experiment, result=result, user_context=user_context)
972
1073
  self._tracked[key] = True
973
- except Exception:
974
- pass
1074
+ except Exception as e:
1075
+ logger.exception(e)
975
1076
 
976
1077
  def _derive_sticky_bucket_identifier_attributes(self) -> List[str]:
977
1078
  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.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
@@ -0,0 +1,15 @@
1
+ growthbook/__init__.py,sha256=BwGsRsv5njoTII6ZRvPJZg96aPrOC4s0KFJbGbWI5m0,444
2
+ growthbook/common_types.py,sha256=YKUmmYfzgrzLQ7kp2IPLc8QBA-B0QbnbF5viekNiTpw,15703
3
+ growthbook/core.py,sha256=C1Nes_AiEuu6ypPghKuIeM2F22XUsLK1KrLW0xDwLYU,35963
4
+ growthbook/growthbook.py,sha256=CzBahR87qYySyWT2-JLmmWtb70Xr9PtknVorPtfHftw,46479
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.8.dist-info/licenses/LICENSE,sha256=D-TcBckB0dTPUlNJ8jBiTIJIj1ekHLB1CY7HJtJKhMY,1069
12
+ growthbook-1.4.8.dist-info/METADATA,sha256=ogrwnsEpsEZfwGsT3WtnMNbxecVH8abUWlXA2svIQOc,22726
13
+ growthbook-1.4.8.dist-info/WHEEL,sha256=JNWh1Fm1UdwIQV075glCn4MVuCRs0sotJIq-J6rbxCU,109
14
+ growthbook-1.4.8.dist-info/top_level.txt,sha256=dzfRQFGYejCIUstRSrrRVTMlxf7pBqASTI5S8gGRlXw,11
15
+ growthbook-1.4.8.dist-info/RECORD,,
@@ -1,15 +0,0 @@
1
- growthbook/__init__.py,sha256=1B2U7uOmsNvvEQ3DQ2hLuvALy6pqnJAduUUIXFbFbso,444
2
- growthbook/common_types.py,sha256=OMfssahxLjvCGuGsWa75G6JMmu5xH1hVrK0pCL1ArMU,14972
3
- growthbook/core.py,sha256=n9nwna26iZTY48LIvQqu5N_RrE35X0wlRBhq0-Qdb-s,35241
4
- growthbook/growthbook.py,sha256=8U6UmxHAdLDCsUi3CBFTvK4nBchs9-ukXwd_bZAiXsY,40333
5
- growthbook/growthbook_client.py,sha256=igD6T9MP9rfYzS1Dk0wBJ3fgfNzJxDQZ_bm5SS8XO7I,24632
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.6.dist-info/licenses/LICENSE,sha256=D-TcBckB0dTPUlNJ8jBiTIJIj1ekHLB1CY7HJtJKhMY,1069
12
- growthbook-1.4.6.dist-info/METADATA,sha256=q04J95e-Kgf9yssVQCfG_JJZHGHWsTDndDwZm1c74eg,22073
13
- growthbook-1.4.6.dist-info/WHEEL,sha256=JNWh1Fm1UdwIQV075glCn4MVuCRs0sotJIq-J6rbxCU,109
14
- growthbook-1.4.6.dist-info/top_level.txt,sha256=dzfRQFGYejCIUstRSrrRVTMlxf7pBqASTI5S8gGRlXw,11
15
- growthbook-1.4.6.dist-info/RECORD,,