growthbook 1.4.10__py2.py3-none-any.whl → 2.1.0__py2.py3-none-any.whl
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/__init__.py +1 -1
- growthbook/core.py +18 -0
- growthbook/growthbook.py +4 -4
- growthbook/growthbook_client.py +63 -19
- growthbook/plugins/growthbook_tracking.py +2 -2
- {growthbook-1.4.10.dist-info → growthbook-2.1.0.dist-info}/METADATA +1 -1
- growthbook-2.1.0.dist-info/RECORD +15 -0
- {growthbook-1.4.10.dist-info → growthbook-2.1.0.dist-info}/WHEEL +1 -1
- growthbook-1.4.10.dist-info/RECORD +0 -15
- {growthbook-1.4.10.dist-info → growthbook-2.1.0.dist-info}/licenses/LICENSE +0 -0
- {growthbook-1.4.10.dist-info → growthbook-2.1.0.dist-info}/top_level.txt +0 -0
growthbook/__init__.py
CHANGED
growthbook/core.py
CHANGED
|
@@ -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
|
growthbook/growthbook.py
CHANGED
|
@@ -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)
|
growthbook/growthbook_client.py
CHANGED
|
@@ -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
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
growthbook/__init__.py,sha256=y6OvqQtSIyIvIXB3h_WZaFPo_QWl_Ssl41Tta2NRa8k,444
|
|
2
|
+
growthbook/common_types.py,sha256=YKUmmYfzgrzLQ7kp2IPLc8QBA-B0QbnbF5viekNiTpw,15703
|
|
3
|
+
growthbook/core.py,sha256=eBH5ItXhnbfJI5p08cri9yw4C73W4aXX8WgRWyIndXc,36573
|
|
4
|
+
growthbook/growthbook.py,sha256=Ee6-jWPtvlgRvxRbsX-ZhrMV-8_F2T78Q7P30DVMsQM,47833
|
|
5
|
+
growthbook/growthbook_client.py,sha256=YnbKGbO2taWZXtlutuTf_ZSkL_WmhYLhr39fB6BFIcw,27578
|
|
6
|
+
growthbook/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
growthbook/plugins/__init__.py,sha256=y2eAV1sA041XWcftBVTDH0t-ggy9r2C5oKRYRF6XR6s,602
|
|
8
|
+
growthbook/plugins/base.py,sha256=PWBXUBj62hi25Y5Eif9WmEWagWdkwGXHi2dMtn44bo8,3637
|
|
9
|
+
growthbook/plugins/growthbook_tracking.py,sha256=yN2xOHtRNsJuxkm16wY0YBQFxjEXDKnKcup7C9bQwe4,11351
|
|
10
|
+
growthbook/plugins/request_context.py,sha256=WzoGxalxPfrsN3RzfkvVYaUGat1A3N4AErnaS9IZ48Y,13005
|
|
11
|
+
growthbook-2.1.0.dist-info/licenses/LICENSE,sha256=D-TcBckB0dTPUlNJ8jBiTIJIj1ekHLB1CY7HJtJKhMY,1069
|
|
12
|
+
growthbook-2.1.0.dist-info/METADATA,sha256=8Nm5oaBRrsidsXKDXAxL8rUFIVFmfRUklDGJr7kDO2c,22726
|
|
13
|
+
growthbook-2.1.0.dist-info/WHEEL,sha256=Q6xS052dXadQWXcEVKSI037R6NoyqhUlJ5BcYz2iMP4,110
|
|
14
|
+
growthbook-2.1.0.dist-info/top_level.txt,sha256=dzfRQFGYejCIUstRSrrRVTMlxf7pBqASTI5S8gGRlXw,11
|
|
15
|
+
growthbook-2.1.0.dist-info/RECORD,,
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
growthbook/__init__.py,sha256=UisD9HLBgfbN9HN7bKSmt9-KIulTfmU1XojdFC-lOZw,445
|
|
2
|
-
growthbook/common_types.py,sha256=YKUmmYfzgrzLQ7kp2IPLc8QBA-B0QbnbF5viekNiTpw,15703
|
|
3
|
-
growthbook/core.py,sha256=C1Nes_AiEuu6ypPghKuIeM2F22XUsLK1KrLW0xDwLYU,35963
|
|
4
|
-
growthbook/growthbook.py,sha256=xHrc_wgrfl9oTbAxIDSpFet7Wb8diW4Fetv02Cpc4aA,47829
|
|
5
|
-
growthbook/growthbook_client.py,sha256=dN7BSWJ2RDNIWLnHh8tYKedOc6FzdecvkG88YzHESto,25082
|
|
6
|
-
growthbook/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
-
growthbook/plugins/__init__.py,sha256=y2eAV1sA041XWcftBVTDH0t-ggy9r2C5oKRYRF6XR6s,602
|
|
8
|
-
growthbook/plugins/base.py,sha256=PWBXUBj62hi25Y5Eif9WmEWagWdkwGXHi2dMtn44bo8,3637
|
|
9
|
-
growthbook/plugins/growthbook_tracking.py,sha256=lWO9ErUSrnqhcpWLp03XIrh45-BdBssdmLDVvaGvulY,11317
|
|
10
|
-
growthbook/plugins/request_context.py,sha256=WzoGxalxPfrsN3RzfkvVYaUGat1A3N4AErnaS9IZ48Y,13005
|
|
11
|
-
growthbook-1.4.10.dist-info/licenses/LICENSE,sha256=D-TcBckB0dTPUlNJ8jBiTIJIj1ekHLB1CY7HJtJKhMY,1069
|
|
12
|
-
growthbook-1.4.10.dist-info/METADATA,sha256=TzLKYNF9RfuJDh-iOFKOF-e95s9U9l3N5cSo4Otmghg,22727
|
|
13
|
-
growthbook-1.4.10.dist-info/WHEEL,sha256=JNWh1Fm1UdwIQV075glCn4MVuCRs0sotJIq-J6rbxCU,109
|
|
14
|
-
growthbook-1.4.10.dist-info/top_level.txt,sha256=dzfRQFGYejCIUstRSrrRVTMlxf7pBqASTI5S8gGRlXw,11
|
|
15
|
-
growthbook-1.4.10.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|