optimizely-sdk 5.1.0__tar.gz → 5.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/CHANGELOG.md +66 -0
  2. {optimizely_sdk-5.1.0/optimizely_sdk.egg-info → optimizely_sdk-5.3.0}/PKG-INFO +86 -8
  3. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/bucketer.py +86 -16
  4. optimizely_sdk-5.3.0/optimizely/cmab/__init__.py +12 -0
  5. optimizely_sdk-5.3.0/optimizely/cmab/cmab_client.py +198 -0
  6. optimizely_sdk-5.3.0/optimizely/cmab/cmab_service.py +175 -0
  7. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/config_manager.py +29 -7
  8. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/decision/optimizely_decide_option.py +4 -1
  9. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/decision/optimizely_decision.py +20 -0
  10. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/decision/optimizely_decision_message.py +1 -1
  11. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/decision_service.py +454 -53
  12. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/entities.py +4 -2
  13. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/event/event_factory.py +13 -4
  14. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/event/event_processor.py +3 -4
  15. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/event/log_event.py +1 -1
  16. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/event/payload.py +4 -1
  17. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/event/user_event.py +6 -3
  18. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/event/user_event_factory.py +13 -3
  19. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/event_builder.py +16 -4
  20. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/event_dispatcher.py +16 -4
  21. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/exceptions.py +18 -0
  22. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/condition.py +1 -1
  23. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/enums.py +6 -1
  24. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/event_tag_utils.py +3 -3
  25. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/sdk_settings.py +5 -1
  26. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/types.py +20 -1
  27. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/validator.py +3 -2
  28. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/lib/pymmh3.py +1 -1
  29. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/logger.py +1 -1
  30. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/notification_center.py +1 -1
  31. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/odp/lru_cache.py +6 -1
  32. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/odp/odp_event_manager.py +1 -1
  33. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/odp/optimizely_odp_option.py +1 -1
  34. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/optimizely.py +153 -35
  35. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/optimizely_factory.py +70 -2
  36. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/optimizely_user_context.py +1 -1
  37. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/project_config.py +156 -8
  38. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/user_profile.py +1 -1
  39. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/version.py +1 -1
  40. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0/optimizely_sdk.egg-info}/PKG-INFO +86 -8
  41. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely_sdk.egg-info/SOURCES.txt +3 -0
  42. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely_sdk.egg-info/requires.txt +12 -6
  43. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/requirements/core.txt +1 -0
  44. optimizely_sdk-5.3.0/requirements/typing.txt +5 -0
  45. optimizely_sdk-5.1.0/requirements/typing.txt +0 -4
  46. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/LICENSE +0 -0
  47. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/MANIFEST.in +0 -0
  48. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/README.md +0 -0
  49. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/__init__.py +0 -0
  50. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/decision/__init__.py +0 -0
  51. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/error_handler.py +0 -0
  52. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/event/__init__.py +0 -0
  53. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/__init__.py +0 -0
  54. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/audience.py +0 -0
  55. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/condition_tree_evaluator.py +0 -0
  56. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/constants.py +0 -0
  57. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/experiment.py +0 -0
  58. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/lib/__init__.py +0 -0
  59. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/notification_center_registry.py +0 -0
  60. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/odp/__init__.py +0 -0
  61. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/odp/odp_config.py +0 -0
  62. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/odp/odp_event.py +0 -0
  63. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/odp/odp_event_api_manager.py +0 -0
  64. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/odp/odp_manager.py +0 -0
  65. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/odp/odp_segment_api_manager.py +0 -0
  66. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/odp/odp_segment_manager.py +0 -0
  67. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/optimizely_config.py +0 -0
  68. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/py.typed +0 -0
  69. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely_sdk.egg-info/dependency_links.txt +0 -0
  70. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely_sdk.egg-info/top_level.txt +0 -0
  71. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/requirements/docs.txt +0 -0
  72. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/requirements/test.txt +0 -0
  73. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/setup.cfg +0 -0
  74. {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/setup.py +0 -0
@@ -1,5 +1,71 @@
1
1
  # Optimizely Python SDK Changelog
2
2
 
3
+ ## 5.3.0
4
+ November 13th, 2025
5
+
6
+ This release introduces **Contextual Multi-Armed Bandit (CMAB)** support in the Optimizely Python SDK along with several enhancements, bug fixes, and infrastructure improvements.
7
+
8
+ ---
9
+
10
+ ### New Features
11
+
12
+ #### **CMAB Support**
13
+
14
+ A major addition in this release is support for **Contextual Multi-Armed Bandit (CMAB)**, enabling adaptive experimentation based on contextual user data.
15
+
16
+ The CMAB feature includes:
17
+
18
+ - CMAB Client and Service implementations to communicate with CMAB APIs ([#453](https://github.com/optimizely/python-sdk/pull/453), [#455](https://github.com/optimizely/python-sdk/pull/455))
19
+ - Decision Service updates to handle CMAB-based decisioning ([#457](https://github.com/optimizely/python-sdk/pull/457))
20
+ - Impression event updates to include CMAB UUID ([#458](https://github.com/optimizely/python-sdk/pull/458))
21
+ - Cache system enhancements including a new `remove()` method for CMAB cache ([#454](https://github.com/optimizely/python-sdk/pull/454))
22
+ - Configuration options to customize CMAB cache parameters ([#463](https://github.com/optimizely/python-sdk/pull/463))
23
+ - Exposure of CMAB prediction endpoint for programmatic access ([#466](https://github.com/optimizely/python-sdk/pull/466))
24
+
25
+ These updates collectively enable Python SDK users to leverage machine learning–driven bandit optimization strategies within Optimizely Feature Experimentation.
26
+
27
+ ---
28
+
29
+ #### **Multi-Region Data Hosting**
30
+
31
+ - Added SDK support for multi-region data hosting, allowing projects to specify their data residency region ([#459](https://github.com/optimizely/python-sdk/pull/459)).
32
+
33
+ ---
34
+
35
+ ### Security Fixes
36
+
37
+ - Addressed a CSRF security warning by properly importing and registering `CSRFProtect`
38
+ ([#448](https://github.com/optimizely/python-sdk/pull/448), [#450](https://github.com/optimizely/python-sdk/pull/450), [#452](https://github.com/optimizely/python-sdk/pull/452)).
39
+
40
+ ---
41
+
42
+ ### Enhancements
43
+
44
+ - Updated project configuration to track CMAB properties ([#451](https://github.com/optimizely/python-sdk/pull/451)).
45
+ - Removed unused `testapp` folder from tests as part of maintenance cleanup ([#461](https://github.com/optimizely/python-sdk/pull/461)).
46
+
47
+ ---
48
+
49
+ ### Bug Fixes
50
+
51
+ - Fixed concurrency issues in CMAB service to ensure thread-safe cache and decision logic ([#462](https://github.com/optimizely/python-sdk/pull/462)).
52
+
53
+ ---
54
+
55
+ ### New Contributors
56
+
57
+ - [@esrakartalOpt](https://github.com/esrakartalOpt) made their first contribution in [#459](https://github.com/optimizely/python-sdk/pull/459).
58
+
59
+ ---
60
+
61
+
62
+ ## 5.2.0
63
+ February 26, 2025
64
+
65
+ Python threads have been named.
66
+
67
+ `PollingConfigManager` now has another optional parameter `retries` that will control how many times the SDK will attempt to get the datafile if the connection fails. Previously, the SDK would only try once. Now it defaults to maximum of three attempts. When sending event data, the SDK will attempt to send event data up to three times, where as before it would only attempt once.
68
+
3
69
  ## 5.1.0
4
70
  November 27th, 2024
5
71
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: optimizely-sdk
3
- Version: 5.1.0
3
+ Version: 5.3.0
4
4
  Summary: Python SDK for Optimizely Feature Experimentation, Optimizely Full Stack (legacy), and Optimizely Rollouts.
5
5
  Home-page: https://github.com/optimizely/python-sdk
6
6
  Author: Optimizely
@@ -22,17 +22,29 @@ Requires-Dist: jsonschema>=3.2.0
22
22
  Requires-Dist: pyrsistent>=0.16.0
23
23
  Requires-Dist: requests>=2.21
24
24
  Requires-Dist: idna>=2.10
25
+ Requires-Dist: rpds-py<0.20.0; python_version < "3.11"
25
26
  Provides-Extra: test
26
- Requires-Dist: pytest-cov; extra == "test"
27
- Requires-Dist: idna>=2.10; extra == "test"
27
+ Requires-Dist: flake8>=4.0.1; extra == "test"
28
+ Requires-Dist: python-coveralls; extra == "test"
28
29
  Requires-Dist: jsonschema>=3.2.0; extra == "test"
30
+ Requires-Dist: rpds-py<0.20.0; python_version < "3.11" and extra == "test"
29
31
  Requires-Dist: requests>=2.21; extra == "test"
30
- Requires-Dist: funcsigs>=0.4; extra == "test"
32
+ Requires-Dist: pyrsistent>=0.16.0; extra == "test"
33
+ Requires-Dist: idna>=2.10; extra == "test"
31
34
  Requires-Dist: pytest>=6.2.0; extra == "test"
32
- Requires-Dist: python-coveralls; extra == "test"
33
35
  Requires-Dist: coverage; extra == "test"
34
- Requires-Dist: flake8>=4.0.1; extra == "test"
35
- Requires-Dist: pyrsistent>=0.16.0; extra == "test"
36
+ Requires-Dist: funcsigs>=0.4; extra == "test"
37
+ Requires-Dist: pytest-cov; extra == "test"
38
+ Dynamic: author
39
+ Dynamic: author-email
40
+ Dynamic: classifier
41
+ Dynamic: description
42
+ Dynamic: description-content-type
43
+ Dynamic: home-page
44
+ Dynamic: license-file
45
+ Dynamic: provides-extra
46
+ Dynamic: requires-dist
47
+ Dynamic: summary
36
48
 
37
49
  Optimizely Feature Experimentation is A/B testing and feature management for product development teams. Experiment in any application. Make every feature on your roadmap an opportunity to learn. Learn more at https://www.optimizely.com/products/experiment/feature-experimentation/ or see our documentation at https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/welcome. # Optimizely Python SDK
38
50
 
@@ -292,6 +304,72 @@ idna (BSD 3-Clause License https://github.com/kjd/idna/blob/master/LICENSE.md)
292
304
  - Swift - https://github.com/optimizely/swift-sdk
293
305
  # Optimizely Python SDK Changelog
294
306
 
307
+ ## 5.3.0
308
+ November 13th, 2025
309
+
310
+ This release introduces **Contextual Multi-Armed Bandit (CMAB)** support in the Optimizely Python SDK along with several enhancements, bug fixes, and infrastructure improvements.
311
+
312
+ ---
313
+
314
+ ### New Features
315
+
316
+ #### **CMAB Support**
317
+
318
+ A major addition in this release is support for **Contextual Multi-Armed Bandit (CMAB)**, enabling adaptive experimentation based on contextual user data.
319
+
320
+ The CMAB feature includes:
321
+
322
+ - CMAB Client and Service implementations to communicate with CMAB APIs ([#453](https://github.com/optimizely/python-sdk/pull/453), [#455](https://github.com/optimizely/python-sdk/pull/455))
323
+ - Decision Service updates to handle CMAB-based decisioning ([#457](https://github.com/optimizely/python-sdk/pull/457))
324
+ - Impression event updates to include CMAB UUID ([#458](https://github.com/optimizely/python-sdk/pull/458))
325
+ - Cache system enhancements including a new `remove()` method for CMAB cache ([#454](https://github.com/optimizely/python-sdk/pull/454))
326
+ - Configuration options to customize CMAB cache parameters ([#463](https://github.com/optimizely/python-sdk/pull/463))
327
+ - Exposure of CMAB prediction endpoint for programmatic access ([#466](https://github.com/optimizely/python-sdk/pull/466))
328
+
329
+ These updates collectively enable Python SDK users to leverage machine learning–driven bandit optimization strategies within Optimizely Feature Experimentation.
330
+
331
+ ---
332
+
333
+ #### **Multi-Region Data Hosting**
334
+
335
+ - Added SDK support for multi-region data hosting, allowing projects to specify their data residency region ([#459](https://github.com/optimizely/python-sdk/pull/459)).
336
+
337
+ ---
338
+
339
+ ### Security Fixes
340
+
341
+ - Addressed a CSRF security warning by properly importing and registering `CSRFProtect`
342
+ ([#448](https://github.com/optimizely/python-sdk/pull/448), [#450](https://github.com/optimizely/python-sdk/pull/450), [#452](https://github.com/optimizely/python-sdk/pull/452)).
343
+
344
+ ---
345
+
346
+ ### Enhancements
347
+
348
+ - Updated project configuration to track CMAB properties ([#451](https://github.com/optimizely/python-sdk/pull/451)).
349
+ - Removed unused `testapp` folder from tests as part of maintenance cleanup ([#461](https://github.com/optimizely/python-sdk/pull/461)).
350
+
351
+ ---
352
+
353
+ ### Bug Fixes
354
+
355
+ - Fixed concurrency issues in CMAB service to ensure thread-safe cache and decision logic ([#462](https://github.com/optimizely/python-sdk/pull/462)).
356
+
357
+ ---
358
+
359
+ ### New Contributors
360
+
361
+ - [@esrakartalOpt](https://github.com/esrakartalOpt) made their first contribution in [#459](https://github.com/optimizely/python-sdk/pull/459).
362
+
363
+ ---
364
+
365
+
366
+ ## 5.2.0
367
+ February 26, 2025
368
+
369
+ Python threads have been named.
370
+
371
+ `PollingConfigManager` now has another optional parameter `retries` that will control how many times the SDK will attempt to get the datafile if the connection fails. Previously, the SDK would only try once. Now it defaults to maximum of three attempts. When sending event data, the SDK will attempt to send event data up to three times, where as before it would only attempt once.
372
+
295
373
  ## 5.1.0
296
374
  November 27th, 2024
297
375
 
@@ -22,7 +22,7 @@ from .lib import pymmh3 as mmh3
22
22
  if version_info < (3, 8):
23
23
  from typing_extensions import Final
24
24
  else:
25
- from typing import Final # type: ignore
25
+ from typing import Final
26
26
 
27
27
 
28
28
  if TYPE_CHECKING:
@@ -119,13 +119,82 @@ class Bucketer:
119
119
  and array of log messages representing decision making.
120
120
  */.
121
121
  """
122
+ # Check if experiment is None first
123
+ if not experiment:
124
+ message = 'Invalid entity key provided for bucketing. Returning nil.'
125
+ project_config.logger.debug(message)
126
+ return None, []
127
+
128
+ if isinstance(experiment, dict):
129
+ # This is a holdout dictionary
130
+ experiment_key = experiment.get('key', '')
131
+ experiment_id = experiment.get('id', '')
132
+ else:
133
+ # This is an Experiment object
134
+ experiment_key = experiment.key
135
+ experiment_id = experiment.id
136
+
137
+ if not experiment_key or not experiment_key.strip():
138
+ message = 'Invalid entity key provided for bucketing. Returning nil.'
139
+ project_config.logger.debug(message)
140
+ return None, []
141
+
142
+ variation_id, decide_reasons = self.bucket_to_entity_id(project_config, experiment, user_id, bucketing_id)
143
+ if variation_id:
144
+ if isinstance(experiment, dict):
145
+ # For holdouts, find the variation in the holdout's variations array
146
+ variations = experiment.get('variations', [])
147
+ variation = next((v for v in variations if v.get('id') == variation_id), None)
148
+ else:
149
+ # For experiments, use the existing method
150
+ variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
151
+ return variation, decide_reasons
152
+
153
+ # No variation found - log message for empty traffic range
154
+ message = 'Bucketed into an empty traffic range. Returning nil.'
155
+ project_config.logger.info(message)
156
+ decide_reasons.append(message)
157
+ return None, decide_reasons
158
+
159
+ def bucket_to_entity_id(
160
+ self, project_config: ProjectConfig,
161
+ experiment: Experiment, user_id: str, bucketing_id: str
162
+ ) -> tuple[Optional[str], list[str]]:
163
+ """
164
+ For a given experiment and bucketing ID determines variation ID to be shown to user.
165
+
166
+ Args:
167
+ project_config: Instance of ProjectConfig.
168
+ experiment: The experiment object (used for group/groupPolicy logic if needed).
169
+ user_id: The user ID string.
170
+ bucketing_id: The bucketing ID string for the user.
171
+
172
+ Returns:
173
+ Tuple of (entity_id or None, list of decide reasons).
174
+ """
122
175
  decide_reasons: list[str] = []
123
176
  if not experiment:
124
177
  return None, decide_reasons
125
178
 
179
+ # Handle both Experiment objects and holdout dictionaries
180
+ if isinstance(experiment, dict):
181
+ # This is a holdout dictionary - holdouts don't have groups
182
+ experiment_key = experiment.get('key', '')
183
+ experiment_id = experiment.get('id', '')
184
+ traffic_allocations = experiment.get('trafficAllocation', [])
185
+ has_cmab = False
186
+ group_policy = None
187
+ else:
188
+ # This is an Experiment object
189
+ experiment_key = experiment.key
190
+ experiment_id = experiment.id
191
+ traffic_allocations = experiment.trafficAllocation
192
+ has_cmab = bool(experiment.cmab)
193
+ group_policy = getattr(experiment, 'groupPolicy', None)
194
+
126
195
  # Determine if experiment is in a mutually exclusive group.
127
- # This will not affect evaluation of rollout rules.
128
- if experiment.groupPolicy in GROUP_POLICIES:
196
+ # This will not affect evaluation of rollout rules or holdouts.
197
+ if group_policy and group_policy in GROUP_POLICIES:
129
198
  group = project_config.get_group(experiment.groupId)
130
199
 
131
200
  if not group:
@@ -141,26 +210,27 @@ class Bucketer:
141
210
  decide_reasons.append(message)
142
211
  return None, decide_reasons
143
212
 
144
- if user_experiment_id != experiment.id:
145
- message = f'User "{user_id}" is not in experiment "{experiment.key}" of group {experiment.groupId}.'
213
+ if user_experiment_id != experiment_id:
214
+ message = f'User "{user_id}" is not in experiment "{experiment_key}" of group {experiment.groupId}.'
146
215
  project_config.logger.info(message)
147
216
  decide_reasons.append(message)
148
217
  return None, decide_reasons
149
218
 
150
- message = f'User "{user_id}" is in experiment {experiment.key} of group {experiment.groupId}.'
219
+ message = f'User "{user_id}" is in experiment {experiment_key} of group {experiment.groupId}.'
151
220
  project_config.logger.info(message)
152
221
  decide_reasons.append(message)
153
222
 
223
+ if has_cmab:
224
+ if experiment.cmab:
225
+ traffic_allocations = [
226
+ {
227
+ "entityId": "$",
228
+ "endOfRange": experiment.cmab['trafficAllocation']
229
+ }
230
+ ]
231
+
154
232
  # Bucket user if not in white-list and in group (if any)
155
233
  variation_id = self.find_bucket(project_config, bucketing_id,
156
- experiment.id, experiment.trafficAllocation)
157
- if variation_id:
158
- variation = project_config.get_variation_from_id_by_experiment_id(experiment.id, variation_id)
159
- return variation, decide_reasons
234
+ experiment_id, traffic_allocations)
160
235
 
161
- else:
162
- message = 'Bucketed into an empty traffic range. Returning nil.'
163
- project_config.logger.info(message)
164
- decide_reasons.append(message)
165
-
166
- return None, decide_reasons
236
+ return variation_id, decide_reasons
@@ -0,0 +1,12 @@
1
+ # Copyright 2025, Optimizely
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
@@ -0,0 +1,198 @@
1
+ # Copyright 2025 Optimizely
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+ import json
14
+ import time
15
+ import requests
16
+ import math
17
+ from typing import Dict, Any, Optional
18
+ from optimizely import logger as _logging
19
+ from optimizely.helpers.enums import Errors
20
+ from optimizely.exceptions import CmabFetchError, CmabInvalidResponseError
21
+
22
+ # Default constants for CMAB requests
23
+ DEFAULT_MAX_RETRIES = 1
24
+ DEFAULT_INITIAL_BACKOFF = 0.1 # in seconds (100 ms)
25
+ DEFAULT_MAX_BACKOFF = 10 # in seconds
26
+ DEFAULT_BACKOFF_MULTIPLIER = 2.0
27
+ MAX_WAIT_TIME = 10.0
28
+ DEFAULT_PREDICTION_ENDPOINT = "https://prediction.cmab.optimizely.com/predict/{}"
29
+
30
+
31
+ class CmabRetryConfig:
32
+ """Configuration for retrying CMAB requests.
33
+
34
+ Contains parameters for maximum retries, backoff intervals, and multipliers.
35
+ """
36
+ def __init__(
37
+ self,
38
+ max_retries: int = DEFAULT_MAX_RETRIES,
39
+ initial_backoff: float = DEFAULT_INITIAL_BACKOFF,
40
+ max_backoff: float = DEFAULT_MAX_BACKOFF,
41
+ backoff_multiplier: float = DEFAULT_BACKOFF_MULTIPLIER,
42
+ ):
43
+ self.max_retries = max_retries
44
+ self.initial_backoff = initial_backoff
45
+ self.max_backoff = max_backoff
46
+ self.backoff_multiplier = backoff_multiplier
47
+
48
+
49
+ class DefaultCmabClient:
50
+ """Client for interacting with the CMAB service.
51
+
52
+ Provides methods to fetch decisions with optional retry logic.
53
+ """
54
+ def __init__(self, http_client: Optional[requests.Session] = None,
55
+ retry_config: Optional[CmabRetryConfig] = None,
56
+ logger: Optional[_logging.Logger] = None,
57
+ prediction_endpoint: Optional[str] = None):
58
+ """Initialize the CMAB client.
59
+
60
+ Args:
61
+ http_client (Optional[requests.Session]): HTTP client for making requests.
62
+ retry_config (Optional[CmabRetryConfig]): Configuration for retry logic.
63
+ logger (Optional[_logging.Logger]): Logger for logging messages.
64
+ prediction_endpoint (Optional[str]): Custom prediction endpoint URL template.
65
+ Use {} as placeholder for rule_id.
66
+ """
67
+ self.http_client = http_client or requests.Session()
68
+ self.retry_config = retry_config
69
+ self.logger = _logging.adapt_logger(logger or _logging.NoOpLogger())
70
+ self.prediction_endpoint = prediction_endpoint or DEFAULT_PREDICTION_ENDPOINT
71
+
72
+ def fetch_decision(
73
+ self,
74
+ rule_id: str,
75
+ user_id: str,
76
+ attributes: Dict[str, Any],
77
+ cmab_uuid: str,
78
+ timeout: float = MAX_WAIT_TIME
79
+ ) -> str:
80
+ """Fetch a decision from the CMAB prediction service.
81
+
82
+ Args:
83
+ rule_id (str): The rule ID for the experiment.
84
+ user_id (str): The user ID for the request.
85
+ attributes (Dict[str, Any]): User attributes for the request.
86
+ cmab_uuid (str): Unique identifier for the CMAB request.
87
+ timeout (float): Maximum wait time for request to respond in seconds. Defaults to 10 seconds.
88
+
89
+ Returns:
90
+ str: The variation ID.
91
+ """
92
+ url = self.prediction_endpoint.format(rule_id)
93
+ cmab_attributes = [
94
+ {"id": key, "value": value, "type": "custom_attribute"}
95
+ for key, value in attributes.items()
96
+ ]
97
+
98
+ request_body = {
99
+ "instances": [{
100
+ "visitorId": user_id,
101
+ "experimentId": rule_id,
102
+ "attributes": cmab_attributes,
103
+ "cmabUUID": cmab_uuid,
104
+ }]
105
+ }
106
+ if self.retry_config:
107
+ variation_id = self._do_fetch_with_retry(url, request_body, self.retry_config, timeout)
108
+ else:
109
+ variation_id = self._do_fetch(url, request_body, timeout)
110
+ return variation_id
111
+
112
+ def _do_fetch(self, url: str, request_body: Dict[str, Any], timeout: float) -> str:
113
+ """Perform a single fetch request to the CMAB prediction service.
114
+
115
+ Args:
116
+ url (str): The endpoint URL.
117
+ request_body (Dict[str, Any]): The request payload.
118
+ timeout (float): Maximum wait time for request to respond in seconds.
119
+ Returns:
120
+ str: The variation ID
121
+ """
122
+ headers = {'Content-Type': 'application/json'}
123
+ try:
124
+ response = self.http_client.post(url, data=json.dumps(request_body), headers=headers, timeout=timeout)
125
+ except requests.exceptions.RequestException as e:
126
+ error_message = Errors.CMAB_FETCH_FAILED.format(str(e))
127
+ self.logger.error(error_message)
128
+ raise CmabFetchError(error_message)
129
+
130
+ if not 200 <= response.status_code < 300:
131
+ error_message = Errors.CMAB_FETCH_FAILED.format(str(response.status_code))
132
+ self.logger.error(error_message)
133
+ raise CmabFetchError(error_message)
134
+
135
+ try:
136
+ body = response.json()
137
+ except json.JSONDecodeError:
138
+ error_message = Errors.INVALID_CMAB_FETCH_RESPONSE
139
+ self.logger.error(error_message)
140
+ raise CmabInvalidResponseError(error_message)
141
+
142
+ if not self.validate_response(body):
143
+ error_message = Errors.INVALID_CMAB_FETCH_RESPONSE
144
+ self.logger.error(error_message)
145
+ raise CmabInvalidResponseError(error_message)
146
+
147
+ return str(body['predictions'][0]['variation_id'])
148
+
149
+ def validate_response(self, body: Dict[str, Any]) -> bool:
150
+ """Validate the response structure from the CMAB service.
151
+
152
+ Args:
153
+ body (Dict[str, Any]): The response body to validate.
154
+
155
+ Returns:
156
+ bool: True if the response is valid, False otherwise.
157
+ """
158
+ return (
159
+ isinstance(body, dict) and
160
+ 'predictions' in body and
161
+ isinstance(body['predictions'], list) and
162
+ len(body['predictions']) > 0 and
163
+ isinstance(body['predictions'][0], dict) and
164
+ "variation_id" in body["predictions"][0]
165
+ )
166
+
167
+ def _do_fetch_with_retry(
168
+ self,
169
+ url: str,
170
+ request_body: Dict[str, Any],
171
+ retry_config: CmabRetryConfig,
172
+ timeout: float
173
+ ) -> str:
174
+ """Perform a fetch request with retry logic.
175
+
176
+ Args:
177
+ url (str): The endpoint URL.
178
+ request_body (Dict[str, Any]): The request payload.
179
+ retry_config (CmabRetryConfig): Configuration for retry logic.
180
+ timeout (float): Maximum wait time for request to respond in seconds.
181
+ Returns:
182
+ str: The variation ID
183
+ """
184
+ backoff = retry_config.initial_backoff
185
+ for attempt in range(retry_config.max_retries + 1):
186
+ try:
187
+ variation_id = self._do_fetch(url, request_body, timeout)
188
+ return variation_id
189
+ except:
190
+ if attempt < retry_config.max_retries:
191
+ self.logger.info(f"Retrying CMAB request (attempt: {attempt + 1}) after {backoff} seconds...")
192
+ time.sleep(backoff)
193
+ backoff = min(backoff * math.pow(retry_config.backoff_multiplier, attempt + 1),
194
+ retry_config.max_backoff)
195
+
196
+ error_message = Errors.CMAB_FETCH_FAILED.format('Exhausted all retries for CMAB request.')
197
+ self.logger.error(error_message)
198
+ raise CmabFetchError(error_message)