growthbook 1.4.10__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {growthbook-1.4.10/growthbook.egg-info → growthbook-2.1.0}/PKG-INFO +1 -1
  2. {growthbook-1.4.10 → growthbook-2.1.0}/growthbook/__init__.py +1 -1
  3. {growthbook-1.4.10 → growthbook-2.1.0}/growthbook/core.py +18 -0
  4. {growthbook-1.4.10 → growthbook-2.1.0}/growthbook/growthbook.py +4 -4
  5. {growthbook-1.4.10 → growthbook-2.1.0}/growthbook/growthbook_client.py +63 -19
  6. {growthbook-1.4.10 → growthbook-2.1.0}/growthbook/plugins/growthbook_tracking.py +2 -2
  7. {growthbook-1.4.10 → growthbook-2.1.0/growthbook.egg-info}/PKG-INFO +1 -1
  8. {growthbook-1.4.10 → growthbook-2.1.0}/setup.cfg +1 -1
  9. {growthbook-1.4.10 → growthbook-2.1.0}/tests/test_growthbook.py +17 -17
  10. {growthbook-1.4.10 → growthbook-2.1.0}/tests/test_growthbook_client.py +8 -2
  11. {growthbook-1.4.10 → growthbook-2.1.0}/LICENSE +0 -0
  12. {growthbook-1.4.10 → growthbook-2.1.0}/MANIFEST.in +0 -0
  13. {growthbook-1.4.10 → growthbook-2.1.0}/README.md +0 -0
  14. {growthbook-1.4.10 → growthbook-2.1.0}/growthbook/common_types.py +0 -0
  15. {growthbook-1.4.10 → growthbook-2.1.0}/growthbook/plugins/__init__.py +0 -0
  16. {growthbook-1.4.10 → growthbook-2.1.0}/growthbook/plugins/base.py +0 -0
  17. {growthbook-1.4.10 → growthbook-2.1.0}/growthbook/plugins/request_context.py +0 -0
  18. {growthbook-1.4.10 → growthbook-2.1.0}/growthbook/py.typed +0 -0
  19. {growthbook-1.4.10 → growthbook-2.1.0}/growthbook.egg-info/SOURCES.txt +0 -0
  20. {growthbook-1.4.10 → growthbook-2.1.0}/growthbook.egg-info/dependency_links.txt +0 -0
  21. {growthbook-1.4.10 → growthbook-2.1.0}/growthbook.egg-info/requires.txt +0 -0
  22. {growthbook-1.4.10 → growthbook-2.1.0}/growthbook.egg-info/top_level.txt +0 -0
  23. {growthbook-1.4.10 → growthbook-2.1.0}/pyproject.toml +0 -0
  24. {growthbook-1.4.10 → growthbook-2.1.0}/setup.py +0 -0
  25. {growthbook-1.4.10 → growthbook-2.1.0}/tests/conftest.py +0 -0
  26. {growthbook-1.4.10 → growthbook-2.1.0}/tests/test_etag.py +0 -0
  27. {growthbook-1.4.10 → growthbook-2.1.0}/tests/test_plugins.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: growthbook
3
- Version: 1.4.10
3
+ Version: 2.1.0
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.10"
21
+ __version__ = "2.1.0"
22
22
  # x-release-please-end
@@ -178,6 +178,24 @@ def evalOperatorCondition(operator, attributeValue, conditionValue, savedGroups)
178
178
  return bool(r.search(attributeValue))
179
179
  except Exception:
180
180
  return False
181
+ elif operator == "$regexi":
182
+ try:
183
+ r = re.compile(conditionValue, re.IGNORECASE)
184
+ return bool(r.search(attributeValue))
185
+ except Exception:
186
+ return False
187
+ elif operator == "$notRegex":
188
+ try:
189
+ r = re.compile(conditionValue)
190
+ return not bool(r.search(attributeValue))
191
+ except Exception:
192
+ return False
193
+ elif operator == "$notRegexi":
194
+ try:
195
+ r = re.compile(conditionValue, re.IGNORECASE)
196
+ return not bool(r.search(attributeValue))
197
+ except Exception:
198
+ return False
181
199
  elif operator == "$in":
182
200
  if not type(conditionValue) is list:
183
201
  return False
@@ -782,7 +782,7 @@ class GrowthBook(object):
782
782
  )
783
783
 
784
784
  if features:
785
- self.setFeatures(features)
785
+ self.set_features(features)
786
786
 
787
787
  # Register for automatic feature updates when cache expires
788
788
  if self._client_key:
@@ -813,7 +813,7 @@ class GrowthBook(object):
813
813
  self._api_host, self._client_key, self._decryption_key, self._cache_ttl
814
814
  )
815
815
  if response is not None and "features" in response.keys():
816
- self.setFeatures(response["features"])
816
+ self.set_features(response["features"])
817
817
 
818
818
  if response is not None and "savedGroups" in response:
819
819
  self._saved_groups = response["savedGroups"]
@@ -828,7 +828,7 @@ class GrowthBook(object):
828
828
 
829
829
  if features is not None:
830
830
  if "features" in features:
831
- self.setFeatures(features["features"])
831
+ self.set_features(features["features"])
832
832
  if "savedGroups" in features:
833
833
  self._saved_groups = features["savedGroups"]
834
834
  feature_repo.save_in_cache(self._client_key, features, self._cache_ttl)
@@ -842,7 +842,7 @@ class GrowthBook(object):
842
842
 
843
843
  if data is not None:
844
844
  if "features" in data:
845
- self.setFeatures(data["features"])
845
+ self.set_features(data["features"])
846
846
  if "savedGroups" in data:
847
847
  self._saved_groups = data["savedGroups"]
848
848
  feature_repo.save_in_cache(self._client_key, features, self._cache_ttl)
@@ -169,40 +169,78 @@ class EnhancedFeatureRepository(FeatureRepository, metaclass=SingletonMeta):
169
169
  if callback in self._callbacks:
170
170
  self._callbacks.remove(callback)
171
171
 
172
+ """
173
+ _start_sse_refresh flow mimics a bridge pattern to connect a blocking, synchronous background thread
174
+ (the SSEClient) with your non-blocking, async main loop.
175
+
176
+ Bridge - _maintain_sse_connection - runs on the main async loop, calls `startAutoRefresh` (which in turn spawns a thread)
177
+ and waits indefinitely. (Awaiting a Future suspends the coroutine, costing zero CPU)
178
+
179
+ The SSEClient runs in a separate thread, makes a blocking HTTP request, and invokes `on_event` synchronously.
180
+
181
+ The Hand off - when the event arrives (we're still on the background thread), sse_handler uses `asyncio.run_coroutine_threadsafe`
182
+ to schedule the async processing `_handle_sse_event` onto the main event loop.
183
+ """
172
184
  async def _start_sse_refresh(self) -> None:
173
185
  """Start SSE-based feature refresh"""
174
186
  with self._refresh_lock:
175
187
  if self._refresh_task is not None: # Already running
176
188
  return
177
189
 
178
- async def sse_handler(event_data: Dict[str, Any]) -> None:
190
+ # SSEClient invokes `on_event` synchronously from a background thread.
191
+ async def _handle_sse_event(event_data: Dict[str, Any]) -> None:
179
192
  try:
180
- if event_data['type'] == 'features-updated':
193
+ event_type = event_data.get("type")
194
+ if event_type == "features-updated":
181
195
  response = await self.load_features_async(
182
196
  self._api_host, self._client_key, self._decryption_key, self._cache_ttl
183
197
  )
184
198
  if response is not None:
185
199
  await self._handle_feature_update(response)
186
- elif event_data['type'] == 'features':
187
- await self._handle_feature_update(event_data['data'])
200
+ elif event_type == "features":
201
+ await self._handle_feature_update(event_data.get("data", {}))
188
202
  except Exception:
189
- traceback.print_exc()
203
+ logger.exception("Error handling SSE event")
190
204
 
191
- # Start the SSE connection task
192
- self._refresh_task = asyncio.create_task(
193
- self._maintain_sse_connection(sse_handler)
194
- )
205
+ main_loop = asyncio.get_running_loop()
195
206
 
196
- async def _maintain_sse_connection(self, handler: Callable) -> None:
197
- """Maintain SSE connection with automatic reconnection"""
198
- while not self._stop_event.is_set():
199
- try:
200
- await self.startAutoRefresh(self._api_host, self._client_key, handler)
201
- except Exception as e:
202
- if not self._stop_event.is_set():
203
- delay = self._backoff.next_delay()
204
- logger.error(f"SSE connection lost, reconnecting in {delay:.2f}s: {str(e)}")
205
- await asyncio.sleep(delay)
207
+ # We must not pass an `async def` callback here (it would never be awaited).
208
+ def sse_handler(event_data: Dict[str, Any]) -> None:
209
+ # Schedule async processing onto the main event loop.
210
+ try:
211
+ asyncio.run_coroutine_threadsafe(_handle_sse_event(event_data), main_loop)
212
+ except Exception:
213
+ logger.exception("Failed to schedule SSE event handler")
214
+
215
+ async def _maintain_sse_connection() -> None:
216
+ """
217
+ Start SSE streaming and keep the task alive until cancelled.
218
+ """
219
+ try:
220
+ # NOTE: `startAutoRefresh` is synchronous and starts a background thread.
221
+ self.startAutoRefresh(self._api_host, self._client_key, sse_handler)
222
+
223
+ # Wait indefinitely until the task is cancelled - basically saying "Keep this service 'active' until someone cancels me."
224
+ # reconnection logic is handled inside SSEClient's thread
225
+ await asyncio.Future()
226
+ except asyncio.CancelledError:
227
+ # Normal shutdown flow
228
+ raise
229
+ except Exception:
230
+ logger.exception("Unexpected error in SSE lifecycle task")
231
+ finally:
232
+ try:
233
+ # stopAutoRefresh blocks joining a thread, so it needs to be run in executor
234
+ # to avoid blocking the async event loop
235
+ await main_loop.run_in_executor(
236
+ None,
237
+ lambda: self.stopAutoRefresh(timeout=10)
238
+ )
239
+ except Exception:
240
+ logger.exception("Failed to stop SSE auto-refresh")
241
+
242
+ # Start a task that owns the SSE lifecycle and cleanup.
243
+ self._refresh_task = asyncio.create_task(_maintain_sse_connection())
206
244
 
207
245
  async def _start_http_refresh(self, interval: int = 60) -> None:
208
246
  """Enhanced HTTP polling with backoff"""
@@ -261,6 +299,12 @@ class EnhancedFeatureRepository(FeatureRepository, metaclass=SingletonMeta):
261
299
  async def stop_refresh(self) -> None:
262
300
  """Clean shutdown of refresh tasks"""
263
301
  self._stop_event.set()
302
+ # Ensure any SSE background thread is stopped as well.
303
+ try:
304
+ self.stopAutoRefresh(timeout=10)
305
+ except Exception:
306
+ # Best-effort cleanup; task cancellation below will proceed.
307
+ logger.exception("Error stopping SSE auto-refresh")
264
308
  if self._refresh_task:
265
309
  # Cancel the task
266
310
  self._refresh_task.cancel()
@@ -132,8 +132,8 @@ class GrowthBookTrackingPlugin(GrowthBookPlugin):
132
132
  """Setup feature evaluation tracking."""
133
133
  original_eval_feature = gb_instance.eval_feature
134
134
 
135
- def eval_feature_wrapper(key: str):
136
- result = original_eval_feature(key)
135
+ def eval_feature_wrapper(key: str, *args, **kwargs):
136
+ result = original_eval_feature(key, *args, **kwargs)
137
137
  self._track_feature_evaluated(key, result, gb_instance)
138
138
  return result
139
139
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: growthbook
3
- Version: 1.4.10
3
+ Version: 2.1.0
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.10
2
+ current_version = 2.1.0
3
3
  commit = True
4
4
  tag = True
5
5
 
@@ -117,7 +117,7 @@ def test_decrypt(decrypt_data):
117
117
  def test_feature(feature_data):
118
118
  _, ctx, key, expected = feature_data
119
119
  gb = GrowthBook(**ctx)
120
- res = gb.evalFeature(key)
120
+ res = gb.eval_feature(key)
121
121
 
122
122
  if "experiment" in expected:
123
123
  expected["experiment"] = Experiment(**expected["experiment"]).to_dict()
@@ -726,7 +726,7 @@ def test_stores_assigned_variations_in_the_user():
726
726
  gb.run(Experiment(key="my-test", variations=[0, 1]))
727
727
  gb.run(Experiment(key="my-test-3", variations=[0, 1]))
728
728
 
729
- assigned = gb.getAllResults()
729
+ assigned = gb.get_all_results()
730
730
  assignedArr = []
731
731
 
732
732
  for e in assigned:
@@ -748,17 +748,17 @@ def test_getters_setters():
748
748
  featuresInput = {"feature-1": feat.to_dict()}
749
749
  attributes = {"id": "123", "url": "/"}
750
750
 
751
- gb.setFeatures(featuresInput)
752
- gb.setAttributes(attributes)
751
+ gb.set_features(featuresInput)
752
+ gb.set_attributes(attributes)
753
753
 
754
- featuresOutput = {k: v.to_dict() for (k, v) in gb.getFeatures().items()}
754
+ featuresOutput = {k: v.to_dict() for (k, v) in gb.get_features().items()}
755
755
 
756
756
  assert featuresOutput == featuresInput
757
- assert attributes == gb.getAttributes()
757
+ assert attributes == gb.get_attributes()
758
758
 
759
759
  newAttrs = {"url": "/hello"}
760
- gb.setAttributes(newAttrs)
761
- assert newAttrs == gb.getAttributes()
760
+ gb.set_attributes(newAttrs)
761
+ assert newAttrs == gb.get_attributes()
762
762
 
763
763
  gb.destroy()
764
764
 
@@ -780,17 +780,17 @@ def test_feature_methods():
780
780
  }
781
781
  )
782
782
 
783
- assert gb.isOn("featureOn") is True
784
- assert gb.isOff("featureOn") is False
785
- assert gb.getFeatureValue("featureOn", 15) == 12
783
+ assert gb.is_on("featureOn") is True
784
+ assert gb.is_off("featureOn") is False
785
+ assert gb.get_feature_value("featureOn", 15) == 12
786
786
 
787
- assert gb.isOn("featureOff") is False
788
- assert gb.isOff("featureOff") is True
789
- assert gb.getFeatureValue("featureOff", 10) == 0
787
+ assert gb.is_on("featureOff") is False
788
+ assert gb.is_off("featureOff") is True
789
+ assert gb.get_feature_value("featureOff", 10) == 0
790
790
 
791
- assert gb.isOn("featureNone") is False
792
- assert gb.isOff("featureNone") is True
793
- assert gb.getFeatureValue("featureNone", 10) == 10
791
+ assert gb.is_on("featureNone") is False
792
+ assert gb.is_off("featureNone") is True
793
+ assert gb.get_feature_value("featureNone", 10) == 10
794
794
 
795
795
  gb.destroy()
796
796
 
@@ -89,10 +89,16 @@ async def test_sse_connection_lifecycle(mock_options, mock_features_response):
89
89
  "refresh_strategy": FeatureRefreshStrategy.SERVER_SENT_EVENTS})
90
90
  )
91
91
 
92
- with patch('growthbook.growthbook_client.EnhancedFeatureRepository._maintain_sse_connection') as mock_sse:
92
+ # `startAutoRefresh` is synchronous and should be invoked as part of SSE start-up.
93
+ # `stopAutoRefresh` should be called during shutdown to stop/join the SSE thread.
94
+ with patch('growthbook.growthbook_client.EnhancedFeatureRepository.startAutoRefresh') as mock_start, \
95
+ patch('growthbook.growthbook_client.EnhancedFeatureRepository.stopAutoRefresh') as mock_stop:
93
96
  await client.initialize()
94
- assert mock_sse.called
97
+ # Allow the SSE lifecycle task to start and invoke startAutoRefresh
98
+ await asyncio.sleep(0)
99
+ assert mock_start.called
95
100
  await client.close()
101
+ assert mock_stop.called
96
102
 
97
103
  @pytest.mark.asyncio
98
104
  async def test_feature_repository_load():
File without changes
File without changes
File without changes
File without changes
File without changes