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.
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/CHANGELOG.md +66 -0
- {optimizely_sdk-5.1.0/optimizely_sdk.egg-info → optimizely_sdk-5.3.0}/PKG-INFO +86 -8
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/bucketer.py +86 -16
- optimizely_sdk-5.3.0/optimizely/cmab/__init__.py +12 -0
- optimizely_sdk-5.3.0/optimizely/cmab/cmab_client.py +198 -0
- optimizely_sdk-5.3.0/optimizely/cmab/cmab_service.py +175 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/config_manager.py +29 -7
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/decision/optimizely_decide_option.py +4 -1
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/decision/optimizely_decision.py +20 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/decision/optimizely_decision_message.py +1 -1
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/decision_service.py +454 -53
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/entities.py +4 -2
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/event/event_factory.py +13 -4
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/event/event_processor.py +3 -4
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/event/log_event.py +1 -1
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/event/payload.py +4 -1
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/event/user_event.py +6 -3
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/event/user_event_factory.py +13 -3
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/event_builder.py +16 -4
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/event_dispatcher.py +16 -4
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/exceptions.py +18 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/condition.py +1 -1
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/enums.py +6 -1
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/event_tag_utils.py +3 -3
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/sdk_settings.py +5 -1
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/types.py +20 -1
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/validator.py +3 -2
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/lib/pymmh3.py +1 -1
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/logger.py +1 -1
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/notification_center.py +1 -1
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/odp/lru_cache.py +6 -1
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/odp/odp_event_manager.py +1 -1
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/odp/optimizely_odp_option.py +1 -1
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/optimizely.py +153 -35
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/optimizely_factory.py +70 -2
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/optimizely_user_context.py +1 -1
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/project_config.py +156 -8
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/user_profile.py +1 -1
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/version.py +1 -1
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0/optimizely_sdk.egg-info}/PKG-INFO +86 -8
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely_sdk.egg-info/SOURCES.txt +3 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely_sdk.egg-info/requires.txt +12 -6
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/requirements/core.txt +1 -0
- optimizely_sdk-5.3.0/requirements/typing.txt +5 -0
- optimizely_sdk-5.1.0/requirements/typing.txt +0 -4
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/LICENSE +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/MANIFEST.in +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/README.md +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/__init__.py +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/decision/__init__.py +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/error_handler.py +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/event/__init__.py +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/__init__.py +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/audience.py +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/condition_tree_evaluator.py +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/constants.py +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/helpers/experiment.py +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/lib/__init__.py +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/notification_center_registry.py +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/odp/__init__.py +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/odp/odp_config.py +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/odp/odp_event.py +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/odp/odp_event_api_manager.py +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/odp/odp_manager.py +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/odp/odp_segment_api_manager.py +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/odp/odp_segment_manager.py +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/optimizely_config.py +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely/py.typed +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely_sdk.egg-info/dependency_links.txt +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/optimizely_sdk.egg-info/top_level.txt +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/requirements/docs.txt +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/requirements/test.txt +0 -0
- {optimizely_sdk-5.1.0 → optimizely_sdk-5.3.0}/setup.cfg +0 -0
- {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
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: optimizely-sdk
|
|
3
|
-
Version: 5.
|
|
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:
|
|
27
|
-
Requires-Dist:
|
|
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:
|
|
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:
|
|
35
|
-
Requires-Dist:
|
|
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
|
|
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
|
|
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 !=
|
|
145
|
-
message = f'User "{user_id}" is not in experiment "{
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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)
|