growthbook 1.4.1__tar.gz → 1.4.3__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.1/growthbook.egg-info → growthbook-1.4.3}/PKG-INFO +3 -3
- {growthbook-1.4.1 → growthbook-1.4.3}/README.md +2 -2
- {growthbook-1.4.1 → growthbook-1.4.3}/growthbook/__init__.py +1 -1
- {growthbook-1.4.1 → growthbook-1.4.3}/growthbook/growthbook.py +181 -77
- {growthbook-1.4.1 → growthbook-1.4.3/growthbook.egg-info}/PKG-INFO +3 -3
- {growthbook-1.4.1 → growthbook-1.4.3}/setup.cfg +1 -1
- {growthbook-1.4.1 → growthbook-1.4.3}/LICENSE +0 -0
- {growthbook-1.4.1 → growthbook-1.4.3}/MANIFEST.in +0 -0
- {growthbook-1.4.1 → growthbook-1.4.3}/growthbook/common_types.py +0 -0
- {growthbook-1.4.1 → growthbook-1.4.3}/growthbook/core.py +0 -0
- {growthbook-1.4.1 → growthbook-1.4.3}/growthbook/growthbook_client.py +0 -0
- {growthbook-1.4.1 → growthbook-1.4.3}/growthbook/plugins/__init__.py +0 -0
- {growthbook-1.4.1 → growthbook-1.4.3}/growthbook/plugins/base.py +0 -0
- {growthbook-1.4.1 → growthbook-1.4.3}/growthbook/plugins/growthbook_tracking.py +0 -0
- {growthbook-1.4.1 → growthbook-1.4.3}/growthbook/plugins/request_context.py +0 -0
- {growthbook-1.4.1 → growthbook-1.4.3}/growthbook/py.typed +0 -0
- {growthbook-1.4.1 → growthbook-1.4.3}/growthbook.egg-info/SOURCES.txt +0 -0
- {growthbook-1.4.1 → growthbook-1.4.3}/growthbook.egg-info/dependency_links.txt +0 -0
- {growthbook-1.4.1 → growthbook-1.4.3}/growthbook.egg-info/requires.txt +0 -0
- {growthbook-1.4.1 → growthbook-1.4.3}/growthbook.egg-info/top_level.txt +0 -0
- {growthbook-1.4.1 → growthbook-1.4.3}/pyproject.toml +0 -0
- {growthbook-1.4.1 → growthbook-1.4.3}/setup.py +0 -0
- {growthbook-1.4.1 → growthbook-1.4.3}/tests/conftest.py +0 -0
- {growthbook-1.4.1 → growthbook-1.4.3}/tests/test_growthbook.py +0 -0
- {growthbook-1.4.1 → growthbook-1.4.3}/tests/test_growthbook_client.py +0 -0
- {growthbook-1.4.1 → growthbook-1.4.3}/tests/test_plugins.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: growthbook
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.3
|
|
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
|
|
@@ -49,7 +49,7 @@ Powerful Feature flagging and A/B testing for Python apps.
|
|
|
49
49
|
- **Use your existing event tracking** (GA, Segment, Mixpanel, custom)
|
|
50
50
|
- **Remote configuration** to change feature flags without deploying new code
|
|
51
51
|
- **Async support** with real-time feature updates
|
|
52
|
-
- Python 3.
|
|
52
|
+
- Python 3.9+
|
|
53
53
|
- 100% test coverage
|
|
54
54
|
|
|
55
55
|
## Installation
|
|
@@ -660,7 +660,7 @@ gb.run(Experiment(
|
|
|
660
660
|
key = "by-company-id",
|
|
661
661
|
variations = ["A", "B"],
|
|
662
662
|
hashAttribute = "company"
|
|
663
|
-
))
|
|
663
|
+
))
|
|
664
664
|
```
|
|
665
665
|
|
|
666
666
|
## Logging
|
|
@@ -14,7 +14,7 @@ Powerful Feature flagging and A/B testing for Python apps.
|
|
|
14
14
|
- **Use your existing event tracking** (GA, Segment, Mixpanel, custom)
|
|
15
15
|
- **Remote configuration** to change feature flags without deploying new code
|
|
16
16
|
- **Async support** with real-time feature updates
|
|
17
|
-
- Python 3.
|
|
17
|
+
- Python 3.9+
|
|
18
18
|
- 100% test coverage
|
|
19
19
|
|
|
20
20
|
## Installation
|
|
@@ -625,7 +625,7 @@ gb.run(Experiment(
|
|
|
625
625
|
key = "by-company-id",
|
|
626
626
|
variations = ["A", "B"],
|
|
627
627
|
hashAttribute = "company"
|
|
628
|
-
))
|
|
628
|
+
))
|
|
629
629
|
```
|
|
630
630
|
|
|
631
631
|
## Logging
|
|
@@ -120,7 +120,7 @@ class InMemoryStickyBucketService(AbstractStickyBucketService):
|
|
|
120
120
|
|
|
121
121
|
|
|
122
122
|
class SSEClient:
|
|
123
|
-
def __init__(self, api_host, client_key, on_event, reconnect_delay=5, headers=None, timeout
|
|
123
|
+
def __init__(self, api_host, client_key, on_event, reconnect_delay=5, headers=None, timeout=30):
|
|
124
124
|
self.api_host = api_host
|
|
125
125
|
self.client_key = client_key
|
|
126
126
|
|
|
@@ -151,17 +151,32 @@ class SSEClient:
|
|
|
151
151
|
self._sse_thread = threading.Thread(target=self._run_sse_channel)
|
|
152
152
|
self._sse_thread.start()
|
|
153
153
|
|
|
154
|
-
def disconnect(self):
|
|
154
|
+
def disconnect(self, timeout=10):
|
|
155
|
+
"""Gracefully disconnect with timeout"""
|
|
156
|
+
logger.debug("Initiating SSE client disconnect")
|
|
155
157
|
self.is_running = False
|
|
158
|
+
|
|
156
159
|
if self._loop and self._loop.is_running():
|
|
157
|
-
future = asyncio.run_coroutine_threadsafe(self._stop_session(), self._loop)
|
|
160
|
+
future = asyncio.run_coroutine_threadsafe(self._stop_session(timeout), self._loop)
|
|
158
161
|
try:
|
|
159
|
-
|
|
162
|
+
# Wait with timeout for clean shutdown
|
|
163
|
+
future.result(timeout=timeout)
|
|
164
|
+
logger.debug("SSE session stopped cleanly")
|
|
160
165
|
except Exception as e:
|
|
161
|
-
logger.
|
|
166
|
+
logger.warning(f"Error during SSE disconnect: {e}")
|
|
167
|
+
# Force close the loop if clean shutdown failed
|
|
168
|
+
if self._loop and self._loop.is_running():
|
|
169
|
+
try:
|
|
170
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
171
|
+
except Exception:
|
|
172
|
+
pass
|
|
162
173
|
|
|
163
174
|
if self._sse_thread:
|
|
164
|
-
self._sse_thread.join(timeout=
|
|
175
|
+
self._sse_thread.join(timeout=timeout)
|
|
176
|
+
if self._sse_thread.is_alive():
|
|
177
|
+
logger.warning("SSE thread did not terminate gracefully within timeout")
|
|
178
|
+
else:
|
|
179
|
+
logger.debug("SSE thread terminated")
|
|
165
180
|
|
|
166
181
|
logger.debug("Streaming session disconnected")
|
|
167
182
|
|
|
@@ -172,52 +187,84 @@ class SSEClient:
|
|
|
172
187
|
async def _init_session(self):
|
|
173
188
|
url = self._get_sse_url(self.api_host, self.client_key)
|
|
174
189
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
except (ClientConnectorError, ClientPayloadError) as e:
|
|
189
|
-
logger.error(f"Streaming error: {e}")
|
|
190
|
-
if not self.is_running:
|
|
190
|
+
try:
|
|
191
|
+
while self.is_running:
|
|
192
|
+
try:
|
|
193
|
+
async with aiohttp.ClientSession(headers=self.headers,
|
|
194
|
+
timeout=aiohttp.ClientTimeout(connect=self.timeout)) as session:
|
|
195
|
+
self._sse_session = session
|
|
196
|
+
|
|
197
|
+
async with session.get(url) as response:
|
|
198
|
+
response.raise_for_status()
|
|
199
|
+
await self._process_response(response)
|
|
200
|
+
except ClientResponseError as e:
|
|
201
|
+
logger.error(f"Streaming error, closing connection: {e.status} {e.message}")
|
|
202
|
+
self.is_running = False
|
|
191
203
|
break
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
204
|
+
except (ClientConnectorError, ClientPayloadError) as e:
|
|
205
|
+
logger.error(f"Streaming error: {e}")
|
|
206
|
+
if not self.is_running:
|
|
207
|
+
break
|
|
208
|
+
await self._wait_for_reconnect()
|
|
209
|
+
except TimeoutError:
|
|
210
|
+
logger.warning(f"Streaming connection timed out after {self.timeout} seconds.")
|
|
211
|
+
if not self.is_running:
|
|
212
|
+
break
|
|
213
|
+
await self._wait_for_reconnect()
|
|
214
|
+
except asyncio.CancelledError:
|
|
215
|
+
logger.debug("SSE session cancelled")
|
|
216
|
+
break
|
|
217
|
+
finally:
|
|
218
|
+
await self._close_session()
|
|
219
|
+
except asyncio.CancelledError:
|
|
220
|
+
logger.debug("SSE _init_session cancelled")
|
|
221
|
+
pass
|
|
222
|
+
finally:
|
|
223
|
+
# Ensure session is closed on any exit
|
|
224
|
+
await self._close_session()
|
|
201
225
|
|
|
202
226
|
async def _process_response(self, response):
|
|
203
227
|
event_data = {}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
228
|
+
try:
|
|
229
|
+
async for line in response.content:
|
|
230
|
+
# Check for cancellation before processing each line
|
|
231
|
+
if not self.is_running:
|
|
232
|
+
logger.debug("SSE processing stopped - is_running is False")
|
|
233
|
+
break
|
|
234
|
+
|
|
235
|
+
decoded_line = line.decode('utf-8').strip()
|
|
236
|
+
if decoded_line.startswith("event:"):
|
|
237
|
+
event_data['type'] = decoded_line[len("event:"):].strip()
|
|
238
|
+
elif decoded_line.startswith("data:"):
|
|
239
|
+
event_data['data'] = event_data.get('data', '') + f"\n{decoded_line[len('data:'):].strip()}"
|
|
240
|
+
elif not decoded_line:
|
|
241
|
+
if 'type' in event_data and 'data' in event_data:
|
|
242
|
+
try:
|
|
243
|
+
self.on_event(event_data)
|
|
244
|
+
except Exception as e:
|
|
245
|
+
logger.warning(f"Error in event handler: {e}")
|
|
246
|
+
event_data = {}
|
|
247
|
+
|
|
248
|
+
# Process any remaining event data
|
|
249
|
+
if 'type' in event_data and 'data' in event_data:
|
|
250
|
+
try:
|
|
212
251
|
self.on_event(event_data)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
252
|
+
except Exception as e:
|
|
253
|
+
logger.warning(f"Error in final event handler: {e}")
|
|
254
|
+
except asyncio.CancelledError:
|
|
255
|
+
logger.debug("SSE response processing cancelled")
|
|
256
|
+
raise
|
|
257
|
+
except Exception as e:
|
|
258
|
+
logger.warning(f"Error processing SSE response: {e}")
|
|
259
|
+
raise
|
|
217
260
|
|
|
218
261
|
async def _wait_for_reconnect(self):
|
|
219
|
-
logger.
|
|
220
|
-
|
|
262
|
+
logger.info(f"Attempting to reconnect streaming in {self.reconnect_delay} seconds")
|
|
263
|
+
try:
|
|
264
|
+
await asyncio.sleep(self.reconnect_delay)
|
|
265
|
+
except asyncio.CancelledError:
|
|
266
|
+
logger.debug("Reconnect wait cancelled")
|
|
267
|
+
raise
|
|
221
268
|
|
|
222
269
|
async def _close_session(self):
|
|
223
270
|
if self._sse_session:
|
|
@@ -235,18 +282,44 @@ class SSEClient:
|
|
|
235
282
|
self._loop.run_until_complete(self._loop.shutdown_asyncgens())
|
|
236
283
|
self._loop.close()
|
|
237
284
|
|
|
238
|
-
async def _stop_session(self):
|
|
239
|
-
|
|
240
|
-
|
|
285
|
+
async def _stop_session(self, timeout=10):
|
|
286
|
+
"""Stop the SSE session and cancel all tasks with timeout"""
|
|
287
|
+
logger.debug("Stopping SSE session")
|
|
288
|
+
|
|
289
|
+
# Close the session first
|
|
290
|
+
if self._sse_session and not self._sse_session.closed:
|
|
291
|
+
try:
|
|
292
|
+
await self._sse_session.close()
|
|
293
|
+
logger.debug("SSE session closed")
|
|
294
|
+
except Exception as e:
|
|
295
|
+
logger.warning(f"Error closing SSE session: {e}")
|
|
241
296
|
|
|
297
|
+
# Cancel all tasks in this loop
|
|
242
298
|
if self._loop and self._loop.is_running():
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
task.
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
299
|
+
try:
|
|
300
|
+
# Get all tasks for this specific loop
|
|
301
|
+
tasks = [task for task in asyncio.all_tasks(self._loop)
|
|
302
|
+
if not task.done() and task is not asyncio.current_task(self._loop)]
|
|
303
|
+
|
|
304
|
+
if tasks:
|
|
305
|
+
logger.debug(f"Cancelling {len(tasks)} SSE tasks")
|
|
306
|
+
# Cancel all tasks
|
|
307
|
+
for task in tasks:
|
|
308
|
+
task.cancel()
|
|
309
|
+
|
|
310
|
+
# Wait for tasks to complete with timeout
|
|
311
|
+
try:
|
|
312
|
+
await asyncio.wait_for(
|
|
313
|
+
asyncio.gather(*tasks, return_exceptions=True),
|
|
314
|
+
timeout=timeout
|
|
315
|
+
)
|
|
316
|
+
logger.debug("All SSE tasks cancelled successfully")
|
|
317
|
+
except asyncio.TimeoutError:
|
|
318
|
+
logger.warning("Some SSE tasks did not cancel within timeout")
|
|
319
|
+
except Exception as e:
|
|
320
|
+
logger.warning(f"Error during task cancellation: {e}")
|
|
321
|
+
except Exception as e:
|
|
322
|
+
logger.warning(f"Error during SSE task cleanup: {e}")
|
|
250
323
|
|
|
251
324
|
class FeatureRepository(object):
|
|
252
325
|
def __init__(self) -> None:
|
|
@@ -415,8 +488,11 @@ class FeatureRepository(object):
|
|
|
415
488
|
self.sse_client = self.sse_client or SSEClient(api_host=api_host, client_key=client_key, on_event=cb, timeout=streaming_timeout)
|
|
416
489
|
self.sse_client.connect()
|
|
417
490
|
|
|
418
|
-
def stopAutoRefresh(self):
|
|
419
|
-
|
|
491
|
+
def stopAutoRefresh(self, timeout=10):
|
|
492
|
+
"""Stop auto refresh with timeout"""
|
|
493
|
+
if self.sse_client:
|
|
494
|
+
self.sse_client.disconnect(timeout=timeout)
|
|
495
|
+
self.sse_client = None
|
|
420
496
|
|
|
421
497
|
@staticmethod
|
|
422
498
|
def _get_features_url(api_host: str, client_key: str) -> str:
|
|
@@ -445,7 +521,7 @@ class GrowthBook(object):
|
|
|
445
521
|
sticky_bucket_identifier_attributes: List[str] = None,
|
|
446
522
|
savedGroups: dict = {},
|
|
447
523
|
streaming: bool = False,
|
|
448
|
-
|
|
524
|
+
streaming_connection_timeout: int = 30,
|
|
449
525
|
plugins: List = None,
|
|
450
526
|
# Deprecated args
|
|
451
527
|
trackingCallback=None,
|
|
@@ -474,7 +550,7 @@ class GrowthBook(object):
|
|
|
474
550
|
self._trackingCallback = on_experiment_viewed or trackingCallback
|
|
475
551
|
|
|
476
552
|
self._streaming = streaming
|
|
477
|
-
self._streaming_timeout =
|
|
553
|
+
self._streaming_timeout = streaming_connection_timeout
|
|
478
554
|
|
|
479
555
|
# Deprecated args
|
|
480
556
|
self._user = user
|
|
@@ -595,8 +671,15 @@ class GrowthBook(object):
|
|
|
595
671
|
streaming_timeout=self._streaming_timeout
|
|
596
672
|
)
|
|
597
673
|
|
|
598
|
-
def stopAutoRefresh(self):
|
|
599
|
-
|
|
674
|
+
def stopAutoRefresh(self, timeout=10):
|
|
675
|
+
"""Stop auto refresh with timeout"""
|
|
676
|
+
try:
|
|
677
|
+
if hasattr(feature_repo, 'sse_client') and feature_repo.sse_client:
|
|
678
|
+
feature_repo.sse_client.disconnect(timeout=timeout)
|
|
679
|
+
else:
|
|
680
|
+
feature_repo.stopAutoRefresh()
|
|
681
|
+
except Exception as e:
|
|
682
|
+
logger.warning(f"Error stopping auto refresh: {e}")
|
|
600
683
|
|
|
601
684
|
# @deprecated, use set_features
|
|
602
685
|
def setFeatures(self, features: dict) -> None:
|
|
@@ -639,23 +722,44 @@ class GrowthBook(object):
|
|
|
639
722
|
def get_attributes(self) -> dict:
|
|
640
723
|
return self._attributes
|
|
641
724
|
|
|
642
|
-
def destroy(self) -> None:
|
|
643
|
-
|
|
644
|
-
|
|
725
|
+
def destroy(self, timeout=10) -> None:
|
|
726
|
+
"""Gracefully destroy the GrowthBook instance"""
|
|
727
|
+
logger.debug("Starting GrowthBook destroy process")
|
|
645
728
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
729
|
+
try:
|
|
730
|
+
# Clean up plugins
|
|
731
|
+
logger.debug("Cleaning up plugins")
|
|
732
|
+
self._cleanup_plugins()
|
|
733
|
+
except Exception as e:
|
|
734
|
+
logger.warning(f"Error cleaning up plugins: {e}")
|
|
735
|
+
|
|
736
|
+
try:
|
|
737
|
+
logger.debug("Stopping auto refresh during destroy")
|
|
738
|
+
self.stopAutoRefresh(timeout=timeout)
|
|
739
|
+
except Exception as e:
|
|
740
|
+
logger.warning(f"Error stopping auto refresh during destroy: {e}")
|
|
741
|
+
|
|
742
|
+
try:
|
|
743
|
+
# Clean up feature update callback
|
|
744
|
+
if self._client_key:
|
|
745
|
+
feature_repo.remove_feature_update_callback(self._on_feature_update)
|
|
746
|
+
except Exception as e:
|
|
747
|
+
logger.warning(f"Error removing feature update callback: {e}")
|
|
748
|
+
|
|
749
|
+
# Clear all internal state
|
|
750
|
+
try:
|
|
751
|
+
self._subscriptions.clear()
|
|
752
|
+
self._tracked.clear()
|
|
753
|
+
self._assigned.clear()
|
|
754
|
+
self._trackingCallback = None
|
|
755
|
+
self._forcedVariations.clear()
|
|
756
|
+
self._overrides.clear()
|
|
757
|
+
self._groups.clear()
|
|
758
|
+
self._attributes.clear()
|
|
759
|
+
self._features.clear()
|
|
760
|
+
logger.debug("GrowthBook instance destroyed successfully")
|
|
761
|
+
except Exception as e:
|
|
762
|
+
logger.warning(f"Error clearing internal state: {e}")
|
|
659
763
|
|
|
660
764
|
# @deprecated, use is_on
|
|
661
765
|
def isOn(self, key: str) -> bool:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: growthbook
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.3
|
|
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
|
|
@@ -49,7 +49,7 @@ Powerful Feature flagging and A/B testing for Python apps.
|
|
|
49
49
|
- **Use your existing event tracking** (GA, Segment, Mixpanel, custom)
|
|
50
50
|
- **Remote configuration** to change feature flags without deploying new code
|
|
51
51
|
- **Async support** with real-time feature updates
|
|
52
|
-
- Python 3.
|
|
52
|
+
- Python 3.9+
|
|
53
53
|
- 100% test coverage
|
|
54
54
|
|
|
55
55
|
## Installation
|
|
@@ -660,7 +660,7 @@ gb.run(Experiment(
|
|
|
660
660
|
key = "by-company-id",
|
|
661
661
|
variations = ["A", "B"],
|
|
662
662
|
hashAttribute = "company"
|
|
663
|
-
))
|
|
663
|
+
))
|
|
664
664
|
```
|
|
665
665
|
|
|
666
666
|
## Logging
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|