growthbook 2.1.2__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 +22 -0
- growthbook/common_types.py +446 -0
- growthbook/core.py +985 -0
- growthbook/growthbook.py +1175 -0
- growthbook/growthbook_client.py +661 -0
- growthbook/plugins/__init__.py +16 -0
- growthbook/plugins/base.py +103 -0
- growthbook/plugins/growthbook_tracking.py +285 -0
- growthbook/plugins/request_context.py +358 -0
- growthbook/py.typed +0 -0
- growthbook-2.1.2.dist-info/METADATA +700 -0
- growthbook-2.1.2.dist-info/RECORD +15 -0
- growthbook-2.1.2.dist-info/WHEEL +6 -0
- growthbook-2.1.2.dist-info/licenses/LICENSE +22 -0
- growthbook-2.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,358 @@
|
|
|
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
|
+
self._extracted_attributes: Dict[str, Any] = {}
|
|
81
|
+
|
|
82
|
+
def initialize(self, gb_instance) -> None:
|
|
83
|
+
"""Initialize plugin - extract attributes from request context."""
|
|
84
|
+
try:
|
|
85
|
+
self._set_initialized(gb_instance)
|
|
86
|
+
|
|
87
|
+
# Get request data from various sources
|
|
88
|
+
request_attributes = self._extract_all_attributes()
|
|
89
|
+
|
|
90
|
+
if 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")
|
|
108
|
+
else:
|
|
109
|
+
self.logger.debug("No request context available")
|
|
110
|
+
|
|
111
|
+
except Exception as e:
|
|
112
|
+
self.logger.error(f"Failed to extract request attributes: {e}")
|
|
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
|
+
|
|
118
|
+
def _extract_all_attributes(self) -> Dict[str, Any]:
|
|
119
|
+
"""Extract all available attributes from request context."""
|
|
120
|
+
attributes = {}
|
|
121
|
+
|
|
122
|
+
# Get request object/data
|
|
123
|
+
request_data = self._get_request_data()
|
|
124
|
+
|
|
125
|
+
if request_data:
|
|
126
|
+
# Extract core request info
|
|
127
|
+
attributes.update(self._extract_basic_info(request_data))
|
|
128
|
+
|
|
129
|
+
# Extract UTM parameters
|
|
130
|
+
if self.extract_utm:
|
|
131
|
+
attributes.update(self._extract_utm_params(request_data))
|
|
132
|
+
|
|
133
|
+
# Extract User-Agent info
|
|
134
|
+
if self.extract_user_agent:
|
|
135
|
+
attributes.update(self._extract_user_agent(request_data))
|
|
136
|
+
|
|
137
|
+
# Add client-side attributes (these override auto-detected)
|
|
138
|
+
if self.client_side_attributes:
|
|
139
|
+
attributes.update(self.client_side_attributes.to_dict())
|
|
140
|
+
|
|
141
|
+
# Add server context
|
|
142
|
+
attributes.update({
|
|
143
|
+
'server_timestamp': int(time.time()),
|
|
144
|
+
'request_id': str(uuid.uuid4())[:8],
|
|
145
|
+
'sdk_context': 'server'
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
return attributes
|
|
149
|
+
|
|
150
|
+
def _get_request_data(self) -> Optional[Dict[str, Any]]:
|
|
151
|
+
"""Get request data from various sources."""
|
|
152
|
+
# 1. Try custom extractor
|
|
153
|
+
if self.request_extractor:
|
|
154
|
+
try:
|
|
155
|
+
request_obj = self.request_extractor()
|
|
156
|
+
if request_obj:
|
|
157
|
+
return self._normalize_request_object(request_obj)
|
|
158
|
+
except Exception as e:
|
|
159
|
+
self.logger.debug(f"Custom extractor failed: {e}")
|
|
160
|
+
|
|
161
|
+
# 2. Try global context
|
|
162
|
+
if _current_request_context:
|
|
163
|
+
return _current_request_context.copy()
|
|
164
|
+
|
|
165
|
+
# 3. Try thread-local storage
|
|
166
|
+
import threading
|
|
167
|
+
from typing import cast
|
|
168
|
+
thread_local = getattr(threading.current_thread(), 'gb_request_context', None)
|
|
169
|
+
if thread_local:
|
|
170
|
+
return cast(Dict[str, Any], thread_local)
|
|
171
|
+
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
def _normalize_request_object(self, request_obj) -> Dict[str, Any]:
|
|
175
|
+
"""Convert various request objects to normalized dict."""
|
|
176
|
+
normalized = {}
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
# Extract URL
|
|
180
|
+
url = None
|
|
181
|
+
if hasattr(request_obj, 'build_absolute_uri'): # Django
|
|
182
|
+
url = request_obj.build_absolute_uri()
|
|
183
|
+
elif hasattr(request_obj, 'url'): # Flask/FastAPI
|
|
184
|
+
url = str(request_obj.url)
|
|
185
|
+
|
|
186
|
+
if url:
|
|
187
|
+
normalized['url'] = url
|
|
188
|
+
parsed = urlparse(url)
|
|
189
|
+
normalized['path'] = parsed.path
|
|
190
|
+
normalized['host'] = parsed.netloc
|
|
191
|
+
normalized['query_string'] = parsed.query
|
|
192
|
+
|
|
193
|
+
# Extract query parameters
|
|
194
|
+
query_params = {}
|
|
195
|
+
if hasattr(request_obj, 'GET'): # Django
|
|
196
|
+
query_params = dict(request_obj.GET)
|
|
197
|
+
elif hasattr(request_obj, 'args'): # Flask
|
|
198
|
+
query_params = dict(request_obj.args)
|
|
199
|
+
elif hasattr(request_obj, 'query_params'): # FastAPI
|
|
200
|
+
query_params = dict(request_obj.query_params)
|
|
201
|
+
|
|
202
|
+
normalized['query_params'] = query_params
|
|
203
|
+
|
|
204
|
+
# Extract User-Agent
|
|
205
|
+
user_agent = None
|
|
206
|
+
if hasattr(request_obj, 'META'): # Django
|
|
207
|
+
user_agent = request_obj.META.get('HTTP_USER_AGENT')
|
|
208
|
+
elif hasattr(request_obj, 'headers'): # Flask/FastAPI
|
|
209
|
+
user_agent = request_obj.headers.get('user-agent') or request_obj.headers.get('User-Agent')
|
|
210
|
+
|
|
211
|
+
if user_agent:
|
|
212
|
+
normalized['user_agent'] = user_agent
|
|
213
|
+
|
|
214
|
+
# Extract user info (if available)
|
|
215
|
+
if hasattr(request_obj, 'user') and hasattr(request_obj.user, 'id'):
|
|
216
|
+
if getattr(request_obj.user, 'is_authenticated', True):
|
|
217
|
+
normalized['user_id'] = str(request_obj.user.id)
|
|
218
|
+
|
|
219
|
+
except Exception as e:
|
|
220
|
+
self.logger.debug(f"Error normalizing request object: {e}")
|
|
221
|
+
|
|
222
|
+
return normalized
|
|
223
|
+
|
|
224
|
+
def _extract_basic_info(self, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
225
|
+
"""Extract basic request information."""
|
|
226
|
+
info = {}
|
|
227
|
+
|
|
228
|
+
if 'url' in request_data:
|
|
229
|
+
info['url'] = request_data['url']
|
|
230
|
+
if 'path' in request_data:
|
|
231
|
+
info['path'] = request_data['path']
|
|
232
|
+
if 'host' in request_data:
|
|
233
|
+
info['host'] = request_data['host']
|
|
234
|
+
if 'user_id' in request_data:
|
|
235
|
+
info['id'] = request_data['user_id']
|
|
236
|
+
|
|
237
|
+
return info
|
|
238
|
+
|
|
239
|
+
def _extract_utm_params(self, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
240
|
+
"""Extract UTM parameters from query string."""
|
|
241
|
+
utm_params = {}
|
|
242
|
+
query_params = request_data.get('query_params', {})
|
|
243
|
+
|
|
244
|
+
utm_mappings = {
|
|
245
|
+
'utm_source': 'utmSource',
|
|
246
|
+
'utm_medium': 'utmMedium',
|
|
247
|
+
'utm_campaign': 'utmCampaign',
|
|
248
|
+
'utm_term': 'utmTerm',
|
|
249
|
+
'utm_content': 'utmContent'
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for param, attr_name in utm_mappings.items():
|
|
253
|
+
value = query_params.get(param)
|
|
254
|
+
if value:
|
|
255
|
+
utm_params[attr_name] = value
|
|
256
|
+
|
|
257
|
+
return utm_params
|
|
258
|
+
|
|
259
|
+
def _extract_user_agent(self, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
260
|
+
"""Extract browser and device info from User-Agent."""
|
|
261
|
+
user_agent = request_data.get('user_agent')
|
|
262
|
+
if not user_agent:
|
|
263
|
+
return {}
|
|
264
|
+
|
|
265
|
+
ua_lower = user_agent.lower()
|
|
266
|
+
info = {'userAgent': user_agent}
|
|
267
|
+
|
|
268
|
+
# Simple browser detection
|
|
269
|
+
if 'edge' in ua_lower or 'edg' in ua_lower:
|
|
270
|
+
info['browser'] = 'edge'
|
|
271
|
+
elif 'chrome' in ua_lower:
|
|
272
|
+
info['browser'] = 'chrome'
|
|
273
|
+
elif 'firefox' in ua_lower:
|
|
274
|
+
info['browser'] = 'firefox'
|
|
275
|
+
elif 'safari' in ua_lower:
|
|
276
|
+
info['browser'] = 'safari'
|
|
277
|
+
else:
|
|
278
|
+
info['browser'] = 'unknown'
|
|
279
|
+
|
|
280
|
+
# Simple device detection
|
|
281
|
+
mobile_indicators = ['mobile', 'android', 'iphone', 'ipad']
|
|
282
|
+
if any(indicator in ua_lower for indicator in mobile_indicators):
|
|
283
|
+
info['deviceType'] = 'mobile'
|
|
284
|
+
else:
|
|
285
|
+
info['deviceType'] = 'desktop'
|
|
286
|
+
|
|
287
|
+
return info
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# Framework-agnostic helper functions
|
|
291
|
+
def set_request_context(request_data: Union[Dict[str, Any], Any]) -> None:
|
|
292
|
+
"""
|
|
293
|
+
Set request context globally for the current thread.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
request_data: Either a dict of request data or a request object to normalize
|
|
297
|
+
"""
|
|
298
|
+
global _current_request_context
|
|
299
|
+
|
|
300
|
+
if isinstance(request_data, dict):
|
|
301
|
+
_current_request_context = request_data
|
|
302
|
+
else:
|
|
303
|
+
# Try to normalize request object
|
|
304
|
+
plugin = RequestContextPlugin()
|
|
305
|
+
_current_request_context = plugin._normalize_request_object(request_data)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def clear_request_context() -> None:
|
|
309
|
+
"""Clear the global request context."""
|
|
310
|
+
global _current_request_context
|
|
311
|
+
_current_request_context = {}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# Convenience functions
|
|
315
|
+
def request_context_plugin(**options) -> RequestContextPlugin:
|
|
316
|
+
"""
|
|
317
|
+
Create a request context plugin.
|
|
318
|
+
|
|
319
|
+
Usage examples:
|
|
320
|
+
|
|
321
|
+
# 1. With middleware setting global context:
|
|
322
|
+
set_request_context(request)
|
|
323
|
+
gb = GrowthBook(plugins=[request_context_plugin()])
|
|
324
|
+
|
|
325
|
+
# 2. With custom extractor:
|
|
326
|
+
def get_current_request():
|
|
327
|
+
return my_framework.get_current_request()
|
|
328
|
+
|
|
329
|
+
gb = GrowthBook(plugins=[
|
|
330
|
+
request_context_plugin(request_extractor=get_current_request)
|
|
331
|
+
])
|
|
332
|
+
|
|
333
|
+
# 3. With client-side attributes:
|
|
334
|
+
gb = GrowthBook(plugins=[
|
|
335
|
+
request_context_plugin(
|
|
336
|
+
client_side_attributes=client_side_attributes(
|
|
337
|
+
pageTitle="Dashboard",
|
|
338
|
+
deviceType="mobile"
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
])
|
|
342
|
+
"""
|
|
343
|
+
return RequestContextPlugin(**options)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def client_side_attributes(**kwargs) -> ClientSideAttributes:
|
|
347
|
+
"""
|
|
348
|
+
Create client-side attributes.
|
|
349
|
+
|
|
350
|
+
Usage:
|
|
351
|
+
attrs = client_side_attributes(
|
|
352
|
+
pageTitle="My Page",
|
|
353
|
+
deviceType="mobile",
|
|
354
|
+
browser="chrome",
|
|
355
|
+
customField="value"
|
|
356
|
+
)
|
|
357
|
+
"""
|
|
358
|
+
return ClientSideAttributes(**kwargs)
|
growthbook/py.typed
ADDED
|
File without changes
|