optimizely-sdk 5.5.0__tar.gz → 5.7.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.5.0 → optimizely_sdk-5.7.0}/CHANGELOG.md +32 -0
- {optimizely_sdk-5.5.0/optimizely_sdk.egg-info → optimizely_sdk-5.7.0}/PKG-INFO +39 -7
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/cmab/cmab_service.py +1 -1
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/decision_service.py +89 -21
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/entities.py +16 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/event/event_factory.py +11 -3
- optimizely_sdk-5.7.0/optimizely/event/event_id_normalizer.py +93 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/event/payload.py +8 -2
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/event_builder.py +12 -6
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/types.py +3 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/odp/odp_event_manager.py +23 -2
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/odp/odp_manager.py +2 -2
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/optimizely.py +28 -4
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/optimizely_user_context.py +3 -1
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/project_config.py +69 -18
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/version.py +1 -1
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0/optimizely_sdk.egg-info}/PKG-INFO +39 -7
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely_sdk.egg-info/SOURCES.txt +1 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/LICENSE +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/MANIFEST.in +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/README.md +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/__init__.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/bucketer.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/cmab/__init__.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/cmab/cmab_client.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/config_manager.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/decision/__init__.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/decision/optimizely_decide_option.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/decision/optimizely_decision.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/decision/optimizely_decision_message.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/error_handler.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/event/__init__.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/event/event_processor.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/event/log_event.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/event/user_event.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/event/user_event_factory.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/event_dispatcher.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/exceptions.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/__init__.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/audience.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/condition.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/condition_tree_evaluator.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/constants.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/enums.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/event_tag_utils.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/experiment.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/sdk_settings.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/validator.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/lib/__init__.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/lib/pymmh3.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/logger.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/notification_center.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/notification_center_registry.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/odp/__init__.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/odp/lru_cache.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/odp/odp_config.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/odp/odp_event.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/odp/odp_event_api_manager.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/odp/odp_segment_api_manager.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/odp/odp_segment_manager.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/odp/optimizely_odp_option.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/optimizely_config.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/optimizely_factory.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/py.typed +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/user_profile.py +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely_sdk.egg-info/dependency_links.txt +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely_sdk.egg-info/requires.txt +5 -5
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely_sdk.egg-info/top_level.txt +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/requirements/core.txt +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/requirements/docs.txt +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/requirements/test.txt +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/requirements/typing.txt +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/setup.cfg +0 -0
- {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/setup.py +0 -0
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Optimizely Python SDK Changelog
|
|
2
2
|
|
|
3
|
+
## 5.7.0
|
|
4
|
+
September 2nd, 2026
|
|
5
|
+
|
|
6
|
+
### New Features
|
|
7
|
+
|
|
8
|
+
**Holdout Targeted Delivery Exclusion**: Holdouts can now be configured to exclude Targeted Delivery rules, so users held out of experiments still receive their Targeted Delivery experiences. This gives you a cleaner measure of experiment impact without holding users back from ongoing rollouts.
|
|
9
|
+
|
|
10
|
+
- Add holdout exclusion logic for Targeted Delivery rules ([#522](https://github.com/optimizely/python-sdk/pull/522))
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 5.6.0
|
|
15
|
+
July 10th, 2026
|
|
16
|
+
|
|
17
|
+
### New Features
|
|
18
|
+
|
|
19
|
+
#### **Local Holdouts Support**
|
|
20
|
+
|
|
21
|
+
- Added local holdouts support to the Python SDK, enabling flag-level traffic exclusion from experiments. ([#512](https://github.com/optimizely/python-sdk/pull/512))
|
|
22
|
+
- Added `localHoldouts` datafile section parsing for backward compatibility with older datafile formats. ([#517](https://github.com/optimizely/python-sdk/pull/517))
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
### Bug Fixes
|
|
27
|
+
|
|
28
|
+
- Fixed invalid IDs in holdout decision events by normalizing `campaign_id`, `variation_id`, and `entity_id` fields. ([#518](https://github.com/optimizely/python-sdk/pull/518))
|
|
29
|
+
- Fixed CMAB prediction requests to use attribute ID instead of attribute key. ([#520](https://github.com/optimizely/python-sdk/pull/520))
|
|
30
|
+
- Fixed ODP identify events being sent with a single identifier, which is a no-op. ([#513](https://github.com/optimizely/python-sdk/pull/513))
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
|
|
3
35
|
## 5.5.0
|
|
4
36
|
April 30th, 2026
|
|
5
37
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: optimizely-sdk
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.7.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
|
|
@@ -24,17 +24,17 @@ Requires-Dist: requests>=2.21
|
|
|
24
24
|
Requires-Dist: idna>=2.10
|
|
25
25
|
Requires-Dist: rpds-py<0.20.0; python_version < "3.11"
|
|
26
26
|
Provides-Extra: test
|
|
27
|
-
Requires-Dist:
|
|
27
|
+
Requires-Dist: funcsigs>=0.4; extra == "test"
|
|
28
28
|
Requires-Dist: idna>=2.10; extra == "test"
|
|
29
29
|
Requires-Dist: ruff>=0.15.0; extra == "test"
|
|
30
|
-
Requires-Dist:
|
|
31
|
-
Requires-Dist: coverage; extra == "test"
|
|
32
|
-
Requires-Dist: pytest-cov; extra == "test"
|
|
30
|
+
Requires-Dist: pyrsistent>=0.16.0; extra == "test"
|
|
33
31
|
Requires-Dist: rpds-py<0.20.0; python_version < "3.11" and extra == "test"
|
|
32
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
33
|
+
Requires-Dist: requests>=2.21; extra == "test"
|
|
34
|
+
Requires-Dist: coverage; extra == "test"
|
|
35
|
+
Requires-Dist: python-coveralls; extra == "test"
|
|
34
36
|
Requires-Dist: jsonschema>=3.2.0; extra == "test"
|
|
35
|
-
Requires-Dist: funcsigs>=0.4; extra == "test"
|
|
36
37
|
Requires-Dist: pytest>=6.2.0; extra == "test"
|
|
37
|
-
Requires-Dist: pyrsistent>=0.16.0; extra == "test"
|
|
38
38
|
Dynamic: author
|
|
39
39
|
Dynamic: author-email
|
|
40
40
|
Dynamic: classifier
|
|
@@ -304,6 +304,38 @@ idna (BSD 3-Clause License https://github.com/kjd/idna/blob/master/LICENSE.md)
|
|
|
304
304
|
- Swift - https://github.com/optimizely/swift-sdk
|
|
305
305
|
# Optimizely Python SDK Changelog
|
|
306
306
|
|
|
307
|
+
## 5.7.0
|
|
308
|
+
September 2nd, 2026
|
|
309
|
+
|
|
310
|
+
### New Features
|
|
311
|
+
|
|
312
|
+
**Holdout Targeted Delivery Exclusion**: Holdouts can now be configured to exclude Targeted Delivery rules, so users held out of experiments still receive their Targeted Delivery experiences. This gives you a cleaner measure of experiment impact without holding users back from ongoing rollouts.
|
|
313
|
+
|
|
314
|
+
- Add holdout exclusion logic for Targeted Delivery rules ([#522](https://github.com/optimizely/python-sdk/pull/522))
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## 5.6.0
|
|
319
|
+
July 10th, 2026
|
|
320
|
+
|
|
321
|
+
### New Features
|
|
322
|
+
|
|
323
|
+
#### **Local Holdouts Support**
|
|
324
|
+
|
|
325
|
+
- Added local holdouts support to the Python SDK, enabling flag-level traffic exclusion from experiments. ([#512](https://github.com/optimizely/python-sdk/pull/512))
|
|
326
|
+
- Added `localHoldouts` datafile section parsing for backward compatibility with older datafile formats. ([#517](https://github.com/optimizely/python-sdk/pull/517))
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
### Bug Fixes
|
|
331
|
+
|
|
332
|
+
- Fixed invalid IDs in holdout decision events by normalizing `campaign_id`, `variation_id`, and `entity_id` fields. ([#518](https://github.com/optimizely/python-sdk/pull/518))
|
|
333
|
+
- Fixed CMAB prediction requests to use attribute ID instead of attribute key. ([#520](https://github.com/optimizely/python-sdk/pull/520))
|
|
334
|
+
- Fixed ODP identify events being sent with a single identifier, which is a no-op. ([#513](https://github.com/optimizely/python-sdk/pull/513))
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
|
|
307
339
|
## 5.5.0
|
|
308
340
|
April 30th, 2026
|
|
309
341
|
|
|
@@ -163,7 +163,7 @@ class DefaultCmabService:
|
|
|
163
163
|
for attribute_id in cmab_attribute_ids:
|
|
164
164
|
attribute = project_config.attribute_id_map.get(attribute_id)
|
|
165
165
|
if attribute and attribute.key in user_attributes:
|
|
166
|
-
filtered_user_attributes[attribute.
|
|
166
|
+
filtered_user_attributes[attribute.id] = user_attributes[attribute.key]
|
|
167
167
|
|
|
168
168
|
return filtered_user_attributes
|
|
169
169
|
|
|
@@ -64,15 +64,11 @@ class VariationResult(TypedDict):
|
|
|
64
64
|
variation: Optional[Union[entities.Variation, VariationDict]]
|
|
65
65
|
|
|
66
66
|
|
|
67
|
-
class
|
|
68
|
-
|
|
69
|
-
A TypedDict representing the result of a decision process.
|
|
67
|
+
class _DecisionResultOptional(TypedDict, total=False):
|
|
68
|
+
holdout_decision: Decision
|
|
70
69
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
error (bool): Indicates whether an error occurred during the decision process.
|
|
74
|
-
reasons (List[str]): A list of reasons explaining the decision or any errors encountered.
|
|
75
|
-
"""
|
|
70
|
+
|
|
71
|
+
class DecisionResult(_DecisionResultOptional):
|
|
76
72
|
decision: Decision
|
|
77
73
|
error: bool
|
|
78
74
|
reasons: List[str]
|
|
@@ -610,6 +606,23 @@ class DecisionService:
|
|
|
610
606
|
return Decision(experiment=rule, variation=forced_decision_variation,
|
|
611
607
|
source=enums.DecisionSources.ROLLOUT, cmab_uuid=None), decide_reasons
|
|
612
608
|
|
|
609
|
+
local_holdouts = project_config.get_holdouts_for_rule(rule.id)
|
|
610
|
+
for holdout in local_holdouts:
|
|
611
|
+
local_holdout_decision = self.get_variation_for_holdout(
|
|
612
|
+
holdout, user_context, project_config
|
|
613
|
+
)
|
|
614
|
+
decide_reasons.extend(local_holdout_decision['reasons'])
|
|
615
|
+
|
|
616
|
+
local_decision = local_holdout_decision['decision']
|
|
617
|
+
if local_decision.variation is not None:
|
|
618
|
+
message = (
|
|
619
|
+
f"The user '{user_id}' is bucketed into local holdout '{holdout.key}' "
|
|
620
|
+
f"for delivery rule '{rule.key}'."
|
|
621
|
+
)
|
|
622
|
+
self.logger.info(message)
|
|
623
|
+
decide_reasons.append(message)
|
|
624
|
+
return local_decision, decide_reasons
|
|
625
|
+
|
|
613
626
|
bucketing_id, bucket_reasons = self._get_bucketing_id(user_id, attributes)
|
|
614
627
|
decide_reasons += bucket_reasons
|
|
615
628
|
|
|
@@ -733,14 +746,16 @@ class DecisionService:
|
|
|
733
746
|
reasons = decide_reasons.copy() if decide_reasons else []
|
|
734
747
|
user_id = user_context.user_id
|
|
735
748
|
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
749
|
+
global_holdout_result: DecisionResult | None = None
|
|
750
|
+
global_holdout_key: str | None = None
|
|
751
|
+
|
|
752
|
+
# Check global holdouts (flag level — before any rules are evaluated)
|
|
753
|
+
global_holdouts = project_config.get_global_holdouts()
|
|
754
|
+
for holdout in global_holdouts:
|
|
739
755
|
holdout_decision = self.get_variation_for_holdout(holdout, user_context, project_config)
|
|
740
756
|
reasons.extend(holdout_decision['reasons'])
|
|
741
757
|
|
|
742
758
|
decision = holdout_decision['decision']
|
|
743
|
-
# Check if user was bucketed into holdout (has a variation)
|
|
744
759
|
if decision.variation is None:
|
|
745
760
|
continue
|
|
746
761
|
|
|
@@ -750,18 +765,33 @@ class DecisionService:
|
|
|
750
765
|
)
|
|
751
766
|
self.logger.info(message)
|
|
752
767
|
reasons.append(message)
|
|
753
|
-
return {
|
|
754
|
-
'decision': holdout_decision['decision'],
|
|
755
|
-
'error': False,
|
|
756
|
-
'reasons': reasons
|
|
757
|
-
}
|
|
758
768
|
|
|
759
|
-
|
|
769
|
+
if not holdout.exclude_targeted_deliveries:
|
|
770
|
+
return {
|
|
771
|
+
'decision': holdout_decision['decision'],
|
|
772
|
+
'error': False,
|
|
773
|
+
'reasons': reasons
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
message = (
|
|
777
|
+
f"Holdout '{holdout.key}' excludes targeted deliveries. "
|
|
778
|
+
f"Targeted delivery rules will be evaluated."
|
|
779
|
+
)
|
|
780
|
+
self.logger.info(message)
|
|
781
|
+
reasons.append(message)
|
|
782
|
+
global_holdout_result = holdout_decision
|
|
783
|
+
global_holdout_key = holdout.key
|
|
784
|
+
break
|
|
785
|
+
|
|
786
|
+
# Check experiments then rollouts
|
|
760
787
|
if feature_flag.experimentIds:
|
|
761
788
|
for experiment_id in feature_flag.experimentIds:
|
|
762
789
|
experiment = project_config.get_experiment_from_id(experiment_id)
|
|
763
790
|
|
|
764
791
|
if experiment:
|
|
792
|
+
if global_holdout_result is not None and experiment.type != enums.ExperimentTypes.td:
|
|
793
|
+
continue
|
|
794
|
+
|
|
765
795
|
# Check for forced decision
|
|
766
796
|
optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(
|
|
767
797
|
feature_flag.key, experiment.key)
|
|
@@ -772,11 +802,35 @@ class DecisionService:
|
|
|
772
802
|
if forced_decision_variation:
|
|
773
803
|
decision = Decision(experiment, forced_decision_variation,
|
|
774
804
|
enums.DecisionSources.FEATURE_TEST, None)
|
|
775
|
-
|
|
805
|
+
result: DecisionResult = {
|
|
776
806
|
'decision': decision,
|
|
777
807
|
'error': False,
|
|
778
808
|
'reasons': reasons
|
|
779
809
|
}
|
|
810
|
+
if global_holdout_result is not None:
|
|
811
|
+
result['holdout_decision'] = global_holdout_result['decision']
|
|
812
|
+
return result
|
|
813
|
+
|
|
814
|
+
local_holdouts = project_config.get_holdouts_for_rule(experiment.id)
|
|
815
|
+
for holdout in local_holdouts:
|
|
816
|
+
local_holdout_decision = self.get_variation_for_holdout(
|
|
817
|
+
holdout, user_context, project_config
|
|
818
|
+
)
|
|
819
|
+
reasons.extend(local_holdout_decision['reasons'])
|
|
820
|
+
|
|
821
|
+
local_decision = local_holdout_decision['decision']
|
|
822
|
+
if local_decision.variation is not None:
|
|
823
|
+
message = (
|
|
824
|
+
f"The user '{user_id}' is bucketed into local holdout '{holdout.key}' "
|
|
825
|
+
f"for experiment rule '{experiment.key}'."
|
|
826
|
+
)
|
|
827
|
+
self.logger.info(message)
|
|
828
|
+
reasons.append(message)
|
|
829
|
+
return {
|
|
830
|
+
'decision': local_holdout_decision['decision'],
|
|
831
|
+
'error': False,
|
|
832
|
+
'reasons': reasons
|
|
833
|
+
}
|
|
780
834
|
|
|
781
835
|
# Get variation for experiment
|
|
782
836
|
variation_result = self.get_variation(
|
|
@@ -797,11 +851,22 @@ class DecisionService:
|
|
|
797
851
|
decision = Decision(experiment, variation_result['variation'],
|
|
798
852
|
enums.DecisionSources.FEATURE_TEST,
|
|
799
853
|
variation_result['cmab_uuid'])
|
|
800
|
-
|
|
854
|
+
result = {
|
|
801
855
|
'decision': decision,
|
|
802
856
|
'error': False,
|
|
803
857
|
'reasons': reasons
|
|
804
858
|
}
|
|
859
|
+
if global_holdout_result is not None:
|
|
860
|
+
result['holdout_decision'] = global_holdout_result['decision']
|
|
861
|
+
return result
|
|
862
|
+
|
|
863
|
+
if global_holdout_result is not None:
|
|
864
|
+
message = (
|
|
865
|
+
f"Holdout \"{global_holdout_key}\" has excludeTargetedDeliveries enabled, "
|
|
866
|
+
f"continuing to rollout evaluation."
|
|
867
|
+
)
|
|
868
|
+
self.logger.info(message)
|
|
869
|
+
reasons.append(message)
|
|
805
870
|
|
|
806
871
|
# If no experiment decision, check rollouts
|
|
807
872
|
rollout_decision, rollout_reasons = self.get_variation_for_rollout(
|
|
@@ -823,11 +888,14 @@ class DecisionService:
|
|
|
823
888
|
else:
|
|
824
889
|
self.logger.debug(f'User "{user_id}" not bucketed into any rollout for feature "{feature_flag.key}".')
|
|
825
890
|
|
|
826
|
-
|
|
891
|
+
final_result: DecisionResult = {
|
|
827
892
|
'decision': rollout_decision,
|
|
828
893
|
'error': False,
|
|
829
894
|
'reasons': reasons
|
|
830
895
|
}
|
|
896
|
+
if global_holdout_result is not None:
|
|
897
|
+
final_result['holdout_decision'] = global_holdout_result['decision']
|
|
898
|
+
return final_result
|
|
831
899
|
|
|
832
900
|
def get_variation_for_holdout(
|
|
833
901
|
self,
|
|
@@ -223,6 +223,8 @@ class Holdout(BaseEntity):
|
|
|
223
223
|
trafficAllocation: list[TrafficAllocation],
|
|
224
224
|
audienceIds: list[str],
|
|
225
225
|
audienceConditions: Optional[Sequence[str | list[str]]] = None,
|
|
226
|
+
includedRules: Optional[list[str]] = None,
|
|
227
|
+
excludeTargetedDeliveries: bool = False,
|
|
226
228
|
**kwargs: Any
|
|
227
229
|
):
|
|
228
230
|
self.id = id
|
|
@@ -232,6 +234,10 @@ class Holdout(BaseEntity):
|
|
|
232
234
|
self.trafficAllocation = trafficAllocation
|
|
233
235
|
self.audienceIds = audienceIds
|
|
234
236
|
self.audienceConditions = audienceConditions
|
|
237
|
+
# Per-rule targeting for local holdouts. Scope comes from the datafile
|
|
238
|
+
# section, not this field; ProjectConfig strips it on 'holdouts' entries.
|
|
239
|
+
self.included_rules: Optional[list[str]] = includedRules
|
|
240
|
+
self.exclude_targeted_deliveries: bool = excludeTargetedDeliveries
|
|
235
241
|
|
|
236
242
|
def get_audience_conditions_or_ids(self) -> Sequence[str | list[str]]:
|
|
237
243
|
"""Returns audienceConditions if present, otherwise audienceIds.
|
|
@@ -241,6 +247,16 @@ class Holdout(BaseEntity):
|
|
|
241
247
|
"""
|
|
242
248
|
return self.audienceConditions if self.audienceConditions is not None else self.audienceIds
|
|
243
249
|
|
|
250
|
+
@property
|
|
251
|
+
def is_global(self) -> bool:
|
|
252
|
+
"""True if this is a global holdout.
|
|
253
|
+
|
|
254
|
+
Scope is set by the datafile section ('holdouts' vs 'localHoldouts').
|
|
255
|
+
ProjectConfig strips 'includedRules' on 'holdouts' entries, so this
|
|
256
|
+
property stays consistent with section membership.
|
|
257
|
+
"""
|
|
258
|
+
return self.included_rules is None
|
|
259
|
+
|
|
244
260
|
@property
|
|
245
261
|
def is_activated(self) -> bool:
|
|
246
262
|
"""Check if the holdout is activated (running).
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Copyright 2019, 2022, Optimizely
|
|
1
|
+
# Copyright 2019, 2022, 2026, Optimizely
|
|
2
2
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
3
|
# you may not use this file except in compliance with the License.
|
|
4
4
|
# You may obtain a copy of the License at
|
|
@@ -18,6 +18,7 @@ from optimizely import entities
|
|
|
18
18
|
from optimizely.helpers import enums
|
|
19
19
|
from optimizely.helpers import event_tag_utils
|
|
20
20
|
from optimizely.helpers import validator
|
|
21
|
+
from . import event_id_normalizer
|
|
21
22
|
from . import log_event
|
|
22
23
|
from . import payload
|
|
23
24
|
from . import user_event
|
|
@@ -134,12 +135,19 @@ class EventFactory:
|
|
|
134
135
|
if isinstance(event.experiment, entities.Experiment):
|
|
135
136
|
experiment_layerId = event.experiment.layerId
|
|
136
137
|
|
|
138
|
+
normalized_campaign_id = event_id_normalizer.normalize_campaign_id(
|
|
139
|
+
experiment_layerId, experiment_id
|
|
140
|
+
)
|
|
141
|
+
normalized_variation_id = event_id_normalizer.normalize_variation_id(variation_id)
|
|
142
|
+
|
|
137
143
|
metadata = payload.Metadata(event.flag_key, event.rule_key,
|
|
138
144
|
event.rule_type, variation_key,
|
|
139
145
|
event.enabled, event.cmab_uuid)
|
|
140
|
-
decision = payload.Decision(
|
|
146
|
+
decision = payload.Decision(
|
|
147
|
+
normalized_campaign_id, experiment_id, normalized_variation_id, metadata
|
|
148
|
+
)
|
|
141
149
|
snapshot_event = payload.SnapshotEvent(
|
|
142
|
-
|
|
150
|
+
normalized_campaign_id, event.uuid, cls.ACTIVATE_EVENT_KEY, event.timestamp,
|
|
143
151
|
)
|
|
144
152
|
|
|
145
153
|
snapshot = payload.Snapshot([snapshot_event], [decision])
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Copyright 2026, 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
|
+
|
|
14
|
+
"""Normalization helpers for decision-event ID fields.
|
|
15
|
+
|
|
16
|
+
This module provides byte-equivalent, cross-SDK normalization for the
|
|
17
|
+
``campaign_id``, ``variation_id``, and impression ``entity_id`` fields that
|
|
18
|
+
appear in dispatched decision events.
|
|
19
|
+
|
|
20
|
+
Rules:
|
|
21
|
+
* ``campaign_id`` and impression ``entity_id`` accept **any non-empty
|
|
22
|
+
string** (numeric like ``"12345"`` or opaque like ``"default-12345"`` /
|
|
23
|
+
``"layer_abc"``). The fallback to ``experiment_id`` fires ONLY when the
|
|
24
|
+
value is the empty string, ``None``, or missing. Non-string types are
|
|
25
|
+
out of scope for this normalization path (the upstream datafile
|
|
26
|
+
producer delivers string or null values).
|
|
27
|
+
* ``variation_id`` retains the stricter contract: it MUST be a non-empty
|
|
28
|
+
string of decimal digits ``0-9`` (leading zeros allowed). Empty,
|
|
29
|
+
whitespace, non-string, and non-numeric inputs are normalized to
|
|
30
|
+
``None`` so the wire payload carries an explicit null.
|
|
31
|
+
* ``entity_id`` on impression events shares the campaign_id normalization
|
|
32
|
+
and is therefore byte-equivalent to the normalized campaign_id for the
|
|
33
|
+
same impression.
|
|
34
|
+
|
|
35
|
+
The normalization path MUST NOT log, warn, or raise. It must never drop or
|
|
36
|
+
defer event dispatch.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
from sys import version_info
|
|
41
|
+
from typing import Any, Optional
|
|
42
|
+
|
|
43
|
+
if version_info < (3, 10):
|
|
44
|
+
from typing_extensions import TypeGuard
|
|
45
|
+
else:
|
|
46
|
+
from typing import TypeGuard
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_non_empty_string(value: Any) -> TypeGuard[str]:
|
|
50
|
+
"""Return ``True`` if ``value`` is a non-empty :class:`str`.
|
|
51
|
+
Any non-empty string is accepted regardless of
|
|
52
|
+
character content (IDs may be opaque, e.g. ``"default-12345"``).
|
|
53
|
+
"""
|
|
54
|
+
return isinstance(value, str) and value != ''
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_numeric_id_string(value: Any) -> TypeGuard[str]:
|
|
58
|
+
"""Return ``True`` if ``value`` is a non-empty decimal-digit string.
|
|
59
|
+
Whitespace, signs, decimal points, exponents
|
|
60
|
+
and non-string types all return ``False``. Leading
|
|
61
|
+
zeros are accepted.
|
|
62
|
+
"""
|
|
63
|
+
if not isinstance(value, str):
|
|
64
|
+
return False
|
|
65
|
+
if value == '':
|
|
66
|
+
return False
|
|
67
|
+
return value.isascii() and value.isdigit()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def normalize_campaign_id(campaign_id: Any, experiment_id: Any) -> str:
|
|
71
|
+
"""Normalize a decision-event ``campaign_id``.
|
|
72
|
+
|
|
73
|
+
Returns ``campaign_id`` unchanged when it is a non-empty string (any
|
|
74
|
+
character content — numeric like ``"12345"`` or opaque like
|
|
75
|
+
``"default-12345"``). Otherwise falls back to ``experiment_id`` (when it
|
|
76
|
+
is itself a non-empty string). If neither is a non-empty string, returns
|
|
77
|
+
an empty string so the event still dispatches.
|
|
78
|
+
"""
|
|
79
|
+
if is_non_empty_string(campaign_id):
|
|
80
|
+
return campaign_id
|
|
81
|
+
if is_non_empty_string(experiment_id):
|
|
82
|
+
return experiment_id
|
|
83
|
+
return ''
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def normalize_variation_id(variation_id: Any) -> Optional[str]:
|
|
87
|
+
"""Normalize a decision-event ``variation_id``.
|
|
88
|
+
|
|
89
|
+
Returns the original value if it is a valid numeric ID string. Otherwise
|
|
90
|
+
returns ``None`` so the event payload carries an explicit null for the
|
|
91
|
+
downstream consumer.
|
|
92
|
+
"""
|
|
93
|
+
return variation_id if is_numeric_id_string(variation_id) else None
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Copyright 2019, 2022, Optimizely
|
|
1
|
+
# Copyright 2019, 2022, 2026, Optimizely
|
|
2
2
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
3
|
# you may not use this file except in compliance with the License.
|
|
4
4
|
# You may obtain a copy of the License at
|
|
@@ -71,7 +71,13 @@ class EventBatch:
|
|
|
71
71
|
class Decision:
|
|
72
72
|
""" Class respresenting Decision. """
|
|
73
73
|
|
|
74
|
-
def __init__(
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
campaign_id: str,
|
|
77
|
+
experiment_id: str,
|
|
78
|
+
variation_id: Optional[str],
|
|
79
|
+
metadata: Metadata,
|
|
80
|
+
):
|
|
75
81
|
self.campaign_id = campaign_id
|
|
76
82
|
self.experiment_id = experiment_id
|
|
77
83
|
self.variation_id = variation_id
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Copyright 2016-2019, 2022, Optimizely
|
|
1
|
+
# Copyright 2016-2019, 2022, 2026, Optimizely
|
|
2
2
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
3
|
# you may not use this file except in compliance with the License.
|
|
4
4
|
# You may obtain a copy of the License at
|
|
@@ -18,6 +18,7 @@ import uuid
|
|
|
18
18
|
from sys import version_info
|
|
19
19
|
|
|
20
20
|
from . import version
|
|
21
|
+
from .event import event_id_normalizer
|
|
21
22
|
from .helpers import enums
|
|
22
23
|
from .helpers import event_tag_utils
|
|
23
24
|
from .helpers import validator
|
|
@@ -178,7 +179,7 @@ class EventBuilder:
|
|
|
178
179
|
|
|
179
180
|
def _get_required_params_for_impression(
|
|
180
181
|
self, experiment: Experiment, variation_id: str
|
|
181
|
-
) -> dict[str, list[dict[str,
|
|
182
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
182
183
|
""" Get parameters that are required for the impression event to register.
|
|
183
184
|
|
|
184
185
|
Args:
|
|
@@ -188,19 +189,24 @@ class EventBuilder:
|
|
|
188
189
|
Returns:
|
|
189
190
|
Dict consisting of decisions and events info for impression event.
|
|
190
191
|
"""
|
|
191
|
-
snapshot: dict[str, list[dict[str,
|
|
192
|
+
snapshot: dict[str, list[dict[str, Any]]] = {}
|
|
193
|
+
|
|
194
|
+
normalized_campaign_id = event_id_normalizer.normalize_campaign_id(
|
|
195
|
+
experiment.layerId, experiment.id
|
|
196
|
+
)
|
|
197
|
+
normalized_variation_id = event_id_normalizer.normalize_variation_id(variation_id)
|
|
192
198
|
|
|
193
199
|
snapshot[self.EventParams.DECISIONS] = [
|
|
194
200
|
{
|
|
195
201
|
self.EventParams.EXPERIMENT_ID: experiment.id,
|
|
196
|
-
self.EventParams.VARIATION_ID:
|
|
197
|
-
self.EventParams.CAMPAIGN_ID:
|
|
202
|
+
self.EventParams.VARIATION_ID: normalized_variation_id,
|
|
203
|
+
self.EventParams.CAMPAIGN_ID: normalized_campaign_id,
|
|
198
204
|
}
|
|
199
205
|
]
|
|
200
206
|
|
|
201
207
|
snapshot[self.EventParams.EVENTS] = [
|
|
202
208
|
{
|
|
203
|
-
self.EventParams.EVENT_ID:
|
|
209
|
+
self.EventParams.EVENT_ID: normalized_campaign_id,
|
|
204
210
|
self.EventParams.TIME: self._get_time(),
|
|
205
211
|
self.EventParams.KEY: 'campaign_activated',
|
|
206
212
|
self.EventParams.UUID: str(uuid.uuid4()),
|
|
@@ -130,3 +130,6 @@ class HoldoutDict(ExperimentDict):
|
|
|
130
130
|
Extends ExperimentDict with holdout-specific properties.
|
|
131
131
|
"""
|
|
132
132
|
holdoutStatus: HoldoutStatus
|
|
133
|
+
# Per-rule targeting for local holdouts. Scope comes from the datafile section,
|
|
134
|
+
# not this field; required on 'localHoldouts' entries, stripped on 'holdouts'.
|
|
135
|
+
includedRules: Optional[list[str]]
|
|
@@ -267,9 +267,30 @@ class OdpEventManager:
|
|
|
267
267
|
except Full:
|
|
268
268
|
self.logger.warning(Errors.ODP_EVENT_FAILED.format("Queue is full"))
|
|
269
269
|
|
|
270
|
-
def identify_user(self,
|
|
270
|
+
def identify_user(self, identifiers: dict[str, str]) -> None:
|
|
271
|
+
"""Send an identify event to ODP if there are multiple valid identifiers.
|
|
272
|
+
|
|
273
|
+
An identify event is only useful when there are two or more identifiers
|
|
274
|
+
to link together in the ODP user profile. Sending a single identifier
|
|
275
|
+
provides no linking value and wastes a network call.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
identifiers: A dictionary of identifier key-value pairs
|
|
279
|
+
(e.g. {"fs_user_id": "abc", "email": "a@b.com"}).
|
|
280
|
+
"""
|
|
281
|
+
# Filter out identifiers with None or empty string values.
|
|
282
|
+
valid_identifiers = {k: v for k, v in identifiers.items() if v}
|
|
283
|
+
|
|
284
|
+
# Identify requires 2+ identifiers to link (e.g., vuid + fs_user_id).
|
|
285
|
+
# A single identifier has no cross-reference value and generates unnecessary traffic.
|
|
286
|
+
if len(valid_identifiers) < 2:
|
|
287
|
+
self.logger.debug(
|
|
288
|
+
'ODP identify event is not dispatched (fewer than 2 valid identifiers).'
|
|
289
|
+
)
|
|
290
|
+
return
|
|
291
|
+
|
|
271
292
|
self.send_event(OdpManagerConfig.EVENT_TYPE, 'identified',
|
|
272
|
-
|
|
293
|
+
valid_identifiers, {})
|
|
273
294
|
|
|
274
295
|
def update_config(self) -> None:
|
|
275
296
|
"""Adds update config signal to event_queue."""
|
|
@@ -73,7 +73,7 @@ class OdpManager:
|
|
|
73
73
|
|
|
74
74
|
return self.segment_manager.fetch_qualified_segments(user_key, user_value, options)
|
|
75
75
|
|
|
76
|
-
def identify_user(self,
|
|
76
|
+
def identify_user(self, identifiers: dict[str, str]) -> None:
|
|
77
77
|
if not self.enabled or not self.event_manager:
|
|
78
78
|
self.logger.debug('ODP identify event is not dispatched (ODP disabled).')
|
|
79
79
|
return
|
|
@@ -81,7 +81,7 @@ class OdpManager:
|
|
|
81
81
|
self.logger.debug('ODP identify event is not dispatched (ODP not integrated).')
|
|
82
82
|
return
|
|
83
83
|
|
|
84
|
-
self.event_manager.identify_user(
|
|
84
|
+
self.event_manager.identify_user(identifiers)
|
|
85
85
|
|
|
86
86
|
def send_event(self, type: str, action: str, identifiers: dict[str, str], data: dict[str, Any]) -> None:
|
|
87
87
|
"""
|
|
@@ -1245,7 +1245,8 @@ class Optimizely:
|
|
|
1245
1245
|
flag_decision: Decision,
|
|
1246
1246
|
decision_reasons: Optional[list[str]],
|
|
1247
1247
|
decide_options: list[str],
|
|
1248
|
-
project_config: ProjectConfig
|
|
1248
|
+
project_config: ProjectConfig,
|
|
1249
|
+
holdout_decision: Optional[Decision] = None
|
|
1249
1250
|
) -> OptimizelyDecision:
|
|
1250
1251
|
user_id = user_context.user_id
|
|
1251
1252
|
feature_enabled = False
|
|
@@ -1264,6 +1265,26 @@ class Optimizely:
|
|
|
1264
1265
|
|
|
1265
1266
|
feature_flag = project_config.feature_key_map.get(flag_key)
|
|
1266
1267
|
|
|
1268
|
+
# Send holdout impression when user was bucketed into a holdout bypassed due to exclude_targeted_deliveries
|
|
1269
|
+
if (holdout_decision is not None
|
|
1270
|
+
and decision_source != DecisionSources.HOLDOUT
|
|
1271
|
+
and OptimizelyDecideOption.DISABLE_DECISION_EVENT not in decide_options):
|
|
1272
|
+
holdout_enabled = self._get_feature_enabled(holdout_decision.variation)
|
|
1273
|
+
holdout_rule_key = holdout_decision.experiment.key if holdout_decision.experiment else ''
|
|
1274
|
+
self._send_impression_event(
|
|
1275
|
+
project_config,
|
|
1276
|
+
holdout_decision.experiment,
|
|
1277
|
+
holdout_decision.variation,
|
|
1278
|
+
flag_key,
|
|
1279
|
+
holdout_rule_key,
|
|
1280
|
+
str(DecisionSources.HOLDOUT),
|
|
1281
|
+
holdout_enabled,
|
|
1282
|
+
user_id,
|
|
1283
|
+
attributes,
|
|
1284
|
+
holdout_decision.cmab_uuid
|
|
1285
|
+
)
|
|
1286
|
+
decision_event_dispatched = True
|
|
1287
|
+
|
|
1267
1288
|
# Send impression event if Decision came from a feature
|
|
1268
1289
|
if OptimizelyDecideOption.DISABLE_DECISION_EVENT not in decide_options:
|
|
1269
1290
|
if (decision_source == DecisionSources.FEATURE_TEST or
|
|
@@ -1448,11 +1469,13 @@ class Optimizely:
|
|
|
1448
1469
|
user_context,
|
|
1449
1470
|
merged_decide_options
|
|
1450
1471
|
)
|
|
1472
|
+
holdout_decisions: dict[str, Optional[Decision]] = {}
|
|
1451
1473
|
for i in range(0, len(flags_without_forced_decision)):
|
|
1452
1474
|
decision = decision_list[i]['decision']
|
|
1453
1475
|
reasons = decision_list[i]['reasons']
|
|
1454
1476
|
error = decision_list[i]['error']
|
|
1455
1477
|
flag_key = flags_without_forced_decision[i].key
|
|
1478
|
+
holdout_decisions[flag_key] = decision_list[i].get('holdout_decision')
|
|
1456
1479
|
# store error decision against key and remove key from valid keys
|
|
1457
1480
|
if error:
|
|
1458
1481
|
optimizely_decision = OptimizelyDecision.new_error_decision(flags_without_forced_decision[i].key,
|
|
@@ -1472,7 +1495,8 @@ class Optimizely:
|
|
|
1472
1495
|
flag_decision,
|
|
1473
1496
|
decision_reasons,
|
|
1474
1497
|
merged_decide_options,
|
|
1475
|
-
project_config
|
|
1498
|
+
project_config,
|
|
1499
|
+
holdout_decision=holdout_decisions.get(key)
|
|
1476
1500
|
)
|
|
1477
1501
|
enabled_flags_only_missing = OptimizelyDecideOption.ENABLED_FLAGS_ONLY not in merged_decide_options
|
|
1478
1502
|
is_enabled = optimizely_decision.enabled
|
|
@@ -1541,7 +1565,7 @@ class Optimizely:
|
|
|
1541
1565
|
config.all_segments
|
|
1542
1566
|
)
|
|
1543
1567
|
|
|
1544
|
-
def _identify_user(self,
|
|
1568
|
+
def _identify_user(self, identifiers: dict[str, str]) -> None:
|
|
1545
1569
|
if not self.is_valid:
|
|
1546
1570
|
self.logger.error(enums.Errors.INVALID_OPTIMIZELY.format('identify_user'))
|
|
1547
1571
|
return
|
|
@@ -1551,7 +1575,7 @@ class Optimizely:
|
|
|
1551
1575
|
self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('identify_user'))
|
|
1552
1576
|
return
|
|
1553
1577
|
|
|
1554
|
-
self.odp_manager.identify_user(
|
|
1578
|
+
self.odp_manager.identify_user(identifiers)
|
|
1555
1579
|
|
|
1556
1580
|
def _fetch_qualified_segments(self, user_id: str, options: Optional[list[str]] = None) -> Optional[list[str]]:
|
|
1557
1581
|
if not self.is_valid:
|
|
@@ -19,6 +19,7 @@ import threading
|
|
|
19
19
|
from typing import TYPE_CHECKING, Any, Callable, Optional, NewType, Dict
|
|
20
20
|
|
|
21
21
|
from optimizely.decision import optimizely_decision
|
|
22
|
+
from optimizely.helpers.enums import OdpManagerConfig
|
|
22
23
|
|
|
23
24
|
if TYPE_CHECKING:
|
|
24
25
|
# prevent circular dependency by skipping import at runtime
|
|
@@ -73,7 +74,8 @@ class OptimizelyUserContext:
|
|
|
73
74
|
] = {}
|
|
74
75
|
|
|
75
76
|
if self.client and identify:
|
|
76
|
-
|
|
77
|
+
identifiers = {OdpManagerConfig.KEY_FOR_USER_ID: user_id}
|
|
78
|
+
self.client._identify_user(identifiers)
|
|
77
79
|
|
|
78
80
|
class OptimizelyDecisionContext:
|
|
79
81
|
""" Using class with attributes here instead of namedtuple because
|
|
@@ -90,14 +90,49 @@ class ProjectConfig:
|
|
|
90
90
|
self.region: str = region_value or 'US'
|
|
91
91
|
|
|
92
92
|
# Parse holdouts from datafile and convert to Holdout entities
|
|
93
|
-
|
|
93
|
+
global_holdouts_data: list[types.HoldoutDict] = config.get('holdouts', [])
|
|
94
|
+
local_holdouts_data: list[types.HoldoutDict] = config.get('localHoldouts', [])
|
|
94
95
|
self.holdouts: list[entities.Holdout] = []
|
|
95
96
|
self.holdout_id_map: dict[str, entities.Holdout] = {}
|
|
96
|
-
|
|
97
|
+
# Global holdouts — evaluated at flag level before any rule
|
|
98
|
+
self.global_holdouts: list[entities.Holdout] = []
|
|
99
|
+
# Rule-level holdouts — map from rule ID to holdouts targeting that rule
|
|
100
|
+
self.rule_holdouts_map: dict[str, list[entities.Holdout]] = {}
|
|
101
|
+
|
|
102
|
+
# Process global holdouts: section membership is the sole signal for scope.
|
|
103
|
+
# Drop any 'includedRules' field on entries here so the entity is unambiguously
|
|
104
|
+
# global (is_global → True), even if the datafile incorrectly includes one.
|
|
105
|
+
for holdout_data in global_holdouts_data:
|
|
106
|
+
sanitized = cast(types.HoldoutDict, dict(holdout_data))
|
|
107
|
+
if sanitized.pop('includedRules', None) is not None: # type: ignore[misc]
|
|
108
|
+
self.logger.warning(
|
|
109
|
+
f'Global holdout "{holdout_data.get("key", "")}" '
|
|
110
|
+
f'(id: {holdout_data.get("id", "<unknown>")}) has "includedRules" '
|
|
111
|
+
f'which will be ignored; global holdouts apply to all flags.'
|
|
112
|
+
)
|
|
113
|
+
holdout = entities.Holdout(**sanitized)
|
|
114
|
+
self.holdouts.append(holdout)
|
|
115
|
+
|
|
116
|
+
# Only process Running holdouts
|
|
117
|
+
if not holdout.is_activated:
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
# Map by ID for quick lookup
|
|
121
|
+
self.holdout_id_map[holdout.id] = holdout
|
|
122
|
+
self.global_holdouts.append(holdout)
|
|
123
|
+
|
|
124
|
+
# Process local holdouts: every entry must carry 'includedRules' (list of rule IDs).
|
|
125
|
+
# Entries without 'includedRules' are invalid — log an error and exclude
|
|
126
|
+
# them from evaluation (do NOT fall back to global application).
|
|
127
|
+
for holdout_data in local_holdouts_data:
|
|
128
|
+
if 'includedRules' not in holdout_data or holdout_data.get('includedRules') is None:
|
|
129
|
+
holdout_id = holdout_data.get('id', '<unknown>')
|
|
130
|
+
self.logger.error(
|
|
131
|
+
f'Local holdout with ID "{holdout_id}" is missing required "includedRules" '
|
|
132
|
+
f'field and will be excluded from evaluation.'
|
|
133
|
+
)
|
|
134
|
+
continue
|
|
97
135
|
|
|
98
|
-
# Convert holdout dicts to Holdout entities
|
|
99
|
-
for holdout_data in holdouts_data:
|
|
100
|
-
# Create Holdout entity
|
|
101
136
|
holdout = entities.Holdout(**holdout_data)
|
|
102
137
|
self.holdouts.append(holdout)
|
|
103
138
|
|
|
@@ -108,6 +143,12 @@ class ProjectConfig:
|
|
|
108
143
|
# Map by ID for quick lookup
|
|
109
144
|
self.holdout_id_map[holdout.id] = holdout
|
|
110
145
|
|
|
146
|
+
# Register for each targeted rule ID
|
|
147
|
+
for rule_id in (holdout.included_rules or []):
|
|
148
|
+
if rule_id not in self.rule_holdouts_map:
|
|
149
|
+
self.rule_holdouts_map[rule_id] = []
|
|
150
|
+
self.rule_holdouts_map[rule_id].append(holdout)
|
|
151
|
+
|
|
111
152
|
# Utility maps for quick lookup
|
|
112
153
|
self.group_id_map: dict[str, entities.Group] = self._generate_key_map(self.groups, 'id', entities.Group)
|
|
113
154
|
self.experiment_id_map: dict[str, entities.Experiment] = self._generate_key_map(
|
|
@@ -240,11 +281,6 @@ class ProjectConfig:
|
|
|
240
281
|
everyone_else_variation.variables, 'id', entities.Variation.VariableUsage
|
|
241
282
|
)
|
|
242
283
|
|
|
243
|
-
# Map all running holdouts to this flag
|
|
244
|
-
applicable_holdouts = list(self.holdout_id_map.values())
|
|
245
|
-
if applicable_holdouts:
|
|
246
|
-
self.flag_holdouts_map[feature.key] = applicable_holdouts
|
|
247
|
-
|
|
248
284
|
rollout = None if len(feature.rolloutId) == 0 else self.rollout_id_map[feature.rolloutId]
|
|
249
285
|
if rollout:
|
|
250
286
|
for exp in rollout.experiments:
|
|
@@ -878,19 +914,34 @@ class ProjectConfig:
|
|
|
878
914
|
|
|
879
915
|
return None
|
|
880
916
|
|
|
881
|
-
def
|
|
882
|
-
"""
|
|
917
|
+
def get_global_holdouts(self) -> list[entities.Holdout]:
|
|
918
|
+
"""Return all global holdouts (parsed from the top-level 'holdouts' section).
|
|
883
919
|
|
|
884
|
-
|
|
885
|
-
|
|
920
|
+
Global holdouts are evaluated at flag level before any rule is checked.
|
|
921
|
+
Section membership in 'holdouts' is the sole signal for global scope —
|
|
922
|
+
any 'includedRules' field on these entries is ignored.
|
|
886
923
|
|
|
887
924
|
Returns:
|
|
888
|
-
|
|
925
|
+
List of global Holdout entities that are currently running.
|
|
889
926
|
"""
|
|
890
|
-
|
|
891
|
-
|
|
927
|
+
return self.global_holdouts
|
|
928
|
+
|
|
929
|
+
def get_holdouts_for_rule(self, rule_id: str) -> list[entities.Holdout]:
|
|
930
|
+
"""Return local holdouts that target a specific rule.
|
|
892
931
|
|
|
893
|
-
|
|
932
|
+
Local holdouts come from the top-level 'localHoldouts' datafile section and
|
|
933
|
+
are scoped per-rule via their 'includedRules' field. They are evaluated
|
|
934
|
+
before the rule's audience and traffic allocation checks. A rule ID not
|
|
935
|
+
present in any holdout's includedRules simply returns an empty list —
|
|
936
|
+
silently skipped.
|
|
937
|
+
|
|
938
|
+
Args:
|
|
939
|
+
rule_id: The experiment or delivery rule ID to look up.
|
|
940
|
+
|
|
941
|
+
Returns:
|
|
942
|
+
List of local Holdout entities targeting the given rule ID.
|
|
943
|
+
"""
|
|
944
|
+
return self.rule_holdouts_map.get(rule_id, [])
|
|
894
945
|
|
|
895
946
|
def get_holdout(self, holdout_id: str) -> Optional[entities.Holdout]:
|
|
896
947
|
""" Helper method to get holdout from holdout ID.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: optimizely-sdk
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.7.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
|
|
@@ -24,17 +24,17 @@ Requires-Dist: requests>=2.21
|
|
|
24
24
|
Requires-Dist: idna>=2.10
|
|
25
25
|
Requires-Dist: rpds-py<0.20.0; python_version < "3.11"
|
|
26
26
|
Provides-Extra: test
|
|
27
|
-
Requires-Dist:
|
|
27
|
+
Requires-Dist: funcsigs>=0.4; extra == "test"
|
|
28
28
|
Requires-Dist: idna>=2.10; extra == "test"
|
|
29
29
|
Requires-Dist: ruff>=0.15.0; extra == "test"
|
|
30
|
-
Requires-Dist:
|
|
31
|
-
Requires-Dist: coverage; extra == "test"
|
|
32
|
-
Requires-Dist: pytest-cov; extra == "test"
|
|
30
|
+
Requires-Dist: pyrsistent>=0.16.0; extra == "test"
|
|
33
31
|
Requires-Dist: rpds-py<0.20.0; python_version < "3.11" and extra == "test"
|
|
32
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
33
|
+
Requires-Dist: requests>=2.21; extra == "test"
|
|
34
|
+
Requires-Dist: coverage; extra == "test"
|
|
35
|
+
Requires-Dist: python-coveralls; extra == "test"
|
|
34
36
|
Requires-Dist: jsonschema>=3.2.0; extra == "test"
|
|
35
|
-
Requires-Dist: funcsigs>=0.4; extra == "test"
|
|
36
37
|
Requires-Dist: pytest>=6.2.0; extra == "test"
|
|
37
|
-
Requires-Dist: pyrsistent>=0.16.0; extra == "test"
|
|
38
38
|
Dynamic: author
|
|
39
39
|
Dynamic: author-email
|
|
40
40
|
Dynamic: classifier
|
|
@@ -304,6 +304,38 @@ idna (BSD 3-Clause License https://github.com/kjd/idna/blob/master/LICENSE.md)
|
|
|
304
304
|
- Swift - https://github.com/optimizely/swift-sdk
|
|
305
305
|
# Optimizely Python SDK Changelog
|
|
306
306
|
|
|
307
|
+
## 5.7.0
|
|
308
|
+
September 2nd, 2026
|
|
309
|
+
|
|
310
|
+
### New Features
|
|
311
|
+
|
|
312
|
+
**Holdout Targeted Delivery Exclusion**: Holdouts can now be configured to exclude Targeted Delivery rules, so users held out of experiments still receive their Targeted Delivery experiences. This gives you a cleaner measure of experiment impact without holding users back from ongoing rollouts.
|
|
313
|
+
|
|
314
|
+
- Add holdout exclusion logic for Targeted Delivery rules ([#522](https://github.com/optimizely/python-sdk/pull/522))
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## 5.6.0
|
|
319
|
+
July 10th, 2026
|
|
320
|
+
|
|
321
|
+
### New Features
|
|
322
|
+
|
|
323
|
+
#### **Local Holdouts Support**
|
|
324
|
+
|
|
325
|
+
- Added local holdouts support to the Python SDK, enabling flag-level traffic exclusion from experiments. ([#512](https://github.com/optimizely/python-sdk/pull/512))
|
|
326
|
+
- Added `localHoldouts` datafile section parsing for backward compatibility with older datafile formats. ([#517](https://github.com/optimizely/python-sdk/pull/517))
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
### Bug Fixes
|
|
331
|
+
|
|
332
|
+
- Fixed invalid IDs in holdout decision events by normalizing `campaign_id`, `variation_id`, and `entity_id` fields. ([#518](https://github.com/optimizely/python-sdk/pull/518))
|
|
333
|
+
- Fixed CMAB prediction requests to use attribute ID instead of attribute key. ([#520](https://github.com/optimizely/python-sdk/pull/520))
|
|
334
|
+
- Fixed ODP identify events being sent with a single identifier, which is a no-op. ([#513](https://github.com/optimizely/python-sdk/pull/513))
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
|
|
307
339
|
## 5.5.0
|
|
308
340
|
April 30th, 2026
|
|
309
341
|
|
|
@@ -32,6 +32,7 @@ optimizely/decision/optimizely_decision.py
|
|
|
32
32
|
optimizely/decision/optimizely_decision_message.py
|
|
33
33
|
optimizely/event/__init__.py
|
|
34
34
|
optimizely/event/event_factory.py
|
|
35
|
+
optimizely/event/event_id_normalizer.py
|
|
35
36
|
optimizely/event/event_processor.py
|
|
36
37
|
optimizely/event/log_event.py
|
|
37
38
|
optimizely/event/payload.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/decision/optimizely_decide_option.py
RENAMED
|
File without changes
|
|
File without changes
|
{optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/decision/optimizely_decision_message.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/condition_tree_evaluator.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -7,16 +7,16 @@ idna>=2.10
|
|
|
7
7
|
rpds-py<0.20.0
|
|
8
8
|
|
|
9
9
|
[test]
|
|
10
|
-
|
|
10
|
+
funcsigs>=0.4
|
|
11
11
|
idna>=2.10
|
|
12
12
|
ruff>=0.15.0
|
|
13
|
-
|
|
14
|
-
coverage
|
|
13
|
+
pyrsistent>=0.16.0
|
|
15
14
|
pytest-cov
|
|
15
|
+
requests>=2.21
|
|
16
|
+
coverage
|
|
17
|
+
python-coveralls
|
|
16
18
|
jsonschema>=3.2.0
|
|
17
|
-
funcsigs>=0.4
|
|
18
19
|
pytest>=6.2.0
|
|
19
|
-
pyrsistent>=0.16.0
|
|
20
20
|
|
|
21
21
|
[test:python_version < "3.11"]
|
|
22
22
|
rpds-py<0.20.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|