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.
Files changed (74) hide show
  1. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/CHANGELOG.md +32 -0
  2. {optimizely_sdk-5.5.0/optimizely_sdk.egg-info → optimizely_sdk-5.7.0}/PKG-INFO +39 -7
  3. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/cmab/cmab_service.py +1 -1
  4. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/decision_service.py +89 -21
  5. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/entities.py +16 -0
  6. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/event/event_factory.py +11 -3
  7. optimizely_sdk-5.7.0/optimizely/event/event_id_normalizer.py +93 -0
  8. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/event/payload.py +8 -2
  9. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/event_builder.py +12 -6
  10. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/types.py +3 -0
  11. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/odp/odp_event_manager.py +23 -2
  12. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/odp/odp_manager.py +2 -2
  13. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/optimizely.py +28 -4
  14. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/optimizely_user_context.py +3 -1
  15. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/project_config.py +69 -18
  16. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/version.py +1 -1
  17. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0/optimizely_sdk.egg-info}/PKG-INFO +39 -7
  18. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely_sdk.egg-info/SOURCES.txt +1 -0
  19. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/LICENSE +0 -0
  20. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/MANIFEST.in +0 -0
  21. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/README.md +0 -0
  22. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/__init__.py +0 -0
  23. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/bucketer.py +0 -0
  24. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/cmab/__init__.py +0 -0
  25. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/cmab/cmab_client.py +0 -0
  26. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/config_manager.py +0 -0
  27. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/decision/__init__.py +0 -0
  28. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/decision/optimizely_decide_option.py +0 -0
  29. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/decision/optimizely_decision.py +0 -0
  30. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/decision/optimizely_decision_message.py +0 -0
  31. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/error_handler.py +0 -0
  32. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/event/__init__.py +0 -0
  33. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/event/event_processor.py +0 -0
  34. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/event/log_event.py +0 -0
  35. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/event/user_event.py +0 -0
  36. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/event/user_event_factory.py +0 -0
  37. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/event_dispatcher.py +0 -0
  38. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/exceptions.py +0 -0
  39. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/__init__.py +0 -0
  40. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/audience.py +0 -0
  41. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/condition.py +0 -0
  42. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/condition_tree_evaluator.py +0 -0
  43. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/constants.py +0 -0
  44. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/enums.py +0 -0
  45. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/event_tag_utils.py +0 -0
  46. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/experiment.py +0 -0
  47. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/sdk_settings.py +0 -0
  48. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/helpers/validator.py +0 -0
  49. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/lib/__init__.py +0 -0
  50. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/lib/pymmh3.py +0 -0
  51. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/logger.py +0 -0
  52. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/notification_center.py +0 -0
  53. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/notification_center_registry.py +0 -0
  54. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/odp/__init__.py +0 -0
  55. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/odp/lru_cache.py +0 -0
  56. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/odp/odp_config.py +0 -0
  57. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/odp/odp_event.py +0 -0
  58. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/odp/odp_event_api_manager.py +0 -0
  59. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/odp/odp_segment_api_manager.py +0 -0
  60. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/odp/odp_segment_manager.py +0 -0
  61. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/odp/optimizely_odp_option.py +0 -0
  62. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/optimizely_config.py +0 -0
  63. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/optimizely_factory.py +0 -0
  64. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/py.typed +0 -0
  65. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely/user_profile.py +0 -0
  66. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely_sdk.egg-info/dependency_links.txt +0 -0
  67. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely_sdk.egg-info/requires.txt +5 -5
  68. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/optimizely_sdk.egg-info/top_level.txt +0 -0
  69. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/requirements/core.txt +0 -0
  70. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/requirements/docs.txt +0 -0
  71. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/requirements/test.txt +0 -0
  72. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/requirements/typing.txt +0 -0
  73. {optimizely_sdk-5.5.0 → optimizely_sdk-5.7.0}/setup.cfg +0 -0
  74. {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.5.0
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: requests>=2.21; extra == "test"
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: python-coveralls; extra == "test"
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.key] = user_attributes[attribute.key]
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 DecisionResult(TypedDict):
68
- """
69
- A TypedDict representing the result of a decision process.
67
+ class _DecisionResultOptional(TypedDict, total=False):
68
+ holdout_decision: Decision
70
69
 
71
- Attributes:
72
- decision (Decision): The decision object containing the outcome of the evaluation.
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
- # Check holdouts
737
- holdouts = project_config.get_holdouts_for_flag(feature_flag.key)
738
- for holdout in holdouts:
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
- # If no holdout decision, check experiments then rollouts
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
- return {
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
- return {
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
- return {
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(experiment_layerId, experiment_id, variation_id, metadata)
146
+ decision = payload.Decision(
147
+ normalized_campaign_id, experiment_id, normalized_variation_id, metadata
148
+ )
141
149
  snapshot_event = payload.SnapshotEvent(
142
- experiment_layerId, event.uuid, cls.ACTIVATE_EVENT_KEY, event.timestamp,
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__(self, campaign_id: str, experiment_id: str, variation_id: str, metadata: Metadata):
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, str | int]]]:
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, str | int]]] = {}
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: variation_id,
197
- self.EventParams.CAMPAIGN_ID: experiment.layerId,
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: experiment.layerId,
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, user_id: str) -> None:
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
- {OdpManagerConfig.KEY_FOR_USER_ID: user_id}, {})
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, user_id: str) -> None:
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(user_id)
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, user_id: str) -> None:
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(user_id)
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
- self.client._identify_user(user_id)
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
- holdouts_data: list[types.HoldoutDict] = config.get('holdouts', [])
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
- self.flag_holdouts_map: dict[str, list[entities.Holdout]] = {}
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 get_holdouts_for_flag(self, flag_key: str) -> list[entities.Holdout]:
882
- """ Helper method to get holdouts from an applied feature flag.
917
+ def get_global_holdouts(self) -> list[entities.Holdout]:
918
+ """Return all global holdouts (parsed from the top-level 'holdouts' section).
883
919
 
884
- Args:
885
- flag_key: Key of the feature flag.
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
- The holdouts that apply for a specific flag as Holdout entity objects.
925
+ List of global Holdout entities that are currently running.
889
926
  """
890
- if not self.holdouts:
891
- return []
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
- return self.flag_holdouts_map.get(flag_key, [])
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.
@@ -11,5 +11,5 @@
11
11
  # See the License for the specific language governing permissions and
12
12
  # limitations under the License.
13
13
 
14
- version_info = (5, 5, 0)
14
+ version_info = (5, 7, 0)
15
15
  __version__ = '.'.join(str(v) for v in version_info)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optimizely-sdk
3
- Version: 5.5.0
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: requests>=2.21; extra == "test"
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: python-coveralls; extra == "test"
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
@@ -7,16 +7,16 @@ idna>=2.10
7
7
  rpds-py<0.20.0
8
8
 
9
9
  [test]
10
- requests>=2.21
10
+ funcsigs>=0.4
11
11
  idna>=2.10
12
12
  ruff>=0.15.0
13
- python-coveralls
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