growthbook 1.4.2__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.
Files changed (26) hide show
  1. {growthbook-1.4.2/growthbook.egg-info → growthbook-1.4.3}/PKG-INFO +1 -1
  2. {growthbook-1.4.2 → growthbook-1.4.3}/growthbook/__init__.py +1 -1
  3. {growthbook-1.4.2 → growthbook-1.4.3}/growthbook/growthbook.py +177 -73
  4. {growthbook-1.4.2 → growthbook-1.4.3/growthbook.egg-info}/PKG-INFO +1 -1
  5. {growthbook-1.4.2 → growthbook-1.4.3}/setup.cfg +1 -1
  6. {growthbook-1.4.2 → growthbook-1.4.3}/LICENSE +0 -0
  7. {growthbook-1.4.2 → growthbook-1.4.3}/MANIFEST.in +0 -0
  8. {growthbook-1.4.2 → growthbook-1.4.3}/README.md +0 -0
  9. {growthbook-1.4.2 → growthbook-1.4.3}/growthbook/common_types.py +0 -0
  10. {growthbook-1.4.2 → growthbook-1.4.3}/growthbook/core.py +0 -0
  11. {growthbook-1.4.2 → growthbook-1.4.3}/growthbook/growthbook_client.py +0 -0
  12. {growthbook-1.4.2 → growthbook-1.4.3}/growthbook/plugins/__init__.py +0 -0
  13. {growthbook-1.4.2 → growthbook-1.4.3}/growthbook/plugins/base.py +0 -0
  14. {growthbook-1.4.2 → growthbook-1.4.3}/growthbook/plugins/growthbook_tracking.py +0 -0
  15. {growthbook-1.4.2 → growthbook-1.4.3}/growthbook/plugins/request_context.py +0 -0
  16. {growthbook-1.4.2 → growthbook-1.4.3}/growthbook/py.typed +0 -0
  17. {growthbook-1.4.2 → growthbook-1.4.3}/growthbook.egg-info/SOURCES.txt +0 -0
  18. {growthbook-1.4.2 → growthbook-1.4.3}/growthbook.egg-info/dependency_links.txt +0 -0
  19. {growthbook-1.4.2 → growthbook-1.4.3}/growthbook.egg-info/requires.txt +0 -0
  20. {growthbook-1.4.2 → growthbook-1.4.3}/growthbook.egg-info/top_level.txt +0 -0
  21. {growthbook-1.4.2 → growthbook-1.4.3}/pyproject.toml +0 -0
  22. {growthbook-1.4.2 → growthbook-1.4.3}/setup.py +0 -0
  23. {growthbook-1.4.2 → growthbook-1.4.3}/tests/conftest.py +0 -0
  24. {growthbook-1.4.2 → growthbook-1.4.3}/tests/test_growthbook.py +0 -0
  25. {growthbook-1.4.2 → growthbook-1.4.3}/tests/test_growthbook_client.py +0 -0
  26. {growthbook-1.4.2 → 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.2
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
@@ -18,5 +18,5 @@ from .plugins import (
18
18
  )
19
19
 
20
20
  # x-release-please-start-version
21
- __version__ = "1.4.2"
21
+ __version__ = "1.4.3"
22
22
  # x-release-please-end
@@ -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
- future.result()
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.error(f"Streaming disconnect error: {e}")
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=5)
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
- while self.is_running:
176
- try:
177
- async with aiohttp.ClientSession(headers=self.headers,
178
- timeout=aiohttp.ClientTimeout(connect=self.timeout)) as session:
179
- self._sse_session = session
180
-
181
- async with session.get(url) as response:
182
- response.raise_for_status()
183
- await self._process_response(response)
184
- except ClientResponseError as e:
185
- logger.error(f"Streaming error, closing connection: {e.status} {e.message}")
186
- self.is_running = False
187
- break
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
- await self._wait_for_reconnect()
193
- except TimeoutError:
194
- logger.warning(f"Streaming connection timed out after {self.timeout} seconds.")
195
- await self._wait_for_reconnect()
196
- except asyncio.CancelledError:
197
- logger.debug("Streaming was cancelled.")
198
- break
199
- finally:
200
- await self._close_session()
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
- async for line in response.content:
205
- decoded_line = line.decode('utf-8').strip()
206
- if decoded_line.startswith("event:"):
207
- event_data['type'] = decoded_line[len("event:"):].strip()
208
- elif decoded_line.startswith("data:"):
209
- event_data['data'] = event_data.get('data', '') + f"\n{decoded_line[len('data:'):].strip()}"
210
- elif not decoded_line:
211
- if 'type' in event_data and 'data' in event_data:
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
- event_data = {}
214
-
215
- if 'type' in event_data and 'data' in event_data:
216
- self.on_event(event_data)
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
262
  logger.info(f"Attempting to reconnect streaming in {self.reconnect_delay} seconds")
220
- await asyncio.sleep(self.reconnect_delay)
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
- if self._sse_session:
240
- await self._sse_session.close()
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
- tasks = [task for task in asyncio.all_tasks(self._loop) if not task.done()]
244
- for task in tasks:
245
- task.cancel()
246
- try:
247
- await task
248
- except asyncio.CancelledError:
249
- pass
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
- self.sse_client.disconnect()
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:
@@ -595,8 +671,15 @@ class GrowthBook(object):
595
671
  streaming_timeout=self._streaming_timeout
596
672
  )
597
673
 
598
- def stopAutoRefresh(self):
599
- feature_repo.stopAutoRefresh()
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
- # Clean up plugins first
644
- self._cleanup_plugins()
725
+ def destroy(self, timeout=10) -> None:
726
+ """Gracefully destroy the GrowthBook instance"""
727
+ logger.debug("Starting GrowthBook destroy process")
645
728
 
646
- # Clean up feature update callback
647
- if self._client_key:
648
- feature_repo.remove_feature_update_callback(self._on_feature_update)
649
-
650
- self._subscriptions.clear()
651
- self._tracked.clear()
652
- self._assigned.clear()
653
- self._trackingCallback = None
654
- self._forcedVariations.clear()
655
- self._overrides.clear()
656
- self._groups.clear()
657
- self._attributes.clear()
658
- self._features.clear()
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.2
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
@@ -1,5 +1,5 @@
1
1
  [bumpversion]
2
- current_version = 1.4.2
2
+ current_version = 1.4.3
3
3
  commit = True
4
4
  tag = True
5
5
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes