growthbook 1.4.4__tar.gz → 1.4.5__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.4/growthbook.egg-info → growthbook-1.4.5}/PKG-INFO +1 -1
  2. {growthbook-1.4.4 → growthbook-1.4.5}/growthbook/__init__.py +1 -1
  3. {growthbook-1.4.4 → growthbook-1.4.5}/growthbook/common_types.py +1 -0
  4. {growthbook-1.4.4 → growthbook-1.4.5}/growthbook/growthbook.py +15 -5
  5. {growthbook-1.4.4 → growthbook-1.4.5}/growthbook/growthbook_client.py +28 -2
  6. {growthbook-1.4.4 → growthbook-1.4.5/growthbook.egg-info}/PKG-INFO +1 -1
  7. {growthbook-1.4.4 → growthbook-1.4.5}/setup.cfg +1 -1
  8. {growthbook-1.4.4 → growthbook-1.4.5}/tests/conftest.py +2 -1
  9. {growthbook-1.4.4 → growthbook-1.4.5}/tests/test_growthbook.py +79 -0
  10. {growthbook-1.4.4 → growthbook-1.4.5}/tests/test_growthbook_client.py +124 -0
  11. {growthbook-1.4.4 → growthbook-1.4.5}/LICENSE +0 -0
  12. {growthbook-1.4.4 → growthbook-1.4.5}/MANIFEST.in +0 -0
  13. {growthbook-1.4.4 → growthbook-1.4.5}/README.md +0 -0
  14. {growthbook-1.4.4 → growthbook-1.4.5}/growthbook/core.py +0 -0
  15. {growthbook-1.4.4 → growthbook-1.4.5}/growthbook/plugins/__init__.py +0 -0
  16. {growthbook-1.4.4 → growthbook-1.4.5}/growthbook/plugins/base.py +0 -0
  17. {growthbook-1.4.4 → growthbook-1.4.5}/growthbook/plugins/growthbook_tracking.py +0 -0
  18. {growthbook-1.4.4 → growthbook-1.4.5}/growthbook/plugins/request_context.py +0 -0
  19. {growthbook-1.4.4 → growthbook-1.4.5}/growthbook/py.typed +0 -0
  20. {growthbook-1.4.4 → growthbook-1.4.5}/growthbook.egg-info/SOURCES.txt +0 -0
  21. {growthbook-1.4.4 → growthbook-1.4.5}/growthbook.egg-info/dependency_links.txt +0 -0
  22. {growthbook-1.4.4 → growthbook-1.4.5}/growthbook.egg-info/requires.txt +0 -0
  23. {growthbook-1.4.4 → growthbook-1.4.5}/growthbook.egg-info/top_level.txt +0 -0
  24. {growthbook-1.4.4 → growthbook-1.4.5}/pyproject.toml +0 -0
  25. {growthbook-1.4.4 → growthbook-1.4.5}/setup.py +0 -0
  26. {growthbook-1.4.4 → growthbook-1.4.5}/tests/test_plugins.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: growthbook
3
- Version: 1.4.4
3
+ Version: 1.4.5
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.4"
21
+ __version__ = "1.4.5"
22
22
  # x-release-please-end
@@ -426,6 +426,7 @@ class Options:
426
426
  sticky_bucket_service: Optional[AbstractStickyBucketService] = None
427
427
  sticky_bucket_identifier_attributes: Optional[List[str]] = None
428
428
  on_experiment_viewed: Optional[Callable[[Experiment, Result, Optional[UserContext]], None]] = None
429
+ on_feature_usage: Optional[Callable[[str, 'FeatureResult'], None]] = None
429
430
  tracking_plugins: Optional[List[Any]] = None
430
431
 
431
432
 
@@ -565,6 +565,7 @@ class GrowthBook(object):
565
565
  features: dict = {},
566
566
  qa_mode: bool = False,
567
567
  on_experiment_viewed=None,
568
+ on_feature_usage=None,
568
569
  api_host: str = "",
569
570
  client_key: str = "",
570
571
  decryption_key: str = "",
@@ -603,6 +604,7 @@ class GrowthBook(object):
603
604
 
604
605
  self._qaMode = qa_mode or qaMode
605
606
  self._trackingCallback = on_experiment_viewed or trackingCallback
607
+ self._featureUsageCallback = on_feature_usage
606
608
 
607
609
  self._streaming = streaming
608
610
  self._streaming_timeout = streaming_connection_timeout
@@ -823,6 +825,7 @@ class GrowthBook(object):
823
825
  self._tracked.clear()
824
826
  self._assigned.clear()
825
827
  self._trackingCallback = None
828
+ self._featureUsageCallback = None
826
829
  self._forcedVariations.clear()
827
830
  self._overrides.clear()
828
831
  self._groups.clear()
@@ -886,11 +889,18 @@ class GrowthBook(object):
886
889
  )
887
890
 
888
891
  def eval_feature(self, key: str) -> FeatureResult:
889
- return core_eval_feature(key=key,
890
- evalContext=self._get_eval_context(),
891
- callback_subscription=self._fireSubscriptions,
892
- tracking_cb=self._track
893
- )
892
+ result = core_eval_feature(key=key,
893
+ evalContext=self._get_eval_context(),
894
+ callback_subscription=self._fireSubscriptions,
895
+ tracking_cb=self._track
896
+ )
897
+ # Call feature usage callback if provided
898
+ if self._featureUsageCallback:
899
+ try:
900
+ self._featureUsageCallback(key, result)
901
+ except Exception:
902
+ pass
903
+ return result
894
904
 
895
905
  # @deprecated, use get_all_results
896
906
  def getAllResults(self):
@@ -500,24 +500,50 @@ class GrowthBookClient:
500
500
  async with self._context_lock:
501
501
  context = await self.create_evaluation_context(user_context)
502
502
  result = core_eval_feature(key=key, evalContext=context, tracking_cb=self._track)
503
+ # Call feature usage callback if provided
504
+ if self.options.on_feature_usage:
505
+ try:
506
+ self.options.on_feature_usage(key, result)
507
+ except Exception:
508
+ logger.exception("Error in feature usage callback")
503
509
  return result
504
510
 
505
511
  async def is_on(self, key: str, user_context: UserContext) -> bool:
506
512
  """Check if a feature is enabled with proper async context management"""
507
513
  async with self._context_lock:
508
514
  context = await self.create_evaluation_context(user_context)
509
- return core_eval_feature(key=key, evalContext=context, tracking_cb=self._track).on
515
+ result = core_eval_feature(key=key, evalContext=context, tracking_cb=self._track)
516
+ # Call feature usage callback if provided
517
+ if self.options.on_feature_usage:
518
+ try:
519
+ self.options.on_feature_usage(key, result)
520
+ except Exception:
521
+ logger.exception("Error in feature usage callback")
522
+ return result.on
510
523
 
511
524
  async def is_off(self, key: str, user_context: UserContext) -> bool:
512
525
  """Check if a feature is set to off with proper async context management"""
513
526
  async with self._context_lock:
514
527
  context = await self.create_evaluation_context(user_context)
515
- return core_eval_feature(key=key, evalContext=context, tracking_cb=self._track).off
528
+ result = core_eval_feature(key=key, evalContext=context, tracking_cb=self._track)
529
+ # Call feature usage callback if provided
530
+ if self.options.on_feature_usage:
531
+ try:
532
+ self.options.on_feature_usage(key, result)
533
+ except Exception:
534
+ logger.exception("Error in feature usage callback")
535
+ return result.off
516
536
 
517
537
  async def get_feature_value(self, key: str, fallback: Any, user_context: UserContext) -> Any:
518
538
  async with self._context_lock:
519
539
  context = await self.create_evaluation_context(user_context)
520
540
  result = core_eval_feature(key=key, evalContext=context, tracking_cb=self._track)
541
+ # Call feature usage callback if provided
542
+ if self.options.on_feature_usage:
543
+ try:
544
+ self.options.on_feature_usage(key, result)
545
+ except Exception:
546
+ logger.exception("Error in feature usage callback")
521
547
  return result.value if result.value is not None else fallback
522
548
 
523
549
  async def run(self, experiment: Experiment, user_context: UserContext) -> Result:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: growthbook
3
- Version: 1.4.4
3
+ Version: 1.4.5
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.4
2
+ current_version = 1.4.5
3
3
  commit = True
4
4
  tag = True
5
5
 
@@ -1,4 +1,5 @@
1
1
  import pytest
2
+ import pytest_asyncio
2
3
  import asyncio
3
4
  import os
4
5
  import sys
@@ -22,7 +23,7 @@ def reset_singleton():
22
23
  instance._stop_event.set()
23
24
  SingletonMeta._instances.clear()
24
25
 
25
- @pytest.fixture(autouse=True)
26
+ @pytest_asyncio.fixture(autouse=True)
26
27
  async def cleanup_tasks():
27
28
  """Cleanup any pending tasks after each test."""
28
29
  yield
@@ -215,6 +215,85 @@ def test_tracking():
215
215
  gb.destroy()
216
216
 
217
217
 
218
+ def test_feature_usage_callback():
219
+ """Test that feature usage callback is called correctly"""
220
+ calls = []
221
+
222
+ def feature_usage_cb(key, result):
223
+ calls.append([key, result])
224
+
225
+ gb = GrowthBook(
226
+ attributes={"id": "1"},
227
+ on_feature_usage=feature_usage_cb,
228
+ features={
229
+ "feature-1": Feature(defaultValue=True),
230
+ "feature-2": Feature(defaultValue=False),
231
+ "feature-3": Feature(
232
+ defaultValue="blue",
233
+ rules=[
234
+ FeatureRule(force="red", condition={"id": "1"})
235
+ ]
236
+ ),
237
+ }
238
+ )
239
+
240
+ # Test eval_feature
241
+ result1 = gb.eval_feature("feature-1")
242
+ assert len(calls) == 1
243
+ assert calls[0][0] == "feature-1"
244
+ assert calls[0][1].value is True
245
+ assert calls[0][1].source == "defaultValue"
246
+
247
+ # Test is_on
248
+ gb.is_on("feature-2")
249
+ assert len(calls) == 2
250
+ assert calls[1][0] == "feature-2"
251
+ assert calls[1][1].value is False
252
+
253
+ # Test get_feature_value
254
+ value = gb.get_feature_value("feature-3", "blue")
255
+ assert len(calls) == 3
256
+ assert calls[2][0] == "feature-3"
257
+ assert calls[2][1].value == "red"
258
+ assert value == "red"
259
+
260
+ # Test is_off
261
+ gb.is_off("feature-1")
262
+ assert len(calls) == 4
263
+ assert calls[3][0] == "feature-1"
264
+
265
+ # Calling same feature multiple times should trigger callback each time
266
+ gb.eval_feature("feature-1")
267
+ gb.eval_feature("feature-1")
268
+ assert len(calls) == 6
269
+
270
+ gb.destroy()
271
+
272
+
273
+ def test_feature_usage_callback_error_handling():
274
+ """Test that feature usage callback errors are handled gracefully"""
275
+
276
+ def failing_callback(key, result):
277
+ raise Exception("Callback error")
278
+
279
+ gb = GrowthBook(
280
+ attributes={"id": "1"},
281
+ on_feature_usage=failing_callback,
282
+ features={
283
+ "feature-1": Feature(defaultValue=True),
284
+ }
285
+ )
286
+
287
+ # Should not raise an error even if callback fails
288
+ result = gb.eval_feature("feature-1")
289
+ assert result.value is True
290
+
291
+ # Should work with is_on as well
292
+ assert gb.is_on("feature-1") is True
293
+
294
+ gb.destroy()
295
+
296
+
218
297
  def test_handles_weird_experiment_values():
219
298
  gb = GrowthBook(attributes={"id": "1"})
220
299
 
@@ -767,5 +767,129 @@ async def test_handles_tracking_errors():
767
767
  calls = getMockedTrackingCalls()
768
768
  assert len(calls) == 1, "Expected exactly 1 tracking call"
769
769
 
770
+ finally:
771
+ await client.close()
772
+
773
+
774
+ @pytest.mark.asyncio
775
+ async def test_feature_usage_callback():
776
+ """Test that feature usage callback is called correctly"""
777
+ calls = []
778
+
779
+ def feature_usage_cb(key, result):
780
+ calls.append([key, result])
781
+
782
+ client = GrowthBookClient(Options(
783
+ api_host="https://localhost.growthbook.io",
784
+ client_key="test-key",
785
+ enabled=True,
786
+ on_feature_usage=feature_usage_cb
787
+ ))
788
+
789
+ user_context = UserContext(attributes={"id": "1"})
790
+
791
+ try:
792
+ # Set up mocks for feature repository
793
+ mock_features = {
794
+ "features": {
795
+ "feature-1": {"defaultValue": True},
796
+ "feature-2": {"defaultValue": False},
797
+ "feature-3": {
798
+ "defaultValue": "blue",
799
+ "rules": [
800
+ {"force": "red", "condition": {"id": "1"}}
801
+ ]
802
+ },
803
+ },
804
+ "savedGroups": {}
805
+ }
806
+
807
+ with patch('growthbook.FeatureRepository.load_features_async',
808
+ new_callable=AsyncMock, return_value=mock_features), \
809
+ patch('growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh',
810
+ new_callable=AsyncMock), \
811
+ patch('growthbook.growthbook_client.EnhancedFeatureRepository.stop_refresh',
812
+ new_callable=AsyncMock):
813
+
814
+ # Initialize client
815
+ await client.initialize()
816
+
817
+ # Test eval_feature
818
+ result1 = await client.eval_feature("feature-1", user_context)
819
+ assert len(calls) == 1
820
+ assert calls[0][0] == "feature-1"
821
+ assert calls[0][1].value is True
822
+ assert calls[0][1].source == "defaultValue"
823
+
824
+ # Test is_on
825
+ await client.is_on("feature-2", user_context)
826
+ assert len(calls) == 2
827
+ assert calls[1][0] == "feature-2"
828
+ assert calls[1][1].value is False
829
+
830
+ # Test get_feature_value
831
+ value = await client.get_feature_value("feature-3", "blue", user_context)
832
+ assert len(calls) == 3
833
+ assert calls[2][0] == "feature-3"
834
+ assert calls[2][1].value == "red"
835
+ assert value == "red"
836
+
837
+ # Test is_off
838
+ await client.is_off("feature-1", user_context)
839
+ assert len(calls) == 4
840
+ assert calls[3][0] == "feature-1"
841
+
842
+ # Calling same feature multiple times should trigger callback each time
843
+ await client.eval_feature("feature-1", user_context)
844
+ await client.eval_feature("feature-1", user_context)
845
+ assert len(calls) == 6
846
+
847
+ finally:
848
+ await client.close()
849
+
850
+
851
+ @pytest.mark.asyncio
852
+ async def test_feature_usage_callback_error_handling():
853
+ """Test that feature usage callback errors are handled gracefully"""
854
+
855
+ def failing_callback(key, result):
856
+ raise Exception("Callback error")
857
+
858
+ client = GrowthBookClient(Options(
859
+ api_host="https://localhost.growthbook.io",
860
+ client_key="test-key",
861
+ enabled=True,
862
+ on_feature_usage=failing_callback
863
+ ))
864
+
865
+ user_context = UserContext(attributes={"id": "1"})
866
+
867
+ try:
868
+ # Set up mocks for feature repository
869
+ mock_features = {
870
+ "features": {
871
+ "feature-1": {"defaultValue": True},
872
+ },
873
+ "savedGroups": {}
874
+ }
875
+
876
+ with patch('growthbook.FeatureRepository.load_features_async',
877
+ new_callable=AsyncMock, return_value=mock_features), \
878
+ patch('growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh',
879
+ new_callable=AsyncMock), \
880
+ patch('growthbook.growthbook_client.EnhancedFeatureRepository.stop_refresh',
881
+ new_callable=AsyncMock):
882
+
883
+ # Initialize client
884
+ await client.initialize()
885
+
886
+ # Should not raise an error even if callback fails
887
+ result = await client.eval_feature("feature-1", user_context)
888
+ assert result.value is True
889
+
890
+ # Should work with is_on as well
891
+ is_on = await client.is_on("feature-1", user_context)
892
+ assert is_on is True
893
+
770
894
  finally:
771
895
  await client.close()
File without changes
File without changes
File without changes
File without changes
File without changes