growthbook 1.2.1__py2.py3-none-any.whl → 1.3.1__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 CHANGED
@@ -1 +1,22 @@
1
- from .growthbook import *
1
+ from .growthbook import *
2
+
3
+ from .growthbook_client import (
4
+ GrowthBookClient,
5
+ EnhancedFeatureRepository,
6
+ FeatureCache,
7
+ BackoffStrategy
8
+ )
9
+
10
+ # Plugin support
11
+ from .plugins import (
12
+ GrowthBookTrackingPlugin,
13
+ growthbook_tracking_plugin,
14
+ RequestContextPlugin,
15
+ ClientSideAttributes,
16
+ request_context_plugin,
17
+ client_side_attributes
18
+ )
19
+
20
+ # x-release-please-start-version
21
+ __version__ = "1.3.1"
22
+ # x-release-please-end
@@ -220,9 +220,8 @@ class FeatureResult(object):
220
220
  "source": self.source,
221
221
  "on": self.on,
222
222
  "off": self.off,
223
+ "ruleId": self.ruleId or "",
223
224
  }
224
- if self.ruleId:
225
- data["ruleId"] = self.ruleId
226
225
  if self.experiment:
227
226
  data["experiment"] = self.experiment.to_dict()
228
227
  if self.experimentResult:
growthbook/core.py CHANGED
@@ -413,7 +413,8 @@ def getBucketRanges(
413
413
  def eval_feature(
414
414
  key: str,
415
415
  evalContext: EvaluationContext = None,
416
- callback_subscription: Callable[[Experiment, Result], None] = None
416
+ callback_subscription: Callable[[Experiment, Result], None] = None,
417
+ tracking_cb: Callable[[Experiment, Result], None] = None
417
418
  ) -> FeatureResult:
418
419
  """Core feature evaluation logic as a standalone function"""
419
420
 
@@ -506,7 +507,7 @@ def eval_feature(
506
507
  minBucketVersion=rule.minBucketVersion,
507
508
  )
508
509
 
509
- result = run_experiment(experiment=exp, featureId=key, evalContext=evalContext)
510
+ result = run_experiment(experiment=exp, featureId=key, evalContext=evalContext, tracking_cb=tracking_cb)
510
511
 
511
512
  if callback_subscription:
512
513
  callback_subscription(exp, result)
@@ -536,12 +537,20 @@ def eval_prereqs(parentConditions: List[dict], evalContext: EvaluationContext) -
536
537
  # Reset the stack in each iteration
537
538
  evalContext.stack.evaluated_features = evaluated_features.copy()
538
539
 
539
- parentRes = eval_feature(key=parentCondition.get("id", None), evalContext=evalContext)
540
+ parent_id = parentCondition.get("id")
541
+ if parent_id is None:
542
+ continue # Skip if no valid ID
543
+
544
+ parentRes = eval_feature(key=parent_id, evalContext=evalContext)
540
545
 
541
546
  if parentRes.source == "cyclicPrerequisite":
542
547
  return "cyclic"
543
548
 
544
- if not evalCondition({'value': parentRes.value}, parentCondition.get("condition", None), evalContext.global_ctx.saved_groups):
549
+ parent_condition = parentCondition.get("condition")
550
+ if parent_condition is None:
551
+ continue # Skip if no valid condition
552
+
553
+ if not evalCondition({'value': parentRes.value}, parent_condition, evalContext.global_ctx.saved_groups):
545
554
  if parentCondition.get("gate", False):
546
555
  return "gate"
547
556
  return "fail"
growthbook/growthbook.py CHANGED
@@ -11,7 +11,7 @@ import threading
11
11
  import logging
12
12
 
13
13
  from abc import ABC, abstractmethod
14
- from typing import Optional, Any, Set, Tuple, List, Dict
14
+ from typing import Optional, Any, Set, Tuple, List, Dict, Callable
15
15
 
16
16
  from .common_types import ( EvaluationContext,
17
17
  Experiment,
@@ -251,6 +251,7 @@ class FeatureRepository(object):
251
251
  self.cache: AbstractFeatureCache = InMemoryFeatureCache()
252
252
  self.http: Optional[PoolManager] = None
253
253
  self.sse_client: Optional[SSEClient] = None
254
+ self._feature_update_callbacks: List[Callable[[Dict], None]] = []
254
255
 
255
256
  def set_cache(self, cache: AbstractFeatureCache) -> None:
256
257
  self.cache = cache
@@ -258,12 +259,30 @@ class FeatureRepository(object):
258
259
  def clear_cache(self):
259
260
  self.cache.clear()
260
261
 
261
- def save_in_cache(self, key: str, res, ttl: int = 60):
262
+ def save_in_cache(self, key: str, res, ttl: int = 600):
262
263
  self.cache.set(key, res, ttl)
263
264
 
264
- # Loads features with an in-memory cache in front
265
+ def add_feature_update_callback(self, callback: Callable[[Dict], None]) -> None:
266
+ """Add a callback to be notified when features are updated due to cache expiry"""
267
+ if callback not in self._feature_update_callbacks:
268
+ self._feature_update_callbacks.append(callback)
269
+
270
+ def remove_feature_update_callback(self, callback: Callable[[Dict], None]) -> None:
271
+ """Remove a feature update callback"""
272
+ if callback in self._feature_update_callbacks:
273
+ self._feature_update_callbacks.remove(callback)
274
+
275
+ def _notify_feature_update_callbacks(self, features_data: Dict) -> None:
276
+ """Notify all registered callbacks about feature updates"""
277
+ for callback in self._feature_update_callbacks:
278
+ try:
279
+ callback(features_data)
280
+ except Exception as e:
281
+ logger.warning(f"Error in feature update callback: {e}")
282
+
283
+ # Loads features with an in-memory cache in front using stale-while-revalidate approach
265
284
  def load_features(
266
- self, api_host: str, client_key: str, decryption_key: str = "", ttl: int = 60
285
+ self, api_host: str, client_key: str, decryption_key: str = "", ttl: int = 600
267
286
  ) -> Optional[Dict]:
268
287
  if not client_key:
269
288
  raise ValueError("Must specify `client_key` to refresh features")
@@ -276,11 +295,13 @@ class FeatureRepository(object):
276
295
  if res is not None:
277
296
  self.cache.set(key, res, ttl)
278
297
  logger.debug("Fetched features from API, stored in cache")
298
+ # Notify callbacks about fresh features
299
+ self._notify_feature_update_callbacks(res)
279
300
  return res
280
301
  return cached
281
302
 
282
303
  async def load_features_async(
283
- self, api_host: str, client_key: str, decryption_key: str = "", ttl: int = 60
304
+ self, api_host: str, client_key: str, decryption_key: str = "", ttl: int = 600
284
305
  ) -> Optional[Dict]:
285
306
  key = api_host + "::" + client_key
286
307
 
@@ -290,6 +311,8 @@ class FeatureRepository(object):
290
311
  if res is not None:
291
312
  self.cache.set(key, res, ttl)
292
313
  logger.debug("Fetched features from API, stored in cache")
314
+ # Notify callbacks about fresh features
315
+ self._notify_feature_update_callbacks(res)
293
316
  return res
294
317
  return cached
295
318
 
@@ -414,12 +437,13 @@ class GrowthBook(object):
414
437
  api_host: str = "",
415
438
  client_key: str = "",
416
439
  decryption_key: str = "",
417
- cache_ttl: int = 60,
440
+ cache_ttl: int = 600,
418
441
  forced_variations: dict = {},
419
442
  sticky_bucket_service: AbstractStickyBucketService = None,
420
443
  sticky_bucket_identifier_attributes: List[str] = None,
421
444
  savedGroups: dict = {},
422
445
  streaming: bool = False,
446
+ plugins: List = None,
423
447
  # Deprecated args
424
448
  trackingCallback=None,
425
449
  qaMode: bool = False,
@@ -458,6 +482,10 @@ class GrowthBook(object):
458
482
  self._assigned: Dict[str, Any] = {}
459
483
  self._subscriptions: Set[Any] = set()
460
484
 
485
+ # support plugins
486
+ self._plugins: List = plugins or []
487
+ self._initialized_plugins: List = []
488
+
461
489
  self._global_ctx = GlobalContext(
462
490
  options=Options(
463
491
  url=self._url,
@@ -486,10 +514,22 @@ class GrowthBook(object):
486
514
  if features:
487
515
  self.setFeatures(features)
488
516
 
517
+ # Register for automatic feature updates when cache expires
518
+ if self._client_key:
519
+ feature_repo.add_feature_update_callback(self._on_feature_update)
520
+
521
+ self._initialize_plugins()
522
+
489
523
  if self._streaming:
490
524
  self.load_features()
491
525
  self.startAutoRefresh()
492
526
 
527
+ def _on_feature_update(self, features_data: Dict) -> None:
528
+ """Callback to handle automatic feature updates from FeatureRepository"""
529
+ if features_data and "features" in features_data:
530
+ self.set_features(features_data["features"])
531
+ if features_data and "savedGroups" in features_data:
532
+ self._saved_groups = features_data["savedGroups"]
493
533
 
494
534
  def load_features(self) -> None:
495
535
 
@@ -595,6 +635,13 @@ class GrowthBook(object):
595
635
  return self._attributes
596
636
 
597
637
  def destroy(self) -> None:
638
+ # Clean up plugins first
639
+ self._cleanup_plugins()
640
+
641
+ # Clean up feature update callback
642
+ if self._client_key:
643
+ feature_repo.remove_feature_update_callback(self._on_feature_update)
644
+
598
645
  self._subscriptions.clear()
599
646
  self._tracked.clear()
600
647
  self._assigned.clear()
@@ -631,7 +678,21 @@ class GrowthBook(object):
631
678
  def evalFeature(self, key: str) -> FeatureResult:
632
679
  return self.eval_feature(key)
633
680
 
681
+ def _ensure_fresh_features(self) -> None:
682
+ """Lazy refresh: Check cache expiry and refresh if needed, but only if client_key is provided"""
683
+
684
+ if self._streaming or not self._client_key:
685
+ return # Skip cache checks - SSE handles freshness for streaming users
686
+
687
+ try:
688
+ self.load_features()
689
+ except Exception as e:
690
+ logger.warning(f"Failed to refresh features: {e}")
691
+
634
692
  def _get_eval_context(self) -> EvaluationContext:
693
+ # Lazy refresh: ensure features are fresh before evaluation
694
+ self._ensure_fresh_features()
695
+
635
696
  # use the latest attributes for every evaluation.
636
697
  self._user_ctx.attributes = self._attributes
637
698
  self._user_ctx.url = self._url
@@ -647,7 +708,8 @@ class GrowthBook(object):
647
708
  def eval_feature(self, key: str) -> FeatureResult:
648
709
  return core_eval_feature(key=key,
649
710
  evalContext=self._get_eval_context(),
650
- callback_subscription=self._fireSubscriptions
711
+ callback_subscription=self._fireSubscriptions,
712
+ tracking_cb=self._track
651
713
  )
652
714
 
653
715
  # @deprecated, use get_all_results
@@ -744,3 +806,33 @@ class GrowthBook(object):
744
806
  self._sticky_bucket_assignment_docs = self.sticky_bucket_service.get_all_assignments(attributes)
745
807
  # Update the user context with the new sticky bucket assignment docs
746
808
  self._user_ctx.sticky_bucket_assignment_docs = self._sticky_bucket_assignment_docs
809
+
810
+ def _initialize_plugins(self) -> None:
811
+ """Initialize all plugins with this GrowthBook instance."""
812
+ for plugin in self._plugins:
813
+ try:
814
+ if hasattr(plugin, 'initialize'):
815
+ # Plugin is a class instance with initialize method
816
+ plugin.initialize(self)
817
+ self._initialized_plugins.append(plugin)
818
+ logger.debug(f"Initialized plugin: {plugin.__class__.__name__}")
819
+ elif callable(plugin):
820
+ # Plugin is a callable function
821
+ plugin(self)
822
+ self._initialized_plugins.append(plugin)
823
+ logger.debug(f"Initialized callable plugin: {plugin.__name__}")
824
+ else:
825
+ logger.warning(f"Plugin {plugin} is neither callable nor has initialize method")
826
+ except Exception as e:
827
+ logger.error(f"Failed to initialize plugin {plugin}: {e}")
828
+
829
+ def _cleanup_plugins(self) -> None:
830
+ """Cleanup all initialized plugins."""
831
+ for plugin in self._initialized_plugins:
832
+ try:
833
+ if hasattr(plugin, 'cleanup'):
834
+ plugin.cleanup()
835
+ logger.debug(f"Cleaned up plugin: {plugin.__class__.__name__}")
836
+ except Exception as e:
837
+ logger.error(f"Error cleaning up plugin {plugin}: {e}")
838
+ self._initialized_plugins.clear()
@@ -10,11 +10,7 @@ import threading
10
10
  import traceback
11
11
  from datetime import datetime
12
12
  from growthbook import FeatureRepository
13
- try:
14
- from contextlib import asynccontextmanager
15
- except ImportError:
16
- # to support python 3.6
17
- from async_generator import asynccontextmanager
13
+ from contextlib import asynccontextmanager
18
14
 
19
15
  from .core import eval_feature as core_eval_feature, run_experiment
20
16
  from .common_types import (
@@ -34,7 +30,7 @@ logger = logging.getLogger("growthbook.growthbook_client")
34
30
 
35
31
  class SingletonMeta(type):
36
32
  """Thread-safe implementation of Singleton pattern"""
37
- _instances = {}
33
+ _instances: Dict[type, Any] = {}
38
34
  _lock = threading.Lock()
39
35
 
40
36
  def __call__(cls, *args, **kwargs):
@@ -113,11 +109,11 @@ class EnhancedFeatureRepository(FeatureRepository, metaclass=SingletonMeta):
113
109
  self._decryption_key = decryption_key
114
110
  self._cache_ttl = cache_ttl
115
111
  self._refresh_lock = threading.Lock()
116
- self._refresh_task = None
112
+ self._refresh_task: Optional[asyncio.Task] = None
117
113
  self._stop_event = asyncio.Event()
118
114
  self._backoff = BackoffStrategy()
119
115
  self._feature_cache = FeatureCache()
120
- self._callbacks = []
116
+ self._callbacks: List[Callable[[Dict[str, Any]], Awaitable[None]]] = []
121
117
  self._last_successful_refresh = None
122
118
  self._refresh_in_progress = asyncio.Lock()
123
119
 
@@ -161,13 +157,13 @@ class EnhancedFeatureRepository(FeatureRepository, metaclass=SingletonMeta):
161
157
  except Exception:
162
158
  traceback.print_exc()
163
159
 
164
- def add_callback(self, callback: Callable) -> None:
160
+ def add_callback(self, callback: Callable[[Dict[str, Any]], Awaitable[None]]) -> None:
165
161
  """Add callback to the list"""
166
162
  with self._refresh_lock:
167
163
  if callback not in self._callbacks:
168
164
  self._callbacks.append(callback)
169
165
 
170
- def remove_callback(self, callback: Callable) -> None:
166
+ def remove_callback(self, callback: Callable[[Dict[str, Any]], Awaitable[None]]) -> None:
171
167
  """Remove callback from the list"""
172
168
  with self._refresh_lock:
173
169
  if callback in self._callbacks:
@@ -185,7 +181,8 @@ class EnhancedFeatureRepository(FeatureRepository, metaclass=SingletonMeta):
185
181
  response = await self.load_features_async(
186
182
  self._api_host, self._client_key, self._decryption_key, self._cache_ttl
187
183
  )
188
- await self._handle_feature_update(response)
184
+ if response is not None:
185
+ await self._handle_feature_update(response)
189
186
  elif event_data['type'] == 'features':
190
187
  await self._handle_feature_update(event_data['data'])
191
188
  except Exception:
@@ -224,7 +221,8 @@ class EnhancedFeatureRepository(FeatureRepository, metaclass=SingletonMeta):
224
221
  decryption_key=self._decryption_key,
225
222
  ttl=self._cache_ttl
226
223
  )
227
- await self._handle_feature_update(response)
224
+ if response is not None:
225
+ await self._handle_feature_update(response)
228
226
  # On success, reset backoff and use normal interval
229
227
  self._backoff.reset()
230
228
  try:
@@ -313,21 +311,27 @@ class GrowthBookClient:
313
311
  self._subscriptions_lock = threading.Lock()
314
312
 
315
313
  # Add sticky bucket cache
316
- self._sticky_bucket_cache = {
314
+ self._sticky_bucket_cache: Dict[str, Dict[str, Any]] = {
317
315
  'attributes': {},
318
316
  'assignments': {}
319
317
  }
320
318
  self._sticky_bucket_cache_lock = False
321
319
 
322
320
  self._features_repository = (
323
- EnhancedFeatureRepository(self.options.api_host, self.options.client_key, self.options.decryption_key)
321
+ EnhancedFeatureRepository(
322
+ self.options.api_host or "https://cdn.growthbook.io",
323
+ self.options.client_key or "",
324
+ self.options.decryption_key or "",
325
+ self.options.cache_ttl
326
+ )
324
327
  if self.options.client_key
325
328
  else None
326
329
  )
327
330
 
328
- self._global_context = None
331
+ self._global_context: Optional[GlobalContext] = None
329
332
  self._context_lock = asyncio.Lock()
330
333
 
334
+
331
335
  def _track(self, experiment: Experiment, result: Result) -> None:
332
336
  """Thread-safe tracking implementation"""
333
337
  if not self.options.on_experiment_viewed:
@@ -369,6 +373,11 @@ class GrowthBookClient:
369
373
  except Exception:
370
374
  logger.exception("Error in subscription callback")
371
375
 
376
+
377
+ async def set_features(self, features: dict) -> None:
378
+ await self._feature_update_callback({"features": features})
379
+
380
+
372
381
  async def _refresh_sticky_buckets(self, attributes: Dict[str, Any]) -> Dict[str, Any]:
373
382
  """Refresh sticky bucket assignments only if attributes have changed"""
374
383
  if not self.options.sticky_bucket_service:
@@ -387,6 +396,9 @@ class GrowthBookClient:
387
396
  return assignments
388
397
  finally:
389
398
  self._sticky_bucket_cache_lock = False
399
+
400
+ # Fallback return for edge case where loop condition is never satisfied
401
+ return {}
390
402
 
391
403
  async def initialize(self) -> bool:
392
404
  """Initialize client with features and start refresh"""
@@ -397,7 +409,10 @@ class GrowthBookClient:
397
409
  try:
398
410
  # Initial feature load
399
411
  initial_features = await self._features_repository.load_features_async(
400
- self.options.api_host, self.options.client_key, self.options.decryption_key, self.options.cache_ttl
412
+ self.options.api_host or "https://cdn.growthbook.io",
413
+ self.options.client_key or "",
414
+ self.options.decryption_key or "",
415
+ self.options.cache_ttl
401
416
  )
402
417
  if not initial_features:
403
418
  logger.error("Failed to load initial features")
@@ -410,7 +425,8 @@ class GrowthBookClient:
410
425
  self._features_repository.add_callback(self._feature_update_callback)
411
426
 
412
427
  # Start feature refresh
413
- await self._features_repository.start_feature_refresh(self.options.refresh_strategy)
428
+ refresh_strategy = self.options.refresh_strategy or FeatureRefreshStrategy.STALE_WHILE_REVALIDATE
429
+ await self._features_repository.start_feature_refresh(refresh_strategy)
414
430
  return True
415
431
 
416
432
  except Exception as e:
@@ -0,0 +1,16 @@
1
+ from .base import GrowthBookPlugin
2
+ # from .auto_attributes import auto_attributes_plugin, AutoAttributesPlugin
3
+ from .growthbook_tracking import growthbook_tracking_plugin, GrowthBookTrackingPlugin
4
+ from .request_context import request_context_plugin, client_side_attributes, RequestContextPlugin, ClientSideAttributes
5
+
6
+ __all__ = [
7
+ 'GrowthBookPlugin',
8
+ # 'auto_attributes_plugin',
9
+ # 'AutoAttributesPlugin',
10
+ 'growthbook_tracking_plugin',
11
+ 'GrowthBookTrackingPlugin',
12
+ 'request_context_plugin',
13
+ 'client_side_attributes',
14
+ 'RequestContextPlugin',
15
+ 'ClientSideAttributes',
16
+ ]
@@ -0,0 +1,103 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any, Dict, Optional
3
+ import logging
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ class GrowthBookPlugin(ABC):
9
+ """
10
+ Base class for all GrowthBook plugins.
11
+
12
+ Plugins extend GrowthBook functionality by adding auto-attributes,
13
+ tracking capabilities, or other enhancements.
14
+
15
+ Lifecycle:
16
+ 1. Plugin is instantiated with configuration options
17
+ 2. initialize(gb_instance) is called when GrowthBook is created
18
+ 3. Plugin enhances GrowthBook functionality
19
+ 4. cleanup() is called when GrowthBook.destroy() is called
20
+ """
21
+
22
+ def __init__(self, **options):
23
+ """Initialize plugin with configuration options."""
24
+ self.options = options
25
+ self._initialized = False
26
+ self._gb_instance = None
27
+ self.logger = logging.getLogger(f"{self.__class__.__module__}.{self.__class__.__name__}")
28
+
29
+ @abstractmethod
30
+ def initialize(self, gb_instance) -> None:
31
+ """
32
+ Initialize the plugin with a GrowthBook instance.
33
+
34
+ This method is called automatically when the GrowthBook instance
35
+ is created. Use this to set up the plugin functionality.
36
+
37
+ Args:
38
+ gb_instance: The GrowthBook instance to enhance
39
+ """
40
+ pass
41
+
42
+ def cleanup(self) -> None:
43
+ """
44
+ Cleanup plugin resources when GrowthBook instance is destroyed.
45
+
46
+ Override this method if your plugin needs to:
47
+ - Close network connections
48
+ - Cancel timers/threads
49
+ - Flush pending data
50
+ - Release resources
51
+
52
+ Default implementation does nothing.
53
+ """
54
+ self.logger.debug(f"Cleaning up plugin {self.__class__.__name__}")
55
+ self._gb_instance = None
56
+
57
+ def is_initialized(self) -> bool:
58
+ """Check if plugin has been initialized."""
59
+ return self._initialized
60
+
61
+ def _set_initialized(self, gb_instance) -> None:
62
+ """Mark plugin as initialized and store GrowthBook reference."""
63
+ self._initialized = True
64
+ self._gb_instance = gb_instance
65
+ self.logger.debug(f"Plugin {self.__class__.__name__} initialized successfully")
66
+
67
+ def _get_option(self, key: str, default: Any = None) -> Any:
68
+ """Get a configuration option with optional default."""
69
+ return self.options.get(key, default)
70
+
71
+ def _merge_attributes(self, new_attributes: Dict[str, Any]) -> None:
72
+ """
73
+ Helper method to merge new attributes with existing ones.
74
+
75
+ Args:
76
+ new_attributes: Dictionary of attributes to add/update
77
+ """
78
+ if not self._gb_instance:
79
+ self.logger.warning("Cannot merge attributes - plugin not initialized")
80
+ return
81
+
82
+ current_attributes = self._gb_instance.get_attributes()
83
+ merged_attributes = {**new_attributes, **current_attributes} # Existing attrs take precedence
84
+ self._gb_instance.set_attributes(merged_attributes)
85
+
86
+ self.logger.debug(f"Merged {len(new_attributes)} attributes: {list(new_attributes.keys())}")
87
+
88
+ def _safe_execute(self, func, *args, **kwargs):
89
+ """
90
+ Safely execute a function, logging any exceptions.
91
+
92
+ Args:
93
+ func: Function to execute
94
+ *args, **kwargs: Arguments to pass to function
95
+
96
+ Returns:
97
+ Function result or None if exception occurred
98
+ """
99
+ try:
100
+ return func(*args, **kwargs)
101
+ except Exception as e:
102
+ self.logger.error(f"Error in {func.__name__}: {e}")
103
+ return None
@@ -0,0 +1,262 @@
1
+ import json
2
+ import logging
3
+ import threading
4
+ import time
5
+ from typing import Dict, Any, Optional, List, Callable
6
+ from .base import GrowthBookPlugin
7
+
8
+ requests: Any = None
9
+ try:
10
+ import requests
11
+ except ImportError:
12
+ pass
13
+
14
+ logger = logging.getLogger("growthbook.plugins.growthbook_tracking")
15
+
16
+
17
+ class GrowthBookTrackingPlugin(GrowthBookPlugin):
18
+ """
19
+ GrowthBook tracking plugin for Built-in Warehouse.
20
+
21
+ This plugin automatically tracks "Experiment Viewed" and "Feature Evaluated"
22
+ events to GrowthBook's built-in data warehouse.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ ingestor_host: str,
28
+ track_experiment_viewed: bool = True,
29
+ track_feature_evaluated: bool = True,
30
+ batch_size: int = 10,
31
+ batch_timeout: float = 10.0,
32
+ additional_callback: Optional[Callable] = None,
33
+ **options
34
+ ):
35
+ """
36
+ Initialize GrowthBook tracking plugin.
37
+
38
+ Args:
39
+ ingestor_host: The GrowthBook ingestor endpoint
40
+ track_experiment_viewed: Whether to track experiment viewed events
41
+ track_feature_evaluated: Whether to track feature evaluated events
42
+ batch_size: Number of events to batch before sending
43
+ batch_timeout: Maximum time (seconds) to wait before sending a batch
44
+ additional_callback: Optional additional tracking callback
45
+ """
46
+ super().__init__(**options)
47
+
48
+ if not requests:
49
+ raise ImportError("requests library is required for GrowthBookTrackingPlugin. Install with: pip install requests")
50
+
51
+ self.ingestor_host = ingestor_host.rstrip('/')
52
+ self.track_experiment_viewed = track_experiment_viewed
53
+ self.track_feature_evaluated = track_feature_evaluated
54
+ self.batch_size = batch_size
55
+ self.batch_timeout = batch_timeout
56
+ self.additional_callback = additional_callback
57
+
58
+ # batching
59
+ self._event_batch: List[Dict[str, Any]] = []
60
+ self._batch_lock = threading.Lock()
61
+ self._flush_timer: Optional[threading.Timer] = None
62
+ self._client_key: Optional[str] = None
63
+
64
+ def initialize(self, gb_instance) -> None:
65
+ """Initialize plugin with GrowthBook instance."""
66
+ try:
67
+ self._client_key = getattr(gb_instance, '_client_key', '')
68
+
69
+ # Hook into experiment tracking
70
+ if self.track_experiment_viewed:
71
+ self._setup_experiment_tracking(gb_instance)
72
+
73
+ # Hook into feature evaluation
74
+ if self.track_feature_evaluated:
75
+ self._setup_feature_tracking(gb_instance)
76
+
77
+ self._set_initialized(gb_instance)
78
+ self.logger.info(f"Tracking enabled for {self.ingestor_host}")
79
+
80
+ except Exception as e:
81
+ self.logger.error(f"Failed to initialize tracking plugin: {e}")
82
+
83
+ def cleanup(self) -> None:
84
+ """Cleanup plugin resources."""
85
+ self._flush_events()
86
+ if self._flush_timer:
87
+ self._flush_timer.cancel()
88
+ super().cleanup()
89
+
90
+ def _setup_experiment_tracking(self, gb_instance) -> None:
91
+ """Setup experiment tracking."""
92
+ original_callback = getattr(gb_instance, '_trackingCallback', None)
93
+
94
+ def tracking_wrapper(experiment, result):
95
+ # Track to ingestor
96
+ self._track_experiment_viewed(experiment, result)
97
+
98
+ # Call additional callback
99
+ if self.additional_callback:
100
+ self._safe_execute(self.additional_callback, experiment, result)
101
+
102
+ # Call original callback
103
+ if original_callback:
104
+ self._safe_execute(original_callback, experiment, result)
105
+
106
+ gb_instance._trackingCallback = tracking_wrapper
107
+
108
+ def _setup_feature_tracking(self, gb_instance):
109
+ """Setup feature evaluation tracking."""
110
+ original_eval_feature = gb_instance.eval_feature
111
+
112
+ def eval_feature_wrapper(key: str):
113
+ result = original_eval_feature(key)
114
+ self._track_feature_evaluated(key, result, gb_instance)
115
+ return result
116
+
117
+ gb_instance.eval_feature = eval_feature_wrapper
118
+
119
+ def _track_experiment_viewed(self, experiment, result) -> None:
120
+ """Track experiment viewed event."""
121
+ try:
122
+ # Build event data with all metadata
123
+ event_data = {
124
+ 'event_type': 'experiment_viewed',
125
+ 'timestamp': int(time.time() * 1000),
126
+ 'client_key': self._client_key,
127
+ 'sdk_language': 'python',
128
+ 'sdk_version': self._get_sdk_version(),
129
+ # Core experiment data
130
+ 'experiment_id': experiment.key,
131
+ 'variation_id': result.variationId,
132
+ 'variation_key': getattr(result, 'key', str(result.variationId)),
133
+ 'variation_value': result.value,
134
+ 'in_experiment': result.inExperiment,
135
+ 'hash_used': result.hashUsed,
136
+ 'hash_attribute': result.hashAttribute,
137
+ 'hash_value': result.hashValue,
138
+ }
139
+
140
+ # Add optional metadata if available
141
+ if hasattr(experiment, 'name') and experiment.name:
142
+ event_data['experiment_name'] = experiment.name
143
+ if hasattr(result, 'featureId') and result.featureId:
144
+ event_data['feature_id'] = result.featureId
145
+
146
+ self._add_event_to_batch(event_data)
147
+
148
+ except Exception as e:
149
+ self.logger.error(f"Error tracking experiment: {e}")
150
+
151
+ def _track_feature_evaluated(self, feature_key: str, result, gb_instance) -> None:
152
+ """Track feature evaluated event."""
153
+ try:
154
+ # Build event data with all metadata
155
+ event_data = {
156
+ 'event_type': 'feature_evaluated',
157
+ 'timestamp': int(time.time() * 1000),
158
+ 'client_key': self._client_key,
159
+ 'sdk_language': 'python',
160
+ 'sdk_version': self._get_sdk_version(),
161
+ # Core feature data
162
+ 'feature_key': feature_key,
163
+ 'feature_value': result.value,
164
+ 'source': result.source,
165
+ 'on': getattr(result, 'on', bool(result.value)),
166
+ 'off': getattr(result, 'off', not bool(result.value)),
167
+ }
168
+
169
+ # Add optional metadata if available
170
+ if hasattr(result, 'ruleId') and result.ruleId:
171
+ event_data['rule_id'] = result.ruleId
172
+
173
+ # Add experiment info if feature came from experiment
174
+ if hasattr(result, 'experiment') and result.experiment:
175
+ event_data['experiment_id'] = result.experiment.key
176
+ if hasattr(result, 'experimentResult') and result.experimentResult:
177
+ event_data['variation_id'] = result.experimentResult.variationId
178
+ event_data['in_experiment'] = result.experimentResult.inExperiment
179
+
180
+ self._add_event_to_batch(event_data)
181
+
182
+ except Exception as e:
183
+ self.logger.error(f"Error tracking feature: {e}")
184
+
185
+ def _add_event_to_batch(self, event_data: Dict[str, Any]) -> None:
186
+ with self._batch_lock:
187
+ self._event_batch.append(event_data)
188
+
189
+ # Flush if batch is full
190
+ if len(self._event_batch) >= self.batch_size:
191
+ self._flush_batch_locked()
192
+ elif len(self._event_batch) == 1:
193
+ # Start timer for first event
194
+ self._start_flush_timer()
195
+
196
+ def _start_flush_timer(self) -> None:
197
+ """Start flush timer."""
198
+ if self._flush_timer:
199
+ self._flush_timer.cancel()
200
+
201
+ self._flush_timer = threading.Timer(self.batch_timeout, self._flush_events)
202
+ self._flush_timer.start()
203
+
204
+ def _flush_events(self) -> None:
205
+ """Flush events with lock."""
206
+ with self._batch_lock:
207
+ self._flush_batch_locked()
208
+
209
+ def _flush_batch_locked(self) -> None:
210
+ """Flush current batch (called while holding lock)."""
211
+ if not self._event_batch:
212
+ return
213
+
214
+ events_to_send = self._event_batch.copy()
215
+ self._event_batch.clear()
216
+
217
+ if self._flush_timer:
218
+ self._flush_timer.cancel()
219
+ self._flush_timer = None
220
+
221
+ # Send in background thread
222
+ threading.Thread(target=self._send_events, args=(events_to_send,), daemon=True).start()
223
+
224
+ def _send_events(self, events: List[Dict[str, Any]]) -> None:
225
+ """Send events using requests library."""
226
+ if not events:
227
+ return
228
+
229
+ try:
230
+ payload = {
231
+ 'events': events,
232
+ 'client_key': self._client_key
233
+ }
234
+
235
+ url = f"{self.ingestor_host}/events"
236
+ response = requests.post(
237
+ url,
238
+ json=payload,
239
+ headers={'User-Agent': f'growthbook-python-sdk/{self._get_sdk_version()}'},
240
+ timeout=30
241
+ )
242
+
243
+ if response.status_code == 200:
244
+ self.logger.debug(f"Successfully sent {len(events)} events")
245
+ else:
246
+ self.logger.warning(f"Ingestor returned status {response.status_code}")
247
+
248
+ except Exception as e:
249
+ self.logger.error(f"Failed to send events: {e}")
250
+
251
+ def _get_sdk_version(self) -> str:
252
+ """Get SDK version."""
253
+ try:
254
+ import growthbook
255
+ return getattr(growthbook, '__version__', 'unknown')
256
+ except:
257
+ return 'unknown'
258
+
259
+
260
+ def growthbook_tracking_plugin(**options) -> GrowthBookTrackingPlugin:
261
+ """Create a GrowthBook tracking plugin."""
262
+ return GrowthBookTrackingPlugin(**options)
@@ -0,0 +1,341 @@
1
+ """
2
+ Request Context Plugin for GrowthBook Python SDK
3
+
4
+ This plugin extracts attributes from HTTP request context using a framework-agnostic approach.
5
+ """
6
+
7
+ import logging
8
+ import uuid
9
+ import time
10
+ from typing import Dict, Any, Optional, Callable, Union
11
+ from urllib.parse import urlparse
12
+ from .base import GrowthBookPlugin
13
+
14
+ logger = logging.getLogger("growthbook.plugins.request_context")
15
+
16
+ # Global context variable for storing current request
17
+ _current_request_context: Dict[str, Any] = {}
18
+
19
+
20
+ class ClientSideAttributes:
21
+ """
22
+ Client-side attributes that can't be detected server-side.
23
+ """
24
+
25
+ def __init__(self, **attributes: Any):
26
+ """
27
+ Initialize with any client-side attributes.
28
+
29
+ Common attributes:
30
+ pageTitle: Current page title
31
+ deviceType: "mobile" | "desktop" | "tablet"
32
+ browser: "chrome" | "firefox" | "safari" | "edge"
33
+ timezone: User's timezone (e.g., "America/New_York")
34
+ language: User's language (e.g., "en-US")
35
+
36
+ Args:
37
+ **attributes: Any client-side attributes as key-value pairs
38
+ """
39
+ for key, value in attributes.items():
40
+ if value is not None:
41
+ setattr(self, key, value)
42
+
43
+ def to_dict(self) -> Dict[str, Any]:
44
+ """Convert to dictionary."""
45
+ return {k: v for k, v in self.__dict__.items() if v is not None}
46
+
47
+
48
+ class RequestContextPlugin(GrowthBookPlugin):
49
+ """
50
+ Framework-agnostic request context plugin.
51
+
52
+ This plugin uses:
53
+ 1. Manual request object passing via set_request_context()
54
+ 2. Context variables set by middleware
55
+ 3. Direct attribute extraction from provided data
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ request_extractor: Optional[Callable] = None,
61
+ client_side_attributes: Optional[ClientSideAttributes] = None,
62
+ extract_utm: bool = True,
63
+ extract_user_agent: bool = True,
64
+ **options
65
+ ):
66
+ """
67
+ Initialize request context plugin.
68
+
69
+ Args:
70
+ request_extractor: Optional function to extract request object from context
71
+ client_side_attributes: Manual client-side attributes
72
+ extract_utm: Whether to extract UTM parameters
73
+ extract_user_agent: Whether to parse User-Agent header
74
+ """
75
+ super().__init__(**options)
76
+ self.request_extractor = request_extractor
77
+ self.client_side_attributes = client_side_attributes
78
+ self.extract_utm = extract_utm
79
+ self.extract_user_agent = extract_user_agent
80
+
81
+ def initialize(self, gb_instance) -> None:
82
+ """Initialize plugin - extract attributes from request context."""
83
+ try:
84
+ self._set_initialized(gb_instance)
85
+
86
+ # Get request data from various sources
87
+ request_attributes = self._extract_all_attributes()
88
+
89
+ if request_attributes:
90
+ # Merge with existing attributes (existing take precedence)
91
+ current_attributes = gb_instance.get_attributes()
92
+ merged_attributes = {**request_attributes, **current_attributes}
93
+ gb_instance.set_attributes(merged_attributes)
94
+
95
+ self.logger.info(f"Extracted {len(request_attributes)} request attributes")
96
+ else:
97
+ self.logger.debug("No request context available")
98
+
99
+ except Exception as e:
100
+ self.logger.error(f"Failed to extract request attributes: {e}")
101
+
102
+ def _extract_all_attributes(self) -> Dict[str, Any]:
103
+ """Extract all available attributes from request context."""
104
+ attributes = {}
105
+
106
+ # Get request object/data
107
+ request_data = self._get_request_data()
108
+
109
+ if request_data:
110
+ # Extract core request info
111
+ attributes.update(self._extract_basic_info(request_data))
112
+
113
+ # Extract UTM parameters
114
+ if self.extract_utm:
115
+ attributes.update(self._extract_utm_params(request_data))
116
+
117
+ # Extract User-Agent info
118
+ if self.extract_user_agent:
119
+ attributes.update(self._extract_user_agent(request_data))
120
+
121
+ # Add client-side attributes (these override auto-detected)
122
+ if self.client_side_attributes:
123
+ attributes.update(self.client_side_attributes.to_dict())
124
+
125
+ # Add server context
126
+ attributes.update({
127
+ 'server_timestamp': int(time.time()),
128
+ 'request_id': str(uuid.uuid4())[:8],
129
+ 'sdk_context': 'server'
130
+ })
131
+
132
+ return attributes
133
+
134
+ def _get_request_data(self) -> Optional[Dict[str, Any]]:
135
+ """Get request data from various sources."""
136
+ # 1. Try custom extractor
137
+ if self.request_extractor:
138
+ try:
139
+ request_obj = self.request_extractor()
140
+ if request_obj:
141
+ return self._normalize_request_object(request_obj)
142
+ except Exception as e:
143
+ self.logger.debug(f"Custom extractor failed: {e}")
144
+
145
+ # 2. Try global context
146
+ if _current_request_context:
147
+ return _current_request_context.copy()
148
+
149
+ # 3. Try thread-local storage
150
+ import threading
151
+ thread_local = getattr(threading.current_thread(), 'gb_request_context', None)
152
+ if thread_local:
153
+ return thread_local
154
+
155
+ return None
156
+
157
+ def _normalize_request_object(self, request_obj) -> Dict[str, Any]:
158
+ """Convert various request objects to normalized dict."""
159
+ normalized = {}
160
+
161
+ try:
162
+ # Extract URL
163
+ url = None
164
+ if hasattr(request_obj, 'build_absolute_uri'): # Django
165
+ url = request_obj.build_absolute_uri()
166
+ elif hasattr(request_obj, 'url'): # Flask/FastAPI
167
+ url = str(request_obj.url)
168
+
169
+ if url:
170
+ normalized['url'] = url
171
+ parsed = urlparse(url)
172
+ normalized['path'] = parsed.path
173
+ normalized['host'] = parsed.netloc
174
+ normalized['query_string'] = parsed.query
175
+
176
+ # Extract query parameters
177
+ query_params = {}
178
+ if hasattr(request_obj, 'GET'): # Django
179
+ query_params = dict(request_obj.GET)
180
+ elif hasattr(request_obj, 'args'): # Flask
181
+ query_params = dict(request_obj.args)
182
+ elif hasattr(request_obj, 'query_params'): # FastAPI
183
+ query_params = dict(request_obj.query_params)
184
+
185
+ normalized['query_params'] = query_params
186
+
187
+ # Extract User-Agent
188
+ user_agent = None
189
+ if hasattr(request_obj, 'META'): # Django
190
+ user_agent = request_obj.META.get('HTTP_USER_AGENT')
191
+ elif hasattr(request_obj, 'headers'): # Flask/FastAPI
192
+ user_agent = request_obj.headers.get('user-agent') or request_obj.headers.get('User-Agent')
193
+
194
+ if user_agent:
195
+ normalized['user_agent'] = user_agent
196
+
197
+ # Extract user info (if available)
198
+ if hasattr(request_obj, 'user') and hasattr(request_obj.user, 'id'):
199
+ if getattr(request_obj.user, 'is_authenticated', True):
200
+ normalized['user_id'] = str(request_obj.user.id)
201
+
202
+ except Exception as e:
203
+ self.logger.debug(f"Error normalizing request object: {e}")
204
+
205
+ return normalized
206
+
207
+ def _extract_basic_info(self, request_data: Dict[str, Any]) -> Dict[str, Any]:
208
+ """Extract basic request information."""
209
+ info = {}
210
+
211
+ if 'url' in request_data:
212
+ info['url'] = request_data['url']
213
+ if 'path' in request_data:
214
+ info['path'] = request_data['path']
215
+ if 'host' in request_data:
216
+ info['host'] = request_data['host']
217
+ if 'user_id' in request_data:
218
+ info['id'] = request_data['user_id']
219
+
220
+ return info
221
+
222
+ def _extract_utm_params(self, request_data: Dict[str, Any]) -> Dict[str, Any]:
223
+ """Extract UTM parameters from query string."""
224
+ utm_params = {}
225
+ query_params = request_data.get('query_params', {})
226
+
227
+ utm_mappings = {
228
+ 'utm_source': 'utmSource',
229
+ 'utm_medium': 'utmMedium',
230
+ 'utm_campaign': 'utmCampaign',
231
+ 'utm_term': 'utmTerm',
232
+ 'utm_content': 'utmContent'
233
+ }
234
+
235
+ for param, attr_name in utm_mappings.items():
236
+ value = query_params.get(param)
237
+ if value:
238
+ utm_params[attr_name] = value
239
+
240
+ return utm_params
241
+
242
+ def _extract_user_agent(self, request_data: Dict[str, Any]) -> Dict[str, Any]:
243
+ """Extract browser and device info from User-Agent."""
244
+ user_agent = request_data.get('user_agent')
245
+ if not user_agent:
246
+ return {}
247
+
248
+ ua_lower = user_agent.lower()
249
+ info = {'userAgent': user_agent}
250
+
251
+ # Simple browser detection
252
+ if 'edge' in ua_lower or 'edg' in ua_lower:
253
+ info['browser'] = 'edge'
254
+ elif 'chrome' in ua_lower:
255
+ info['browser'] = 'chrome'
256
+ elif 'firefox' in ua_lower:
257
+ info['browser'] = 'firefox'
258
+ elif 'safari' in ua_lower:
259
+ info['browser'] = 'safari'
260
+ else:
261
+ info['browser'] = 'unknown'
262
+
263
+ # Simple device detection
264
+ mobile_indicators = ['mobile', 'android', 'iphone', 'ipad']
265
+ if any(indicator in ua_lower for indicator in mobile_indicators):
266
+ info['deviceType'] = 'mobile'
267
+ else:
268
+ info['deviceType'] = 'desktop'
269
+
270
+ return info
271
+
272
+
273
+ # Framework-agnostic helper functions
274
+ def set_request_context(request_data: Union[Dict[str, Any], Any]) -> None:
275
+ """
276
+ Set request context globally for the current thread.
277
+
278
+ Args:
279
+ request_data: Either a dict of request data or a request object to normalize
280
+ """
281
+ global _current_request_context
282
+
283
+ if isinstance(request_data, dict):
284
+ _current_request_context = request_data
285
+ else:
286
+ # Try to normalize request object
287
+ plugin = RequestContextPlugin()
288
+ _current_request_context = plugin._normalize_request_object(request_data)
289
+
290
+
291
+ def clear_request_context() -> None:
292
+ """Clear the global request context."""
293
+ global _current_request_context
294
+ _current_request_context = {}
295
+
296
+
297
+ # Convenience functions
298
+ def request_context_plugin(**options) -> RequestContextPlugin:
299
+ """
300
+ Create a request context plugin.
301
+
302
+ Usage examples:
303
+
304
+ # 1. With middleware setting global context:
305
+ set_request_context(request)
306
+ gb = GrowthBook(plugins=[request_context_plugin()])
307
+
308
+ # 2. With custom extractor:
309
+ def get_current_request():
310
+ return my_framework.get_current_request()
311
+
312
+ gb = GrowthBook(plugins=[
313
+ request_context_plugin(request_extractor=get_current_request)
314
+ ])
315
+
316
+ # 3. With client-side attributes:
317
+ gb = GrowthBook(plugins=[
318
+ request_context_plugin(
319
+ client_side_attributes=client_side_attributes(
320
+ pageTitle="Dashboard",
321
+ deviceType="mobile"
322
+ )
323
+ )
324
+ ])
325
+ """
326
+ return RequestContextPlugin(**options)
327
+
328
+
329
+ def client_side_attributes(**kwargs) -> ClientSideAttributes:
330
+ """
331
+ Create client-side attributes.
332
+
333
+ Usage:
334
+ attrs = client_side_attributes(
335
+ pageTitle="My Page",
336
+ deviceType="mobile",
337
+ browser="chrome",
338
+ customField="value"
339
+ )
340
+ """
341
+ return ClientSideAttributes(**kwargs)
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: growthbook
3
- Version: 1.2.1
3
+ Version: 1.3.1
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,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.9
18
18
  Classifier: Programming Language :: Python :: 3.10
19
19
  Classifier: Programming Language :: Python :: 3.11
20
20
  Classifier: Programming Language :: Python :: 3.12
21
- Requires-Python: >=3.6
21
+ Requires-Python: >=3.7
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
24
  Requires-Dist: cryptography
@@ -30,22 +30,27 @@ Requires-Dist: aiohttp>=3.6.0
30
30
  Requires-Dist: importlib-metadata; python_version < "3.8"
31
31
  Dynamic: author
32
32
  Dynamic: home-page
33
+ Dynamic: license-file
33
34
  Dynamic: requires-python
34
35
 
35
36
  # GrowthBook Python SDK
36
37
 
37
- Powerful Feature flagging and A/B testing for Python apps.
38
-
39
38
  ![Build Status](https://github.com/growthbook/growthbook-python/workflows/Build/badge.svg)
39
+ [![PyPI](https://img.shields.io/pypi/v/growthbook.svg?maxAge=2592000)](https://pypi.org/project/growthbook/)
40
+ [![PyPI](https://img.shields.io/pypi/pyversions/growthbook.svg)](https://pypi.org/project/growthbook/)
41
+
42
+ ## Overview
43
+
44
+ Powerful Feature flagging and A/B testing for Python apps.
40
45
 
41
46
  - **Lightweight and fast**
42
47
  - **Local evaluation**, no network requests required
43
- - Python 3.6+
44
- - 100% test coverage
45
48
  - Flexible **targeting**
46
49
  - **Use your existing event tracking** (GA, Segment, Mixpanel, custom)
47
50
  - **Remote configuration** to change feature flags without deploying new code
48
51
  - **Async support** with real-time feature updates
52
+ - Python 3.6+
53
+ - 100% test coverage
49
54
 
50
55
  ## Installation
51
56
 
@@ -410,6 +415,33 @@ gb = GrowthBook(
410
415
  )
411
416
  ```
412
417
 
418
+ #### Built-in Tracking Plugin
419
+
420
+ For easier setup, you can use the built-in tracking plugin that automatically sends experiment and feature events to GrowthBook's data warehouse:
421
+
422
+ ```python
423
+ from growthbook import GrowthBook
424
+ from growthbook.plugins import growthbook_tracking_plugin, request_context_plugin
425
+
426
+ gb = GrowthBook(
427
+ attributes={"id": "user-123"},
428
+ plugins=[
429
+ request_context_plugin(), # Extracts request data
430
+ growthbook_tracking_plugin(
431
+ ingestor_host="https://gb-ingest.growthbook.io",
432
+ # Optional: Add custom tracking callback
433
+ additional_callback=my_custom_tracker
434
+ )
435
+ ]
436
+ )
437
+
438
+ # Events are now automatically tracked for experiments and features
439
+ result = gb.run(experiment) # -> Tracked automatically
440
+ is_enabled = gb.is_on("my-feature") # -> Tracked automatically
441
+ ```
442
+
443
+ The tracking plugin provides batching, error handling, and works alongside your existing tracking callbacks. See the [plugin documentation](https://docs.growthbook.io/lib/python#tracking-plugins) for more details.
444
+
413
445
  ## Using Features
414
446
 
415
447
  There are 3 main methods for interacting with features.
@@ -0,0 +1,15 @@
1
+ growthbook/__init__.py,sha256=2pw6uyYKI4IQ59LZbCUsNroy4LLFZ_5XyPmscPUyAwI,444
2
+ growthbook/common_types.py,sha256=1jDWwS_9L0z_AZb5AJq1arEqi6QU66N3S4vYzkcDFDY,14728
3
+ growthbook/core.py,sha256=LCz5f5rchUL056kgJxgH566NoZWr2nr4hhL7MRlyNiM,35184
4
+ growthbook/growthbook.py,sha256=IJmFKTx87zAwQ6nO_Gepm5-S_Mtwf-lnt2RFBviB6Lk,30672
5
+ growthbook/growthbook_client.py,sha256=zLgoBSBCSfCb8OE0b-XQiDAEB4djTiMSrbNpqBui-M0,21454
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=aL_dgsPt51IZNxRVZVA5U3QEiGW-e-l5tC7Owl6CZO0,10032
10
+ growthbook/plugins/request_context.py,sha256=7_9LOfS5nXbxXE7KJ-zNT4Ug4-_698A4c3t0hjr6l4c,11895
11
+ growthbook-1.3.1.dist-info/licenses/LICENSE,sha256=D-TcBckB0dTPUlNJ8jBiTIJIj1ekHLB1CY7HJtJKhMY,1069
12
+ growthbook-1.3.1.dist-info/METADATA,sha256=FwTzARVgHtfSH2ZnCWVq0G9k5ISiPyQDIeYjabR3vCQ,22073
13
+ growthbook-1.3.1.dist-info/WHEEL,sha256=JNWh1Fm1UdwIQV075glCn4MVuCRs0sotJIq-J6rbxCU,109
14
+ growthbook-1.3.1.dist-info/top_level.txt,sha256=dzfRQFGYejCIUstRSrrRVTMlxf7pBqASTI5S8gGRlXw,11
15
+ growthbook-1.3.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.8.2)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py2-none-any
5
5
  Tag: py3-none-any
@@ -1,11 +0,0 @@
1
- growthbook/__init__.py,sha256=j7q2Y9SF-g2sZKxpqKeyFeRDIxWv_JlBeWmb3NUaOs0,25
2
- growthbook/common_types.py,sha256=PmZ6fmje0Tvr2rTxUU1pYkyGr-M4CKRnZhJbf9fKSr0,14752
3
- growthbook/core.py,sha256=kcr5wZ6yWdiHjss408qteqio3NHFMh1sk2JNR4S0OrE,34847
4
- growthbook/growthbook.py,sha256=mFdURTSTMjcQrl1FkrXGr6SFdXdErQNyIttpcuHlL9E,26360
5
- growthbook/growthbook_client.py,sha256=KNBL_TQUySl1ZCbYcB2xL_0p2ZTGhUKtjFXW2odSOhU,20647
6
- growthbook/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- growthbook-1.2.1.dist-info/LICENSE,sha256=D-TcBckB0dTPUlNJ8jBiTIJIj1ekHLB1CY7HJtJKhMY,1069
8
- growthbook-1.2.1.dist-info/METADATA,sha256=QIaobsseMytueEdiMZXDvDCBnBMGeIbVo_i6D1VYS6A,20805
9
- growthbook-1.2.1.dist-info/WHEEL,sha256=rF4EZyR2XVS6irmOHQIJx2SUqXLZKRMUrjsg8UwN-XQ,109
10
- growthbook-1.2.1.dist-info/top_level.txt,sha256=dzfRQFGYejCIUstRSrrRVTMlxf7pBqASTI5S8gGRlXw,11
11
- growthbook-1.2.1.dist-info/RECORD,,