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.
- {growthbook-1.4.4/growthbook.egg-info → growthbook-1.4.5}/PKG-INFO +1 -1
- {growthbook-1.4.4 → growthbook-1.4.5}/growthbook/__init__.py +1 -1
- {growthbook-1.4.4 → growthbook-1.4.5}/growthbook/common_types.py +1 -0
- {growthbook-1.4.4 → growthbook-1.4.5}/growthbook/growthbook.py +15 -5
- {growthbook-1.4.4 → growthbook-1.4.5}/growthbook/growthbook_client.py +28 -2
- {growthbook-1.4.4 → growthbook-1.4.5/growthbook.egg-info}/PKG-INFO +1 -1
- {growthbook-1.4.4 → growthbook-1.4.5}/setup.cfg +1 -1
- {growthbook-1.4.4 → growthbook-1.4.5}/tests/conftest.py +2 -1
- {growthbook-1.4.4 → growthbook-1.4.5}/tests/test_growthbook.py +79 -0
- {growthbook-1.4.4 → growthbook-1.4.5}/tests/test_growthbook_client.py +124 -0
- {growthbook-1.4.4 → growthbook-1.4.5}/LICENSE +0 -0
- {growthbook-1.4.4 → growthbook-1.4.5}/MANIFEST.in +0 -0
- {growthbook-1.4.4 → growthbook-1.4.5}/README.md +0 -0
- {growthbook-1.4.4 → growthbook-1.4.5}/growthbook/core.py +0 -0
- {growthbook-1.4.4 → growthbook-1.4.5}/growthbook/plugins/__init__.py +0 -0
- {growthbook-1.4.4 → growthbook-1.4.5}/growthbook/plugins/base.py +0 -0
- {growthbook-1.4.4 → growthbook-1.4.5}/growthbook/plugins/growthbook_tracking.py +0 -0
- {growthbook-1.4.4 → growthbook-1.4.5}/growthbook/plugins/request_context.py +0 -0
- {growthbook-1.4.4 → growthbook-1.4.5}/growthbook/py.typed +0 -0
- {growthbook-1.4.4 → growthbook-1.4.5}/growthbook.egg-info/SOURCES.txt +0 -0
- {growthbook-1.4.4 → growthbook-1.4.5}/growthbook.egg-info/dependency_links.txt +0 -0
- {growthbook-1.4.4 → growthbook-1.4.5}/growthbook.egg-info/requires.txt +0 -0
- {growthbook-1.4.4 → growthbook-1.4.5}/growthbook.egg-info/top_level.txt +0 -0
- {growthbook-1.4.4 → growthbook-1.4.5}/pyproject.toml +0 -0
- {growthbook-1.4.4 → growthbook-1.4.5}/setup.py +0 -0
- {growthbook-1.4.4 → growthbook-1.4.5}/tests/test_plugins.py +0 -0
|
@@ -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
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
-
@
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|