growthbook 1.4.3__tar.gz → 1.4.4__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.3/growthbook.egg-info → growthbook-1.4.4}/PKG-INFO +1 -1
  2. {growthbook-1.4.3 → growthbook-1.4.4}/growthbook/__init__.py +1 -1
  3. {growthbook-1.4.3 → growthbook-1.4.4}/growthbook/growthbook.py +74 -3
  4. {growthbook-1.4.3 → growthbook-1.4.4/growthbook.egg-info}/PKG-INFO +1 -1
  5. {growthbook-1.4.3 → growthbook-1.4.4}/setup.cfg +1 -1
  6. {growthbook-1.4.3 → growthbook-1.4.4}/tests/test_growthbook.py +160 -0
  7. {growthbook-1.4.3 → growthbook-1.4.4}/LICENSE +0 -0
  8. {growthbook-1.4.3 → growthbook-1.4.4}/MANIFEST.in +0 -0
  9. {growthbook-1.4.3 → growthbook-1.4.4}/README.md +0 -0
  10. {growthbook-1.4.3 → growthbook-1.4.4}/growthbook/common_types.py +0 -0
  11. {growthbook-1.4.3 → growthbook-1.4.4}/growthbook/core.py +0 -0
  12. {growthbook-1.4.3 → growthbook-1.4.4}/growthbook/growthbook_client.py +0 -0
  13. {growthbook-1.4.3 → growthbook-1.4.4}/growthbook/plugins/__init__.py +0 -0
  14. {growthbook-1.4.3 → growthbook-1.4.4}/growthbook/plugins/base.py +0 -0
  15. {growthbook-1.4.3 → growthbook-1.4.4}/growthbook/plugins/growthbook_tracking.py +0 -0
  16. {growthbook-1.4.3 → growthbook-1.4.4}/growthbook/plugins/request_context.py +0 -0
  17. {growthbook-1.4.3 → growthbook-1.4.4}/growthbook/py.typed +0 -0
  18. {growthbook-1.4.3 → growthbook-1.4.4}/growthbook.egg-info/SOURCES.txt +0 -0
  19. {growthbook-1.4.3 → growthbook-1.4.4}/growthbook.egg-info/dependency_links.txt +0 -0
  20. {growthbook-1.4.3 → growthbook-1.4.4}/growthbook.egg-info/requires.txt +0 -0
  21. {growthbook-1.4.3 → growthbook-1.4.4}/growthbook.egg-info/top_level.txt +0 -0
  22. {growthbook-1.4.3 → growthbook-1.4.4}/pyproject.toml +0 -0
  23. {growthbook-1.4.3 → growthbook-1.4.4}/setup.py +0 -0
  24. {growthbook-1.4.3 → growthbook-1.4.4}/tests/conftest.py +0 -0
  25. {growthbook-1.4.3 → growthbook-1.4.4}/tests/test_growthbook_client.py +0 -0
  26. {growthbook-1.4.3 → growthbook-1.4.4}/tests/test_plugins.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: growthbook
3
- Version: 1.4.3
3
+ Version: 1.4.4
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,5 +18,5 @@ from .plugins import (
18
18
  )
19
19
 
20
20
  # x-release-please-start-version
21
- __version__ = "1.4.3"
21
+ __version__ = "1.4.4"
22
22
  # x-release-please-end
@@ -100,7 +100,8 @@ class InMemoryFeatureCache(AbstractFeatureCache):
100
100
  def set(self, key: str, value: Dict, ttl: int) -> None:
101
101
  if key in self.cache:
102
102
  self.cache[key].update(value)
103
- self.cache[key] = CacheEntry(value, ttl)
103
+ else:
104
+ self.cache[key] = CacheEntry(value, ttl)
104
105
 
105
106
  def clear(self) -> None:
106
107
  self.cache.clear()
@@ -327,6 +328,11 @@ class FeatureRepository(object):
327
328
  self.http: Optional[PoolManager] = None
328
329
  self.sse_client: Optional[SSEClient] = None
329
330
  self._feature_update_callbacks: List[Callable[[Dict], None]] = []
331
+
332
+ # Background refresh support
333
+ self._refresh_thread: Optional[threading.Thread] = None
334
+ self._refresh_stop_event = threading.Event()
335
+ self._refresh_lock = threading.Lock()
330
336
 
331
337
  def set_cache(self, cache: AbstractFeatureCache) -> None:
332
338
  self.cache = cache
@@ -375,6 +381,7 @@ class FeatureRepository(object):
375
381
  return res
376
382
  return cached
377
383
 
384
+
378
385
  async def load_features_async(
379
386
  self, api_host: str, client_key: str, decryption_key: str = "", ttl: int = 600
380
387
  ) -> Optional[Dict]:
@@ -493,6 +500,52 @@ class FeatureRepository(object):
493
500
  if self.sse_client:
494
501
  self.sse_client.disconnect(timeout=timeout)
495
502
  self.sse_client = None
503
+
504
+ def start_background_refresh(self, api_host: str, client_key: str, decryption_key: str, ttl: int = 600, refresh_interval: int = 300) -> None:
505
+ """Start periodic background refresh task"""
506
+ with self._refresh_lock:
507
+ if self._refresh_thread is not None:
508
+ return # Already running
509
+
510
+ self._refresh_stop_event.clear()
511
+ self._refresh_thread = threading.Thread(
512
+ target=self._background_refresh_worker,
513
+ args=(api_host, client_key, decryption_key, ttl, refresh_interval),
514
+ daemon=True
515
+ )
516
+ self._refresh_thread.start()
517
+ logger.debug("Started background refresh task")
518
+
519
+ def _background_refresh_worker(self, api_host: str, client_key: str, decryption_key: str, ttl: int, refresh_interval: int) -> None:
520
+ """Worker method for periodic background refresh"""
521
+ while not self._refresh_stop_event.is_set():
522
+ try:
523
+ # Wait for the refresh interval or stop event
524
+ if self._refresh_stop_event.wait(refresh_interval):
525
+ break # Stop event was set
526
+
527
+ logger.debug("Background refresh for Features - started")
528
+ res = self._fetch_features(api_host, client_key, decryption_key)
529
+ if res is not None:
530
+ cache_key = api_host + "::" + client_key
531
+ self.cache.set(cache_key, res, ttl)
532
+ logger.debug("Background refresh completed")
533
+ # Notify callbacks about fresh features
534
+ self._notify_feature_update_callbacks(res)
535
+ else:
536
+ logger.warning("Background refresh failed")
537
+ except Exception as e:
538
+ logger.warning(f"Background refresh error: {e}")
539
+
540
+ def stop_background_refresh(self) -> None:
541
+ """Stop background refresh task"""
542
+ self._refresh_stop_event.set()
543
+
544
+ with self._refresh_lock:
545
+ if self._refresh_thread is not None:
546
+ self._refresh_thread.join(timeout=1.0) # Wait up to 1 second
547
+ self._refresh_thread = None
548
+ logger.debug("Stopped background refresh task")
496
549
 
497
550
  @staticmethod
498
551
  def _get_features_url(api_host: str, client_key: str) -> str:
@@ -522,6 +575,8 @@ class GrowthBook(object):
522
575
  savedGroups: dict = {},
523
576
  streaming: bool = False,
524
577
  streaming_connection_timeout: int = 30,
578
+ stale_while_revalidate: bool = False,
579
+ stale_ttl: int = 300, # 5 minutes default
525
580
  plugins: List = None,
526
581
  # Deprecated args
527
582
  trackingCallback=None,
@@ -551,6 +606,8 @@ class GrowthBook(object):
551
606
 
552
607
  self._streaming = streaming
553
608
  self._streaming_timeout = streaming_connection_timeout
609
+ self._stale_while_revalidate = stale_while_revalidate
610
+ self._stale_ttl = stale_ttl
554
611
 
555
612
  # Deprecated args
556
613
  self._user = user
@@ -603,6 +660,13 @@ class GrowthBook(object):
603
660
  if self._streaming:
604
661
  self.load_features()
605
662
  self.startAutoRefresh()
663
+ elif self._stale_while_revalidate and self._client_key:
664
+ # Start background refresh task for stale-while-revalidate
665
+ self.load_features() # Initial load
666
+ feature_repo.start_background_refresh(
667
+ self._api_host, self._client_key, self._decryption_key,
668
+ self._cache_ttl, self._stale_ttl
669
+ )
606
670
 
607
671
  def _on_feature_update(self, features_data: Dict) -> None:
608
672
  """Callback to handle automatic feature updates from FeatureRepository"""
@@ -739,6 +803,13 @@ class GrowthBook(object):
739
803
  except Exception as e:
740
804
  logger.warning(f"Error stopping auto refresh during destroy: {e}")
741
805
 
806
+ try:
807
+ # Stop background refresh operations
808
+ if self._stale_while_revalidate and self._client_key:
809
+ feature_repo.stop_background_refresh()
810
+ except Exception as e:
811
+ logger.warning(f"Error stopping background refresh during destroy: {e}")
812
+
742
813
  try:
743
814
  # Clean up feature update callback
744
815
  if self._client_key:
@@ -790,8 +861,8 @@ class GrowthBook(object):
790
861
  def _ensure_fresh_features(self) -> None:
791
862
  """Lazy refresh: Check cache expiry and refresh if needed, but only if client_key is provided"""
792
863
 
793
- if self._streaming or not self._client_key:
794
- return # Skip cache checks - SSE handles freshness for streaming users
864
+ if self._streaming or self._stale_while_revalidate or not self._client_key:
865
+ return # Skip cache checks - SSE or background refresh handles freshness
795
866
 
796
867
  try:
797
868
  self.load_features()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: growthbook
3
- Version: 1.4.3
3
+ Version: 1.4.4
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
@@ -1,5 +1,5 @@
1
1
  [bumpversion]
2
- current_version = 1.4.3
2
+ current_version = 1.4.4
3
3
  commit = True
4
4
  tag = True
5
5
 
@@ -1009,4 +1009,164 @@ def test_multiple_instances_get_updated_on_cache_expiry(mocker):
1009
1009
  finally:
1010
1010
  gb1.destroy()
1011
1011
  gb2.destroy()
1012
+ feature_repo.clear_cache()
1013
+
1014
+
1015
+ def test_stale_while_revalidate_basic_functionality(mocker):
1016
+ """Test basic stale-while-revalidate functionality"""
1017
+ # Mock responses - first call returns v1, subsequent calls return v2
1018
+ mock_responses = [
1019
+ {"features": {"test_feature": {"defaultValue": "v1"}}, "savedGroups": {}},
1020
+ {"features": {"test_feature": {"defaultValue": "v2"}}, "savedGroups": {}}
1021
+ ]
1022
+
1023
+ call_count = 0
1024
+ def mock_fetch_features(api_host, client_key, decryption_key=""):
1025
+ nonlocal call_count
1026
+ response = mock_responses[min(call_count, len(mock_responses) - 1)]
1027
+ call_count += 1
1028
+ return response
1029
+
1030
+ # Clear cache and mock the fetch method
1031
+ feature_repo.clear_cache()
1032
+ m = mocker.patch.object(feature_repo, '_fetch_features', side_effect=mock_fetch_features)
1033
+
1034
+ # Create GrowthBook instance with stale-while-revalidate enabled and short refresh interval
1035
+ gb = GrowthBook(
1036
+ api_host="https://cdn.growthbook.io",
1037
+ client_key="test-key",
1038
+ cache_ttl=10, # 10 second TTL
1039
+ stale_while_revalidate=True,
1040
+ stale_ttl=1 # 1 second refresh interval for testing
1041
+ )
1042
+
1043
+ try:
1044
+ # Initial evaluation - should use initial loaded data
1045
+ assert gb.get_feature_value('test_feature', 'default') == "v1"
1046
+ assert call_count == 1 # Initial load
1047
+
1048
+ # Wait for background refresh to happen
1049
+ import time as time_module
1050
+ time_module.sleep(1.5) # Wait longer than refresh interval
1051
+
1052
+ # Should have triggered background refresh
1053
+ assert call_count >= 2
1054
+
1055
+ # Next evaluation should get updated data from background refresh
1056
+ assert gb.get_feature_value('test_feature', 'default') == "v2"
1057
+
1058
+ finally:
1059
+ gb.destroy()
1060
+ feature_repo.clear_cache()
1061
+
1062
+
1063
+ def test_stale_while_revalidate_starts_background_task(mocker):
1064
+ """Test that stale-while-revalidate starts background refresh task"""
1065
+ mock_response = {"features": {"test_feature": {"defaultValue": "fresh"}}, "savedGroups": {}}
1066
+
1067
+ call_count = 0
1068
+ def mock_fetch_features(api_host, client_key, decryption_key=""):
1069
+ nonlocal call_count
1070
+ call_count += 1
1071
+ return mock_response
1072
+
1073
+ # Clear cache and mock the fetch method
1074
+ feature_repo.clear_cache()
1075
+ m = mocker.patch.object(feature_repo, '_fetch_features', side_effect=mock_fetch_features)
1076
+
1077
+ # Create GrowthBook instance with stale-while-revalidate enabled
1078
+ gb = GrowthBook(
1079
+ api_host="https://cdn.growthbook.io",
1080
+ client_key="test-key",
1081
+ stale_while_revalidate=True,
1082
+ stale_ttl=5
1083
+ )
1084
+
1085
+ try:
1086
+ # Should have started background refresh task
1087
+ assert feature_repo._refresh_thread is not None
1088
+ assert feature_repo._refresh_thread.is_alive()
1089
+
1090
+ # Initial evaluation should work
1091
+ assert gb.get_feature_value('test_feature', 'default') == "fresh"
1092
+ assert call_count == 1 # Initial load
1093
+
1094
+ finally:
1095
+ gb.destroy()
1096
+ feature_repo.clear_cache()
1097
+
1098
+ def test_stale_while_revalidate_disabled_fallback(mocker):
1099
+ """Test that when stale_while_revalidate is disabled, it falls back to normal behavior"""
1100
+ mock_response = {"features": {"test_feature": {"defaultValue": "normal"}}, "savedGroups": {}}
1101
+
1102
+ call_count = 0
1103
+ def mock_fetch_features(api_host, client_key, decryption_key=""):
1104
+ nonlocal call_count
1105
+ call_count += 1
1106
+ return mock_response
1107
+
1108
+ # Clear cache and mock the fetch method
1109
+ feature_repo.clear_cache()
1110
+ m = mocker.patch.object(feature_repo, '_fetch_features', side_effect=mock_fetch_features)
1111
+
1112
+ # Create GrowthBook instance with stale-while-revalidate disabled (default)
1113
+ gb = GrowthBook(
1114
+ api_host="https://cdn.growthbook.io",
1115
+ client_key="test-key",
1116
+ cache_ttl=1, # Short TTL
1117
+ stale_while_revalidate=False # Explicitly disabled
1118
+ )
1119
+
1120
+ try:
1121
+ # Should NOT have started background refresh task
1122
+ assert feature_repo._refresh_thread is None
1123
+
1124
+ # Initial evaluation
1125
+ assert gb.get_feature_value('test_feature', 'default') == "normal"
1126
+ assert call_count == 1
1127
+
1128
+ # Manually expire the cache
1129
+ cache_key = "https://cdn.growthbook.io::test-key"
1130
+ if hasattr(feature_repo.cache, 'cache') and cache_key in feature_repo.cache.cache:
1131
+ feature_repo.cache.cache[cache_key].expires = time() - 10
1132
+
1133
+ # Next evaluation should fetch synchronously (normal behavior)
1134
+ assert gb.get_feature_value('test_feature', 'default') == "normal"
1135
+ assert call_count == 2 # Should have fetched again
1136
+
1137
+ finally:
1138
+ gb.destroy()
1139
+ feature_repo.clear_cache()
1140
+
1141
+
1142
+ def test_stale_while_revalidate_cleanup(mocker):
1143
+ """Test that background refresh is properly cleaned up"""
1144
+ mock_response = {"features": {"test_feature": {"defaultValue": "test"}}, "savedGroups": {}}
1145
+
1146
+ # Mock the fetch method
1147
+ feature_repo.clear_cache()
1148
+ m = mocker.patch.object(feature_repo, '_fetch_features', return_value=mock_response)
1149
+
1150
+ # Create GrowthBook instance with stale-while-revalidate enabled
1151
+ gb = GrowthBook(
1152
+ api_host="https://cdn.growthbook.io",
1153
+ client_key="test-key",
1154
+ stale_while_revalidate=True
1155
+ )
1156
+
1157
+ try:
1158
+ # Should have started background refresh task
1159
+ assert feature_repo._refresh_thread is not None
1160
+ assert feature_repo._refresh_thread.is_alive()
1161
+
1162
+ # Destroy should clean up the background task
1163
+ gb.destroy()
1164
+
1165
+ # Background task should be stopped
1166
+ assert feature_repo._refresh_thread is None or not feature_repo._refresh_thread.is_alive()
1167
+
1168
+ finally:
1169
+ # Ensure cleanup even if test fails
1170
+ if feature_repo._refresh_thread:
1171
+ feature_repo.stop_background_refresh()
1012
1172
  feature_repo.clear_cache()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes