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.
- {growthbook-1.4.10/growthbook.egg-info → growthbook-2.1.0}/PKG-INFO +1 -1
- {growthbook-1.4.10 → growthbook-2.1.0}/growthbook/__init__.py +1 -1
- {growthbook-1.4.10 → growthbook-2.1.0}/growthbook/core.py +18 -0
- {growthbook-1.4.10 → growthbook-2.1.0}/growthbook/growthbook.py +4 -4
- {growthbook-1.4.10 → growthbook-2.1.0}/growthbook/growthbook_client.py +63 -19
- {growthbook-1.4.10 → growthbook-2.1.0}/growthbook/plugins/growthbook_tracking.py +2 -2
- {growthbook-1.4.10 → growthbook-2.1.0/growthbook.egg-info}/PKG-INFO +1 -1
- {growthbook-1.4.10 → growthbook-2.1.0}/setup.cfg +1 -1
- {growthbook-1.4.10 → growthbook-2.1.0}/tests/test_growthbook.py +17 -17
- {growthbook-1.4.10 → growthbook-2.1.0}/tests/test_growthbook_client.py +8 -2
- {growthbook-1.4.10 → growthbook-2.1.0}/LICENSE +0 -0
- {growthbook-1.4.10 → growthbook-2.1.0}/MANIFEST.in +0 -0
- {growthbook-1.4.10 → growthbook-2.1.0}/README.md +0 -0
- {growthbook-1.4.10 → growthbook-2.1.0}/growthbook/common_types.py +0 -0
- {growthbook-1.4.10 → growthbook-2.1.0}/growthbook/plugins/__init__.py +0 -0
- {growthbook-1.4.10 → growthbook-2.1.0}/growthbook/plugins/base.py +0 -0
- {growthbook-1.4.10 → growthbook-2.1.0}/growthbook/plugins/request_context.py +0 -0
- {growthbook-1.4.10 → growthbook-2.1.0}/growthbook/py.typed +0 -0
- {growthbook-1.4.10 → growthbook-2.1.0}/growthbook.egg-info/SOURCES.txt +0 -0
- {growthbook-1.4.10 → growthbook-2.1.0}/growthbook.egg-info/dependency_links.txt +0 -0
- {growthbook-1.4.10 → growthbook-2.1.0}/growthbook.egg-info/requires.txt +0 -0
- {growthbook-1.4.10 → growthbook-2.1.0}/growthbook.egg-info/top_level.txt +0 -0
- {growthbook-1.4.10 → growthbook-2.1.0}/pyproject.toml +0 -0
- {growthbook-1.4.10 → growthbook-2.1.0}/setup.py +0 -0
- {growthbook-1.4.10 → growthbook-2.1.0}/tests/conftest.py +0 -0
- {growthbook-1.4.10 → growthbook-2.1.0}/tests/test_etag.py +0 -0
- {growthbook-1.4.10 → growthbook-2.1.0}/tests/test_plugins.py +0 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
187
|
-
await self._handle_feature_update(event_data
|
|
200
|
+
elif event_type == "features":
|
|
201
|
+
await self._handle_feature_update(event_data.get("data", {}))
|
|
188
202
|
except Exception:
|
|
189
|
-
|
|
203
|
+
logger.exception("Error handling SSE event")
|
|
190
204
|
|
|
191
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
752
|
-
gb.
|
|
751
|
+
gb.set_features(featuresInput)
|
|
752
|
+
gb.set_attributes(attributes)
|
|
753
753
|
|
|
754
|
-
featuresOutput = {k: v.to_dict() for (k, v) in gb.
|
|
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.
|
|
757
|
+
assert attributes == gb.get_attributes()
|
|
758
758
|
|
|
759
759
|
newAttrs = {"url": "/hello"}
|
|
760
|
-
gb.
|
|
761
|
-
assert newAttrs == gb.
|
|
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.
|
|
784
|
-
assert gb.
|
|
785
|
-
assert gb.
|
|
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.
|
|
788
|
-
assert gb.
|
|
789
|
-
assert gb.
|
|
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.
|
|
792
|
-
assert gb.
|
|
793
|
-
assert gb.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|