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.
@@ -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