optimizely-sdk 5.5.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.5.0 → optimizely_sdk-5.6.0}/CHANGELOG.md +21 -0
  2. {optimizely_sdk-5.5.0/optimizely_sdk.egg-info → optimizely_sdk-5.6.0}/PKG-INFO +29 -8
  3. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/cmab/cmab_service.py +1 -1
  4. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/decision_service.py +44 -4
  5. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/entities.py +14 -0
  6. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/event/event_factory.py +11 -3
  7. optimizely_sdk-5.6.0/optimizely/event/event_id_normalizer.py +93 -0
  8. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/event/payload.py +8 -2
  9. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/event_builder.py +12 -6
  10. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/helpers/types.py +3 -0
  11. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/odp/odp_event_manager.py +23 -2
  12. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/odp/odp_manager.py +2 -2
  13. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/optimizely.py +2 -2
  14. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/optimizely_user_context.py +3 -1
  15. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/project_config.py +69 -18
  16. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/version.py +1 -1
  17. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0/optimizely_sdk.egg-info}/PKG-INFO +29 -8
  18. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely_sdk.egg-info/SOURCES.txt +1 -0
  19. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/LICENSE +0 -0
  20. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/MANIFEST.in +0 -0
  21. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/README.md +0 -0
  22. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/__init__.py +0 -0
  23. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/bucketer.py +0 -0
  24. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/cmab/__init__.py +0 -0
  25. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/cmab/cmab_client.py +0 -0
  26. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/config_manager.py +0 -0
  27. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/decision/__init__.py +0 -0
  28. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/decision/optimizely_decide_option.py +0 -0
  29. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/decision/optimizely_decision.py +0 -0
  30. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/decision/optimizely_decision_message.py +0 -0
  31. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/error_handler.py +0 -0
  32. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/event/__init__.py +0 -0
  33. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/event/event_processor.py +0 -0
  34. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/event/log_event.py +0 -0
  35. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/event/user_event.py +0 -0
  36. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/event/user_event_factory.py +0 -0
  37. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/event_dispatcher.py +0 -0
  38. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/exceptions.py +0 -0
  39. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/helpers/__init__.py +0 -0
  40. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/helpers/audience.py +0 -0
  41. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/helpers/condition.py +0 -0
  42. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/helpers/condition_tree_evaluator.py +0 -0
  43. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/helpers/constants.py +0 -0
  44. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/helpers/enums.py +0 -0
  45. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/helpers/event_tag_utils.py +0 -0
  46. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/helpers/experiment.py +0 -0
  47. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/helpers/sdk_settings.py +0 -0
  48. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/helpers/validator.py +0 -0
  49. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/lib/__init__.py +0 -0
  50. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/lib/pymmh3.py +0 -0
  51. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/logger.py +0 -0
  52. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/notification_center.py +0 -0
  53. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/notification_center_registry.py +0 -0
  54. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/odp/__init__.py +0 -0
  55. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/odp/lru_cache.py +0 -0
  56. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/odp/odp_config.py +0 -0
  57. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/odp/odp_event.py +0 -0
  58. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/odp/odp_event_api_manager.py +0 -0
  59. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/odp/odp_segment_api_manager.py +0 -0
  60. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/odp/odp_segment_manager.py +0 -0
  61. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/odp/optimizely_odp_option.py +0 -0
  62. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/optimizely_config.py +0 -0
  63. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/optimizely_factory.py +0 -0
  64. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/py.typed +0 -0
  65. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely/user_profile.py +0 -0
  66. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely_sdk.egg-info/dependency_links.txt +0 -0
  67. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely_sdk.egg-info/requires.txt +6 -6
  68. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/optimizely_sdk.egg-info/top_level.txt +0 -0
  69. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/requirements/core.txt +0 -0
  70. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/requirements/docs.txt +0 -0
  71. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/requirements/test.txt +0 -0
  72. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/requirements/typing.txt +0 -0
  73. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/setup.cfg +0 -0
  74. {optimizely_sdk-5.5.0 → optimizely_sdk-5.6.0}/setup.py +0 -0
@@ -1,5 +1,26 @@
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
+
3
24
  ## 5.5.0
4
25
  April 30th, 2026
5
26
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optimizely-sdk
3
- Version: 5.5.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
@@ -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"
28
- Requires-Dist: idna>=2.10; extra == "test"
29
- Requires-Dist: ruff>=0.15.0; extra == "test"
30
- Requires-Dist: python-coveralls; extra == "test"
27
+ Requires-Dist: pytest>=6.2.0; extra == "test"
31
28
  Requires-Dist: coverage; extra == "test"
32
29
  Requires-Dist: pytest-cov; extra == "test"
33
- Requires-Dist: rpds-py<0.20.0; python_version < "3.11" and extra == "test"
34
30
  Requires-Dist: jsonschema>=3.2.0; extra == "test"
35
- Requires-Dist: funcsigs>=0.4; extra == "test"
36
- Requires-Dist: pytest>=6.2.0; extra == "test"
37
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
38
  Dynamic: author
39
39
  Dynamic: author-email
40
40
  Dynamic: classifier
@@ -304,6 +304,27 @@ 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.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
+
307
328
  ## 5.5.0
308
329
  April 30th, 2026
309
330
 
@@ -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
 
@@ -610,6 +610,24 @@ class DecisionService:
610
610
  return Decision(experiment=rule, variation=forced_decision_variation,
611
611
  source=enums.DecisionSources.ROLLOUT, cmab_uuid=None), decide_reasons
612
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
+
613
631
  bucketing_id, bucket_reasons = self._get_bucketing_id(user_id, attributes)
614
632
  decide_reasons += bucket_reasons
615
633
 
@@ -733,9 +751,9 @@ class DecisionService:
733
751
  reasons = decide_reasons.copy() if decide_reasons else []
734
752
  user_id = user_context.user_id
735
753
 
736
- # Check holdouts
737
- holdouts = project_config.get_holdouts_for_flag(feature_flag.key)
738
- 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:
739
757
  holdout_decision = self.get_variation_for_holdout(holdout, user_context, project_config)
740
758
  reasons.extend(holdout_decision['reasons'])
741
759
 
@@ -756,7 +774,7 @@ class DecisionService:
756
774
  'reasons': reasons
757
775
  }
758
776
 
759
- # If no holdout decision, check experiments then rollouts
777
+ # If no global holdout decision, check experiments then rollouts
760
778
  if feature_flag.experimentIds:
761
779
  for experiment_id in feature_flag.experimentIds:
762
780
  experiment = project_config.get_experiment_from_id(experiment_id)
@@ -778,6 +796,28 @@ class DecisionService:
778
796
  'reasons': reasons
779
797
  }
780
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
+
781
821
  # Get variation for experiment
782
822
  variation_result = self.get_variation(
783
823
  project_config, experiment, user_context, user_profile_tracker, reasons, decide_options
@@ -223,6 +223,7 @@ 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,
226
227
  **kwargs: Any
227
228
  ):
228
229
  self.id = id
@@ -232,6 +233,9 @@ class Holdout(BaseEntity):
232
233
  self.trafficAllocation = trafficAllocation
233
234
  self.audienceIds = audienceIds
234
235
  self.audienceConditions = audienceConditions
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
235
239
 
236
240
  def get_audience_conditions_or_ids(self) -> Sequence[str | list[str]]:
237
241
  """Returns audienceConditions if present, otherwise audienceIds.
@@ -241,6 +245,16 @@ class Holdout(BaseEntity):
241
245
  """
242
246
  return self.audienceConditions if self.audienceConditions is not None else self.audienceIds
243
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
+
244
258
  @property
245
259
  def is_activated(self) -> bool:
246
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()),
@@ -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
  """
@@ -1541,7 +1541,7 @@ class Optimizely:
1541
1541
  config.all_segments
1542
1542
  )
1543
1543
 
1544
- def _identify_user(self, user_id: str) -> None:
1544
+ def _identify_user(self, identifiers: dict[str, str]) -> None:
1545
1545
  if not self.is_valid:
1546
1546
  self.logger.error(enums.Errors.INVALID_OPTIMIZELY.format('identify_user'))
1547
1547
  return
@@ -1551,7 +1551,7 @@ class Optimizely:
1551
1551
  self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('identify_user'))
1552
1552
  return
1553
1553
 
1554
- self.odp_manager.identify_user(user_id)
1554
+ self.odp_manager.identify_user(identifiers)
1555
1555
 
1556
1556
  def _fetch_qualified_segments(self, user_id: str, options: Optional[list[str]] = None) -> Optional[list[str]]:
1557
1557
  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, 6, 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.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
@@ -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"
28
- Requires-Dist: idna>=2.10; extra == "test"
29
- Requires-Dist: ruff>=0.15.0; extra == "test"
30
- Requires-Dist: python-coveralls; extra == "test"
27
+ Requires-Dist: pytest>=6.2.0; extra == "test"
31
28
  Requires-Dist: coverage; extra == "test"
32
29
  Requires-Dist: pytest-cov; extra == "test"
33
- Requires-Dist: rpds-py<0.20.0; python_version < "3.11" and extra == "test"
34
30
  Requires-Dist: jsonschema>=3.2.0; extra == "test"
35
- Requires-Dist: funcsigs>=0.4; extra == "test"
36
- Requires-Dist: pytest>=6.2.0; extra == "test"
37
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
38
  Dynamic: author
39
39
  Dynamic: author-email
40
40
  Dynamic: classifier
@@ -304,6 +304,27 @@ 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.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
+
307
328
  ## 5.5.0
308
329
  April 30th, 2026
309
330
 
@@ -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
11
- idna>=2.10
12
- ruff>=0.15.0
13
- python-coveralls
10
+ pytest>=6.2.0
14
11
  coverage
15
12
  pytest-cov
16
13
  jsonschema>=3.2.0
17
- funcsigs>=0.4
18
- pytest>=6.2.0
19
14
  pyrsistent>=0.16.0
15
+ requests>=2.21
16
+ python-coveralls
17
+ funcsigs>=0.4
18
+ idna>=2.10
19
+ ruff>=0.15.0
20
20
 
21
21
  [test:python_version < "3.11"]
22
22
  rpds-py<0.20.0
File without changes
File without changes