optimizely-sdk 5.4.0__tar.gz → 5.6.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.4.0 → optimizely_sdk-5.6.0}/CHANGELOG.md +49 -0
  2. {optimizely-sdk-5.4.0/optimizely_sdk.egg-info → optimizely_sdk-5.6.0}/PKG-INFO +78 -3
  3. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/cmab/cmab_service.py +1 -1
  4. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/config_manager.py +6 -9
  5. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/decision_service.py +49 -4
  6. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/entities.py +16 -4
  7. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/event/event_factory.py +11 -3
  8. optimizely_sdk-5.6.0/optimizely/event/event_id_normalizer.py +93 -0
  9. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/event/payload.py +8 -2
  10. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/event_builder.py +12 -6
  11. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/event_dispatcher.py +1 -1
  12. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/enums.py +8 -0
  13. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/types.py +7 -2
  14. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/validator.py +2 -2
  15. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/odp/odp_event_manager.py +31 -3
  16. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/odp/odp_manager.py +2 -2
  17. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/optimizely.py +2 -2
  18. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/optimizely_user_context.py +3 -1
  19. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/project_config.py +133 -50
  20. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/version.py +1 -1
  21. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0/optimizely_sdk.egg-info}/PKG-INFO +78 -3
  22. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely_sdk.egg-info/SOURCES.txt +1 -0
  23. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely_sdk.egg-info/requires.txt +6 -6
  24. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/requirements/test.txt +1 -1
  25. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/LICENSE +0 -0
  26. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/MANIFEST.in +0 -0
  27. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/README.md +0 -0
  28. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/__init__.py +0 -0
  29. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/bucketer.py +0 -0
  30. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/cmab/__init__.py +0 -0
  31. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/cmab/cmab_client.py +0 -0
  32. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/decision/__init__.py +0 -0
  33. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/decision/optimizely_decide_option.py +0 -0
  34. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/decision/optimizely_decision.py +0 -0
  35. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/decision/optimizely_decision_message.py +0 -0
  36. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/error_handler.py +0 -0
  37. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/event/__init__.py +0 -0
  38. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/event/event_processor.py +0 -0
  39. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/event/log_event.py +0 -0
  40. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/event/user_event.py +0 -0
  41. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/event/user_event_factory.py +0 -0
  42. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/exceptions.py +0 -0
  43. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/__init__.py +0 -0
  44. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/audience.py +0 -0
  45. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/condition.py +0 -0
  46. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/condition_tree_evaluator.py +0 -0
  47. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/constants.py +0 -0
  48. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/event_tag_utils.py +0 -0
  49. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/experiment.py +0 -0
  50. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/helpers/sdk_settings.py +0 -0
  51. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/lib/__init__.py +0 -0
  52. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/lib/pymmh3.py +0 -0
  53. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/logger.py +0 -0
  54. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/notification_center.py +0 -0
  55. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/notification_center_registry.py +0 -0
  56. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/odp/__init__.py +0 -0
  57. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/odp/lru_cache.py +0 -0
  58. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/odp/odp_config.py +0 -0
  59. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/odp/odp_event.py +0 -0
  60. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/odp/odp_event_api_manager.py +0 -0
  61. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/odp/odp_segment_api_manager.py +0 -0
  62. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/odp/odp_segment_manager.py +0 -0
  63. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/odp/optimizely_odp_option.py +0 -0
  64. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/optimizely_config.py +0 -0
  65. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/optimizely_factory.py +0 -0
  66. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/py.typed +0 -0
  67. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely/user_profile.py +0 -0
  68. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely_sdk.egg-info/dependency_links.txt +0 -0
  69. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/optimizely_sdk.egg-info/top_level.txt +0 -0
  70. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/requirements/core.txt +0 -0
  71. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/requirements/docs.txt +0 -0
  72. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/requirements/typing.txt +0 -0
  73. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/setup.cfg +0 -0
  74. {optimizely-sdk-5.4.0 → optimizely_sdk-5.6.0}/setup.py +0 -0
@@ -1,5 +1,54 @@
1
1
  # Optimizely Python SDK Changelog
2
2
 
3
+ ## 5.6.0
4
+ July 10th, 2026
5
+
6
+ ### New Features
7
+
8
+ #### **Local Holdouts Support**
9
+
10
+ - Added local holdouts support to the Python SDK, enabling flag-level traffic exclusion from experiments. ([#512](https://github.com/optimizely/python-sdk/pull/512))
11
+ - Added `localHoldouts` datafile section parsing for backward compatibility with older datafile formats. ([#517](https://github.com/optimizely/python-sdk/pull/517))
12
+
13
+ ---
14
+
15
+ ### Bug Fixes
16
+
17
+ - 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))
18
+ - Fixed CMAB prediction requests to use attribute ID instead of attribute key. ([#520](https://github.com/optimizely/python-sdk/pull/520))
19
+ - Fixed ODP identify events being sent with a single identifier, which is a no-op. ([#513](https://github.com/optimizely/python-sdk/pull/513))
20
+
21
+ ---
22
+
23
+
24
+ ## 5.5.0
25
+ April 30th, 2026
26
+
27
+ ### New Features
28
+
29
+ #### **Feature Rollout Support**
30
+
31
+ - Added support for Feature Rollouts, a new rule type that combines Targeted Delivery simplicity with A/B test measurement capabilities. During project config parsing, the "everyone else" variation from the flag's rollout is automatically injected into feature rollout experiments, enabling correct evaluation without changes to decision logic. ([#499](https://github.com/optimizely/python-sdk/pull/499))
32
+ - Removed experiment type validation from config parsing to support flexible rule types. ([#500](https://github.com/optimizely/python-sdk/pull/500))
33
+
34
+ ---
35
+
36
+ ### Enhancements
37
+
38
+ - Added event retry support for reliable event dispatching. ([#475](https://github.com/optimizely/python-sdk/pull/475))
39
+ - Excluded CMAB experiments from UserProfileService to prevent stale cached decisions. ([#474](https://github.com/optimizely/python-sdk/pull/474))
40
+ - Cleaned up flag-level holdout fields for simplified configuration. ([#507](https://github.com/optimizely/python-sdk/pull/507))
41
+
42
+ ---
43
+
44
+ ### Bug Fixes
45
+
46
+ - Fixed `return` in `finally` block silently swallowing exceptions. ([#505](https://github.com/optimizely/python-sdk/pull/505))
47
+ - Fixed `PollingConfigManager` rejecting valid URL-only configuration. ([#497](https://github.com/optimizely/python-sdk/pull/497))
48
+
49
+ ---
50
+
51
+
3
52
  ## 5.4.0
4
53
  December 19th, 2025
5
54
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: optimizely-sdk
3
- Version: 5.4.0
3
+ Version: 5.6.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
@@ -17,8 +17,34 @@ Classifier: Programming Language :: Python :: 3.10
17
17
  Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Description-Content-Type: text/markdown
20
- Provides-Extra: test
21
20
  License-File: LICENSE
21
+ Requires-Dist: jsonschema>=3.2.0
22
+ Requires-Dist: pyrsistent>=0.16.0
23
+ Requires-Dist: requests>=2.21
24
+ Requires-Dist: idna>=2.10
25
+ Requires-Dist: rpds-py<0.20.0; python_version < "3.11"
26
+ Provides-Extra: test
27
+ Requires-Dist: pytest>=6.2.0; extra == "test"
28
+ Requires-Dist: coverage; extra == "test"
29
+ Requires-Dist: pytest-cov; extra == "test"
30
+ Requires-Dist: jsonschema>=3.2.0; extra == "test"
31
+ Requires-Dist: pyrsistent>=0.16.0; extra == "test"
32
+ Requires-Dist: requests>=2.21; extra == "test"
33
+ Requires-Dist: rpds-py<0.20.0; python_version < "3.11" and extra == "test"
34
+ Requires-Dist: python-coveralls; extra == "test"
35
+ Requires-Dist: funcsigs>=0.4; extra == "test"
36
+ Requires-Dist: idna>=2.10; extra == "test"
37
+ Requires-Dist: ruff>=0.15.0; extra == "test"
38
+ Dynamic: author
39
+ Dynamic: author-email
40
+ Dynamic: classifier
41
+ Dynamic: description
42
+ Dynamic: description-content-type
43
+ Dynamic: home-page
44
+ Dynamic: license-file
45
+ Dynamic: provides-extra
46
+ Dynamic: requires-dist
47
+ Dynamic: summary
22
48
 
23
49
  Optimizely Feature Experimentation is A/B testing and feature management for product development teams. Experiment in any application. Make every feature on your roadmap an opportunity to learn. Learn more at https://www.optimizely.com/products/experiment/feature-experimentation/ or see our documentation at https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/welcome. # Optimizely Python SDK
24
50
 
@@ -278,6 +304,55 @@ idna (BSD 3-Clause License https://github.com/kjd/idna/blob/master/LICENSE.md)
278
304
  - Swift - https://github.com/optimizely/swift-sdk
279
305
  # Optimizely Python SDK Changelog
280
306
 
307
+ ## 5.6.0
308
+ July 10th, 2026
309
+
310
+ ### New Features
311
+
312
+ #### **Local Holdouts Support**
313
+
314
+ - Added local holdouts support to the Python SDK, enabling flag-level traffic exclusion from experiments. ([#512](https://github.com/optimizely/python-sdk/pull/512))
315
+ - Added `localHoldouts` datafile section parsing for backward compatibility with older datafile formats. ([#517](https://github.com/optimizely/python-sdk/pull/517))
316
+
317
+ ---
318
+
319
+ ### Bug Fixes
320
+
321
+ - 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))
322
+ - Fixed CMAB prediction requests to use attribute ID instead of attribute key. ([#520](https://github.com/optimizely/python-sdk/pull/520))
323
+ - Fixed ODP identify events being sent with a single identifier, which is a no-op. ([#513](https://github.com/optimizely/python-sdk/pull/513))
324
+
325
+ ---
326
+
327
+
328
+ ## 5.5.0
329
+ April 30th, 2026
330
+
331
+ ### New Features
332
+
333
+ #### **Feature Rollout Support**
334
+
335
+ - Added support for Feature Rollouts, a new rule type that combines Targeted Delivery simplicity with A/B test measurement capabilities. During project config parsing, the "everyone else" variation from the flag's rollout is automatically injected into feature rollout experiments, enabling correct evaluation without changes to decision logic. ([#499](https://github.com/optimizely/python-sdk/pull/499))
336
+ - Removed experiment type validation from config parsing to support flexible rule types. ([#500](https://github.com/optimizely/python-sdk/pull/500))
337
+
338
+ ---
339
+
340
+ ### Enhancements
341
+
342
+ - Added event retry support for reliable event dispatching. ([#475](https://github.com/optimizely/python-sdk/pull/475))
343
+ - Excluded CMAB experiments from UserProfileService to prevent stale cached decisions. ([#474](https://github.com/optimizely/python-sdk/pull/474))
344
+ - Cleaned up flag-level holdout fields for simplified configuration. ([#507](https://github.com/optimizely/python-sdk/pull/507))
345
+
346
+ ---
347
+
348
+ ### Bug Fixes
349
+
350
+ - Fixed `return` in `finally` block silently swallowing exceptions. ([#505](https://github.com/optimizely/python-sdk/pull/505))
351
+ - Fixed `PollingConfigManager` rejecting valid URL-only configuration. ([#497](https://github.com/optimizely/python-sdk/pull/497))
352
+
353
+ ---
354
+
355
+
281
356
  ## 5.4.0
282
357
  December 19th, 2025
283
358
 
@@ -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
 
@@ -145,14 +145,14 @@ class StaticConfigManager(BaseConfigManager):
145
145
  except optimizely_exceptions.UnsupportedDatafileVersionException as error:
146
146
  error_msg = error.args[0]
147
147
  error_to_handle = error
148
- except:
148
+ except Exception:
149
149
  error_msg = enums.Errors.INVALID_INPUT.format('datafile')
150
150
  error_to_handle = optimizely_exceptions.InvalidInputException(error_msg)
151
- finally:
152
- if error_msg or config is None:
153
- self.logger.error(error_msg)
154
- self.error_handler.handle_error(error_to_handle or Exception('Unknown Error'))
155
- return
151
+
152
+ if error_msg or config is None:
153
+ self.logger.error(error_msg)
154
+ self.error_handler.handle_error(error_to_handle or Exception('Unknown Error'))
155
+ return
156
156
 
157
157
  previous_revision = self._config.get_revision() if self._config else None
158
158
 
@@ -236,9 +236,6 @@ class PollingConfigManager(StaticConfigManager):
236
236
  )
237
237
  self._sdk_key = sdk_key or self._sdk_key
238
238
 
239
- if self._sdk_key is None:
240
- raise optimizely_exceptions.InvalidInputException(enums.Errors.MISSING_SDK_KEY)
241
-
242
239
  self.datafile_url = self.get_datafile_url(
243
240
  self._sdk_key, url, url_template or self.DATAFILE_URL_TEMPLATE
244
241
  )
@@ -515,6 +515,11 @@ class DecisionService:
515
515
  'reasons': decide_reasons,
516
516
  'variation': None
517
517
  }
518
+ ignore_user_profile = True
519
+ self.logger.debug(
520
+ f'Skipping user profile service for CMAB experiment "{experiment.key}". '
521
+ f'CMAB decisions are dynamic and not stored for sticky bucketing.'
522
+ )
518
523
  variation_id = cmab_decision['variation_id'] if cmab_decision else None
519
524
  cmab_uuid = cmab_decision['cmab_uuid'] if cmab_decision else None
520
525
  variation = project_config.get_variation_from_id(experiment_key=experiment.key,
@@ -605,6 +610,24 @@ class DecisionService:
605
610
  return Decision(experiment=rule, variation=forced_decision_variation,
606
611
  source=enums.DecisionSources.ROLLOUT, cmab_uuid=None), decide_reasons
607
612
 
613
+ # Check local holdouts targeting this specific delivery rule (FSSDK-12369)
614
+ local_holdouts = project_config.get_holdouts_for_rule(rule.id)
615
+ for holdout in local_holdouts:
616
+ local_holdout_decision = self.get_variation_for_holdout(
617
+ holdout, user_context, project_config
618
+ )
619
+ decide_reasons.extend(local_holdout_decision['reasons'])
620
+
621
+ local_decision = local_holdout_decision['decision']
622
+ if local_decision.variation is not None:
623
+ message = (
624
+ f"The user '{user_id}' is bucketed into local holdout '{holdout.key}' "
625
+ f"for delivery rule '{rule.key}'."
626
+ )
627
+ self.logger.info(message)
628
+ decide_reasons.append(message)
629
+ return local_decision, decide_reasons
630
+
608
631
  bucketing_id, bucket_reasons = self._get_bucketing_id(user_id, attributes)
609
632
  decide_reasons += bucket_reasons
610
633
 
@@ -728,9 +751,9 @@ class DecisionService:
728
751
  reasons = decide_reasons.copy() if decide_reasons else []
729
752
  user_id = user_context.user_id
730
753
 
731
- # Check holdouts
732
- holdouts = project_config.get_holdouts_for_flag(feature_flag.key)
733
- for holdout in holdouts:
754
+ # Check global holdouts (flag level — before any rules are evaluated)
755
+ global_holdouts = project_config.get_global_holdouts()
756
+ for holdout in global_holdouts:
734
757
  holdout_decision = self.get_variation_for_holdout(holdout, user_context, project_config)
735
758
  reasons.extend(holdout_decision['reasons'])
736
759
 
@@ -751,7 +774,7 @@ class DecisionService:
751
774
  'reasons': reasons
752
775
  }
753
776
 
754
- # If no holdout decision, check experiments then rollouts
777
+ # If no global holdout decision, check experiments then rollouts
755
778
  if feature_flag.experimentIds:
756
779
  for experiment_id in feature_flag.experimentIds:
757
780
  experiment = project_config.get_experiment_from_id(experiment_id)
@@ -773,6 +796,28 @@ class DecisionService:
773
796
  'reasons': reasons
774
797
  }
775
798
 
799
+ # Check local holdouts targeting this specific experiment rule (FSSDK-12369)
800
+ local_holdouts = project_config.get_holdouts_for_rule(experiment.id)
801
+ for holdout in local_holdouts:
802
+ local_holdout_decision = self.get_variation_for_holdout(
803
+ holdout, user_context, project_config
804
+ )
805
+ reasons.extend(local_holdout_decision['reasons'])
806
+
807
+ local_decision = local_holdout_decision['decision']
808
+ if local_decision.variation is not None:
809
+ message = (
810
+ f"The user '{user_id}' is bucketed into local holdout '{holdout.key}' "
811
+ f"for experiment rule '{experiment.key}'."
812
+ )
813
+ self.logger.info(message)
814
+ reasons.append(message)
815
+ return {
816
+ 'decision': local_holdout_decision['decision'],
817
+ 'error': False,
818
+ 'reasons': reasons
819
+ }
820
+
776
821
  # Get variation for experiment
777
822
  variation_result = self.get_variation(
778
823
  project_config, experiment, user_context, user_profile_tracker, reasons, decide_options
@@ -87,6 +87,7 @@ class Experiment(BaseEntity):
87
87
  groupId: Optional[str] = None,
88
88
  groupPolicy: Optional[str] = None,
89
89
  cmab: Optional[CmabDict] = None,
90
+ type: Optional[str] = None,
90
91
  **kwargs: Any
91
92
  ):
92
93
  self.id = id
@@ -101,6 +102,7 @@ class Experiment(BaseEntity):
101
102
  self.groupId = groupId
102
103
  self.groupPolicy = groupPolicy
103
104
  self.cmab = cmab
105
+ self.type = type
104
106
 
105
107
  def get_audience_conditions_or_ids(self) -> Sequence[str | list[str]]:
106
108
  """ Returns audienceConditions if present, otherwise audienceIds. """
@@ -220,9 +222,8 @@ class Holdout(BaseEntity):
220
222
  variations: list[VariationDict],
221
223
  trafficAllocation: list[TrafficAllocation],
222
224
  audienceIds: list[str],
223
- includedFlags: Optional[list[str]] = None,
224
- excludedFlags: Optional[list[str]] = None,
225
225
  audienceConditions: Optional[Sequence[str | list[str]]] = None,
226
+ includedRules: Optional[list[str]] = None,
226
227
  **kwargs: Any
227
228
  ):
228
229
  self.id = id
@@ -232,8 +233,9 @@ class Holdout(BaseEntity):
232
233
  self.trafficAllocation = trafficAllocation
233
234
  self.audienceIds = audienceIds
234
235
  self.audienceConditions = audienceConditions
235
- self.includedFlags = includedFlags or []
236
- self.excludedFlags = excludedFlags or []
236
+ # Per-rule targeting for local holdouts. Scope comes from the datafile
237
+ # section, not this field; ProjectConfig strips it on 'holdouts' entries.
238
+ self.included_rules: Optional[list[str]] = includedRules
237
239
 
238
240
  def get_audience_conditions_or_ids(self) -> Sequence[str | list[str]]:
239
241
  """Returns audienceConditions if present, otherwise audienceIds.
@@ -243,6 +245,16 @@ class Holdout(BaseEntity):
243
245
  """
244
246
  return self.audienceConditions if self.audienceConditions is not None else self.audienceIds
245
247
 
248
+ @property
249
+ def is_global(self) -> bool:
250
+ """True if this is a global holdout.
251
+
252
+ Scope is set by the datafile section ('holdouts' vs 'localHoldouts').
253
+ ProjectConfig strips 'includedRules' on 'holdouts' entries, so this
254
+ property stays consistent with section membership.
255
+ """
256
+ return self.included_rules is None
257
+
246
258
  @property
247
259
  def is_activated(self) -> bool:
248
260
  """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()),
@@ -49,7 +49,7 @@ class EventDispatcher:
49
49
  session = requests.Session()
50
50
 
51
51
  retries = Retry(total=EventDispatchConfig.RETRIES,
52
- backoff_factor=0.1,
52
+ backoff_factor=0.2,
53
53
  status_forcelist=[500, 502, 503, 504])
54
54
  adapter = HTTPAdapter(max_retries=retries)
55
55
 
@@ -229,6 +229,14 @@ class OdpManagerConfig:
229
229
  EVENT_TYPE: Final = 'fullstack'
230
230
 
231
231
 
232
+ class ExperimentTypes:
233
+ ab: Final = 'ab'
234
+ mab: Final = 'mab'
235
+ cmab: Final = 'cmab'
236
+ td: Final = 'td'
237
+ fr: Final = 'fr'
238
+
239
+
232
240
  class OdpSegmentsCacheConfig:
233
241
  """ODP Segment Cache configs."""
234
242
  DEFAULT_CAPACITY: Final = 10_000
@@ -117,6 +117,10 @@ class CmabDict(BaseEntity):
117
117
  trafficAllocation: int
118
118
 
119
119
 
120
+ ExperimentType = Literal[
121
+ 'ab', 'mab', 'cmab', 'td', 'fr'
122
+ ]
123
+
120
124
  HoldoutStatus = Literal['Draft', 'Running', 'Concluded', 'Archived']
121
125
 
122
126
 
@@ -126,5 +130,6 @@ class HoldoutDict(ExperimentDict):
126
130
  Extends ExperimentDict with holdout-specific properties.
127
131
  """
128
132
  holdoutStatus: HoldoutStatus
129
- includedFlags: list[str]
130
- excludedFlags: list[str]
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]]
@@ -193,7 +193,7 @@ def is_user_profile_valid(user_profile: dict[str, Any]) -> bool:
193
193
  if not user_profile:
194
194
  return False
195
195
 
196
- if not type(user_profile) is dict:
196
+ if type(user_profile) is not dict:
197
197
  return False
198
198
 
199
199
  if UserProfile.USER_ID_KEY not in user_profile:
@@ -203,7 +203,7 @@ def is_user_profile_valid(user_profile: dict[str, Any]) -> bool:
203
203
  return False
204
204
 
205
205
  experiment_bucket_map = user_profile.get(UserProfile.EXPERIMENT_BUCKET_MAP_KEY)
206
- if not type(experiment_bucket_map) is dict:
206
+ if type(experiment_bucket_map) is not dict:
207
207
  return False
208
208
 
209
209
  for decision in experiment_bucket_map.values():
@@ -163,6 +163,8 @@ class OdpEventManager:
163
163
 
164
164
  self.logger.debug(f'ODP event queue: flushing batch size {batch_len}.')
165
165
  should_retry = False
166
+ initial_retry_interval = 0.2 # 200ms
167
+ max_retry_interval = 1.0 # 1 second
166
168
 
167
169
  for i in range(1 + self.retry_count):
168
170
  try:
@@ -176,7 +178,12 @@ class OdpEventManager:
176
178
  if not should_retry:
177
179
  break
178
180
  if i < self.retry_count:
179
- self.logger.debug('Error dispatching ODP events, scheduled to retry.')
181
+ # Exponential backoff: 200ms, 400ms, 800ms, ... capped at 1s
182
+ delay = initial_retry_interval * (2 ** i)
183
+ if delay > max_retry_interval:
184
+ delay = max_retry_interval
185
+ self.logger.debug(f'Error dispatching ODP events, retrying after {delay}s.')
186
+ time.sleep(delay)
180
187
 
181
188
  if should_retry:
182
189
  self.logger.error(Errors.ODP_EVENT_FAILED.format(f'Failed after {i} retries: {self._current_batch}'))
@@ -260,9 +267,30 @@ class OdpEventManager:
260
267
  except Full:
261
268
  self.logger.warning(Errors.ODP_EVENT_FAILED.format("Queue is full"))
262
269
 
263
- 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
+
264
292
  self.send_event(OdpManagerConfig.EVENT_TYPE, 'identified',
265
- {OdpManagerConfig.KEY_FOR_USER_ID: user_id}, {})
293
+ valid_identifiers, {})
266
294
 
267
295
  def update_config(self) -> None:
268
296
  """Adds update config signal to event_queue."""