growthbook 1.3.1__py2.py3-none-any.whl → 1.4.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 CHANGED
@@ -18,5 +18,5 @@ from .plugins import (
18
18
  )
19
19
 
20
20
  # x-release-please-start-version
21
- __version__ = "1.3.1"
21
+ __version__ = "1.4.0"
22
22
  # x-release-please-end
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env python
2
2
 
3
3
  import sys
4
+ from token import OP
4
5
  # Only require typing_extensions if using Python 3.7 or earlier
5
6
  if sys.version_info >= (3, 8):
6
7
  from typing import TypedDict
@@ -8,7 +9,7 @@ else:
8
9
  from typing_extensions import TypedDict
9
10
 
10
11
  from dataclasses import dataclass, field
11
- from typing import Any, Dict, List, Optional, Union, Set, Tuple
12
+ from typing import Any, Callable, Dict, List, Optional, Union, Set, Tuple
12
13
  from enum import Enum
13
14
  from abc import ABC, abstractmethod
14
15
 
@@ -400,6 +401,15 @@ class StackContext:
400
401
  class FeatureRefreshStrategy(Enum):
401
402
  STALE_WHILE_REVALIDATE = 'HTTP_REFRESH'
402
403
  SERVER_SENT_EVENTS = 'SSE'
404
+ @dataclass
405
+ class UserContext:
406
+ # user_id: Optional[str] = None
407
+ url: str = ""
408
+ attributes: Dict[str, Any] = field(default_factory=dict)
409
+ groups: Dict[str, str] = field(default_factory=dict)
410
+ forced_variations: Dict[str, Any] = field(default_factory=dict)
411
+ overrides: Dict[str, Any] = field(default_factory=dict)
412
+ sticky_bucket_assignment_docs: Dict[str, Any] = field(default_factory=dict)
403
413
 
404
414
  @dataclass
405
415
  class Options:
@@ -415,17 +425,9 @@ class Options:
415
425
  refresh_strategy: Optional[FeatureRefreshStrategy] = FeatureRefreshStrategy.STALE_WHILE_REVALIDATE
416
426
  sticky_bucket_service: Optional[AbstractStickyBucketService] = None
417
427
  sticky_bucket_identifier_attributes: Optional[List[str]] = None
418
- on_experiment_viewed=None
428
+ on_experiment_viewed: Optional[Callable[[Experiment, Result, Optional[UserContext]], None]] = None
429
+ tracking_plugins: Optional[List[Any]] = None
419
430
 
420
- @dataclass
421
- class UserContext:
422
- # user_id: Optional[str] = None
423
- url: str = ""
424
- attributes: Dict[str, Any] = field(default_factory=dict)
425
- groups: Dict[str, str] = field(default_factory=dict)
426
- forced_variations: Dict[str, Any] = field(default_factory=dict)
427
- overrides: Dict[str, Any] = field(default_factory=dict)
428
- sticky_bucket_assignment_docs: Dict[str, Any] = field(default_factory=dict)
429
431
 
430
432
  @dataclass
431
433
  class GlobalContext:
growthbook/core.py CHANGED
@@ -4,7 +4,7 @@ import json
4
4
 
5
5
  from urllib.parse import urlparse, parse_qs
6
6
  from typing import Callable, Optional, Any, Set, Tuple, List, Dict
7
- from .common_types import EvaluationContext, FeatureResult, Experiment, Filter, Result, VariationMeta
7
+ from .common_types import EvaluationContext, FeatureResult, Experiment, Filter, Result, UserContext, VariationMeta
8
8
 
9
9
 
10
10
  logger = logging.getLogger("growthbook.core")
@@ -414,7 +414,7 @@ def eval_feature(
414
414
  key: str,
415
415
  evalContext: EvaluationContext = None,
416
416
  callback_subscription: Callable[[Experiment, Result], None] = None,
417
- tracking_cb: Callable[[Experiment, Result], None] = None
417
+ tracking_cb: Callable[[Experiment, Result, UserContext], None] = None
418
418
  ) -> FeatureResult:
419
419
  """Core feature evaluation logic as a standalone function"""
420
420
 
@@ -634,7 +634,7 @@ def _get_sticky_bucket_variation(
634
634
  def run_experiment(experiment: Experiment,
635
635
  featureId: Optional[str] = None,
636
636
  evalContext: EvaluationContext = None,
637
- tracking_cb: Callable[[Experiment, Result], None] = None
637
+ tracking_cb: Callable[[Experiment, Result, UserContext], None] = None
638
638
  ) -> Result:
639
639
  if evalContext is None:
640
640
  raise ValueError("evalContext is required - run_experiment")
@@ -859,7 +859,7 @@ def run_experiment(experiment: Experiment,
859
859
 
860
860
  # 14. Fire the tracking callback if set
861
861
  if tracking_cb:
862
- tracking_cb(experiment, result)
862
+ tracking_cb(experiment, result, evalContext.user)
863
863
 
864
864
  # 15. Return the result
865
865
  logger.debug("Assigned variation %d in experiment %s", assigned, experiment.key)
growthbook/growthbook.py CHANGED
@@ -671,7 +671,7 @@ class GrowthBook(object):
671
671
  return self.get_feature_value(key, fallback)
672
672
 
673
673
  def get_feature_value(self, key: str, fallback):
674
- res = self.evalFeature(key)
674
+ res = self.eval_feature(key)
675
675
  return res.value if res.value is not None else fallback
676
676
 
677
677
  # @deprecated, use eval_feature
@@ -753,7 +753,7 @@ class GrowthBook(object):
753
753
  self._subscriptions.add(callback)
754
754
  return lambda: self._subscriptions.remove(callback)
755
755
 
756
- def _track(self, experiment: Experiment, result: Result) -> None:
756
+ def _track(self, experiment: Experiment, result: Result, user_context: UserContext) -> None:
757
757
  if not self._trackingCallback:
758
758
  return None
759
759
  key = (
@@ -764,7 +764,7 @@ class GrowthBook(object):
764
764
  )
765
765
  if not self._tracked.get(key):
766
766
  try:
767
- self._trackingCallback(experiment=experiment, result=result)
767
+ self._trackingCallback(experiment=experiment, result=result, user_context=user_context)
768
768
  self._tracked[key] = True
769
769
  except Exception:
770
770
  pass
@@ -317,6 +317,10 @@ class GrowthBookClient:
317
317
  }
318
318
  self._sticky_bucket_cache_lock = False
319
319
 
320
+ # Plugin support
321
+ self._tracking_plugins: List[Any] = self.options.tracking_plugins or []
322
+ self._initialized_plugins: List[Any] = []
323
+
320
324
  self._features_repository = (
321
325
  EnhancedFeatureRepository(
322
326
  self.options.api_host or "https://cdn.growthbook.io",
@@ -330,9 +334,12 @@ class GrowthBookClient:
330
334
 
331
335
  self._global_context: Optional[GlobalContext] = None
332
336
  self._context_lock = asyncio.Lock()
337
+
338
+ # Initialize plugins
339
+ self._initialize_plugins()
333
340
 
334
341
 
335
- def _track(self, experiment: Experiment, result: Result) -> None:
342
+ def _track(self, experiment: Experiment, result: Result, user_context: UserContext) -> None:
336
343
  """Thread-safe tracking implementation"""
337
344
  if not self.options.on_experiment_viewed:
338
345
  return
@@ -348,7 +355,7 @@ class GrowthBookClient:
348
355
  with self._tracked_lock:
349
356
  if not self._tracked.get(key):
350
357
  try:
351
- self.options.on_experiment_viewed(experiment=experiment, result=result)
358
+ self.options.on_experiment_viewed(experiment, result, user_context)
352
359
  self._tracked[key] = True
353
360
  except Exception:
354
361
  logger.exception("Error in tracking callback")
@@ -492,25 +499,25 @@ class GrowthBookClient:
492
499
  """Evaluate a feature with proper async context management"""
493
500
  async with self._context_lock:
494
501
  context = await self.create_evaluation_context(user_context)
495
- result = core_eval_feature(key=key, evalContext=context)
502
+ result = core_eval_feature(key=key, evalContext=context, tracking_cb=self._track)
496
503
  return result
497
504
 
498
505
  async def is_on(self, key: str, user_context: UserContext) -> bool:
499
506
  """Check if a feature is enabled with proper async context management"""
500
507
  async with self._context_lock:
501
508
  context = await self.create_evaluation_context(user_context)
502
- return core_eval_feature(key=key, evalContext=context).on
509
+ return core_eval_feature(key=key, evalContext=context, tracking_cb=self._track).on
503
510
 
504
511
  async def is_off(self, key: str, user_context: UserContext) -> bool:
505
512
  """Check if a feature is set to off with proper async context management"""
506
513
  async with self._context_lock:
507
514
  context = await self.create_evaluation_context(user_context)
508
- return core_eval_feature(key=key, evalContext=context).off
515
+ return core_eval_feature(key=key, evalContext=context, tracking_cb=self._track).off
509
516
 
510
517
  async def get_feature_value(self, key: str, fallback: Any, user_context: UserContext) -> Any:
511
518
  async with self._context_lock:
512
519
  context = await self.create_evaluation_context(user_context)
513
- result = core_eval_feature(key=key, evalContext=context)
520
+ result = core_eval_feature(key=key, evalContext=context, tracking_cb=self._track)
514
521
  return result.value if result.value is not None else fallback
515
522
 
516
523
  async def run(self, experiment: Experiment, user_context: UserContext) -> Result:
@@ -539,4 +546,37 @@ class GrowthBookClient:
539
546
 
540
547
  # Clear context
541
548
  async with self._context_lock:
542
- self._global_context = None
549
+ self._global_context = None
550
+
551
+ # Cleanup plugins
552
+ self._cleanup_plugins()
553
+
554
+ def _initialize_plugins(self) -> None:
555
+ """Initialize all tracking plugins with this GrowthBookClient instance."""
556
+ for plugin in self._tracking_plugins:
557
+ try:
558
+ if hasattr(plugin, 'initialize'):
559
+ # Plugin is a class instance with initialize method
560
+ plugin.initialize(self)
561
+ self._initialized_plugins.append(plugin)
562
+ logger.debug(f"Initialized plugin: {plugin.__class__.__name__}")
563
+ elif callable(plugin):
564
+ # Plugin is a callable function
565
+ plugin(self)
566
+ self._initialized_plugins.append(plugin)
567
+ logger.debug(f"Initialized callable plugin: {plugin.__name__}")
568
+ else:
569
+ logger.warning(f"Plugin {plugin} is neither callable nor has initialize method")
570
+ except Exception as e:
571
+ logger.error(f"Failed to initialize plugin {plugin}: {e}")
572
+
573
+ def _cleanup_plugins(self) -> None:
574
+ """Cleanup all initialized plugins."""
575
+ for plugin in self._initialized_plugins:
576
+ try:
577
+ if hasattr(plugin, 'cleanup'):
578
+ plugin.cleanup()
579
+ logger.debug(f"Cleaned up plugin: {plugin.__class__.__name__}")
580
+ except Exception as e:
581
+ logger.error(f"Error cleaning up plugin {plugin}: {e}")
582
+ self._initialized_plugins.clear()
@@ -2,14 +2,16 @@ import json
2
2
  import logging
3
3
  import threading
4
4
  import time
5
- from typing import Dict, Any, Optional, List, Callable
5
+ from typing import Dict, Any, Optional, List, Callable, TYPE_CHECKING
6
6
  from .base import GrowthBookPlugin
7
7
 
8
- requests: Any = None
9
- try:
10
- import requests
11
- except ImportError:
12
- pass
8
+ if TYPE_CHECKING:
9
+ import requests # type: ignore
10
+ else:
11
+ try:
12
+ import requests # type: ignore
13
+ except ImportError:
14
+ requests = None
13
15
 
14
16
  logger = logging.getLogger("growthbook.plugins.growthbook_tracking")
15
17
 
@@ -88,22 +90,43 @@ class GrowthBookTrackingPlugin(GrowthBookPlugin):
88
90
  super().cleanup()
89
91
 
90
92
  def _setup_experiment_tracking(self, gb_instance) -> None:
91
- """Setup experiment tracking."""
92
- original_callback = getattr(gb_instance, '_trackingCallback', None)
93
+ """Setup experiment tracking for both legacy and async clients."""
93
94
 
94
- def tracking_wrapper(experiment, result):
95
+ def tracking_wrapper(experiment, result, user_context=None):
95
96
  # Track to ingestor
96
97
  self._track_experiment_viewed(experiment, result)
97
98
 
98
99
  # Call additional callback
99
100
  if self.additional_callback:
100
- self._safe_execute(self.additional_callback, experiment, result)
101
+ self._safe_execute(self.additional_callback, experiment, result, user_context)
102
+
103
+ # Check if it's the legacy GrowthBook client (has _trackingCallback)
104
+ if hasattr(gb_instance, '_trackingCallback'):
105
+ # Legacy GrowthBook client
106
+ original_callback = getattr(gb_instance, '_trackingCallback', None)
107
+
108
+ def legacy_wrapper(experiment, result, user_context=None):
109
+ tracking_wrapper(experiment, result, user_context)
110
+ # Call original callback
111
+ if original_callback:
112
+ self._safe_execute(original_callback, experiment, result, user_context)
113
+
114
+ gb_instance._trackingCallback = legacy_wrapper
115
+
116
+ elif hasattr(gb_instance, 'options') and hasattr(gb_instance.options, 'on_experiment_viewed'):
117
+ # New GrowthBookClient (async)
118
+ original_callback = gb_instance.options.on_experiment_viewed
119
+
120
+ def async_wrapper(experiment, result, user_context):
121
+ tracking_wrapper(experiment, result, user_context)
122
+ # Call original callback
123
+ if original_callback:
124
+ self._safe_execute(original_callback, experiment, result, user_context)
101
125
 
102
- # Call original callback
103
- if original_callback:
104
- self._safe_execute(original_callback, experiment, result)
126
+ gb_instance.options.on_experiment_viewed = async_wrapper
105
127
 
106
- gb_instance._trackingCallback = tracking_wrapper
128
+ else:
129
+ self.logger.warning("_trackingCallback or on_experiment_viewed properties not found - tracking may not work properly")
107
130
 
108
131
  def _setup_feature_tracking(self, gb_instance):
109
132
  """Setup feature evaluation tracking."""
@@ -77,6 +77,7 @@ class RequestContextPlugin(GrowthBookPlugin):
77
77
  self.client_side_attributes = client_side_attributes
78
78
  self.extract_utm = extract_utm
79
79
  self.extract_user_agent = extract_user_agent
80
+ self._extracted_attributes: Dict[str, Any] = {}
80
81
 
81
82
  def initialize(self, gb_instance) -> None:
82
83
  """Initialize plugin - extract attributes from request context."""
@@ -87,18 +88,33 @@ class RequestContextPlugin(GrowthBookPlugin):
87
88
  request_attributes = self._extract_all_attributes()
88
89
 
89
90
  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")
91
+ # Check client type and merge attributes accordingly
92
+ if hasattr(gb_instance, 'get_attributes') and hasattr(gb_instance, 'set_attributes'):
93
+ # Legacy GrowthBook client
94
+ current_attributes = gb_instance.get_attributes()
95
+ merged_attributes = {**request_attributes, **current_attributes}
96
+ gb_instance.set_attributes(merged_attributes)
97
+ self.logger.info(f"Extracted {len(request_attributes)} request attributes for legacy client")
98
+
99
+ elif hasattr(gb_instance, 'options'):
100
+ # New GrowthBookClient - store attributes for future use
101
+ # Note: GrowthBookClient doesn't have get/set_attributes, but we can store
102
+ # the extracted attributes for potential future use or logging
103
+ self._extracted_attributes = request_attributes
104
+ self.logger.info(f"Extracted {len(request_attributes)} request attributes for async client (stored for reference)")
105
+
106
+ else:
107
+ self.logger.warning("Unknown client type - cannot set attributes")
96
108
  else:
97
109
  self.logger.debug("No request context available")
98
110
 
99
111
  except Exception as e:
100
112
  self.logger.error(f"Failed to extract request attributes: {e}")
101
113
 
114
+ def get_extracted_attributes(self) -> Dict[str, Any]:
115
+ """Get the attributes extracted from request context."""
116
+ return self._extracted_attributes.copy()
117
+
102
118
  def _extract_all_attributes(self) -> Dict[str, Any]:
103
119
  """Extract all available attributes from request context."""
104
120
  attributes = {}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: growthbook
3
- Version: 1.3.1
3
+ Version: 1.4.0
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
@@ -0,0 +1,15 @@
1
+ growthbook/__init__.py,sha256=u5eO2zI3G12ffZMXFXGG0baoDkdDG6mEzVbqU9Kq9-Q,444
2
+ growthbook/common_types.py,sha256=OUGkqoUuYetWz1cyA1eWz5DM3awYw_ExcNAjFqJuGAc,14881
3
+ growthbook/core.py,sha256=n9nwna26iZTY48LIvQqu5N_RrE35X0wlRBhq0-Qdb-s,35241
4
+ growthbook/growthbook.py,sha256=9YXfqyWYBj1Hh5GoPN-1Db3CApUhpRfG6at1yhiNQUw,30727
5
+ growthbook/growthbook_client.py,sha256=1bDIuJoxlKUR_bKe_gD6V7JlUPt53uGgix9DhgSkPPc,23360
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=FvPFOuKF_xKjmTX8x_hzMlHrrL-68Y2ZPw1Hfl2_ilQ,11333
10
+ growthbook/plugins/request_context.py,sha256=O5FJDrjJR5u0rx3ENGO9cOsKMHd9e0l0Nvdb1PHfmm8,12951
11
+ growthbook-1.4.0.dist-info/licenses/LICENSE,sha256=D-TcBckB0dTPUlNJ8jBiTIJIj1ekHLB1CY7HJtJKhMY,1069
12
+ growthbook-1.4.0.dist-info/METADATA,sha256=vwbaRVbHMEoqz1Elxrt_n3gKYCabg1spRJCtCviobLI,22073
13
+ growthbook-1.4.0.dist-info/WHEEL,sha256=JNWh1Fm1UdwIQV075glCn4MVuCRs0sotJIq-J6rbxCU,109
14
+ growthbook-1.4.0.dist-info/top_level.txt,sha256=dzfRQFGYejCIUstRSrrRVTMlxf7pBqASTI5S8gGRlXw,11
15
+ growthbook-1.4.0.dist-info/RECORD,,
@@ -1,15 +0,0 @@
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,,