optimizely-sdk 5.6.0__tar.gz → 5.8.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.6.0 → optimizely_sdk-5.8.0}/CHANGELOG.md +27 -0
  2. {optimizely_sdk-5.6.0/optimizely_sdk.egg-info → optimizely_sdk-5.8.0}/PKG-INFO +34 -7
  3. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/bucketer.py +1 -8
  4. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/decision/optimizely_decide_option.py +1 -6
  5. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/decision/optimizely_decision_message.py +1 -6
  6. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/decision_service.py +98 -60
  7. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/entities.py +3 -7
  8. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/event/event_factory.py +1 -7
  9. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/event/event_processor.py +1 -8
  10. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/event/log_event.py +1 -8
  11. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/event/user_event.py +1 -8
  12. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/event_builder.py +1 -7
  13. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/event_dispatcher.py +1 -6
  14. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/helpers/condition.py +1 -8
  15. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/helpers/enums.py +1 -6
  16. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/helpers/event_tag_utils.py +1 -7
  17. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/helpers/types.py +1 -8
  18. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/logger.py +1 -7
  19. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/notification_center.py +1 -7
  20. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/odp/lru_cache.py +1 -7
  21. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/odp/optimizely_odp_option.py +1 -6
  22. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/optimizely.py +28 -3
  23. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/project_config.py +1 -7
  24. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/user_profile.py +5 -11
  25. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/version.py +1 -1
  26. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0/optimizely_sdk.egg-info}/PKG-INFO +34 -7
  27. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/LICENSE +0 -0
  28. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/MANIFEST.in +0 -0
  29. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/README.md +0 -0
  30. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/__init__.py +0 -0
  31. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/cmab/__init__.py +0 -0
  32. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/cmab/cmab_client.py +0 -0
  33. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/cmab/cmab_service.py +0 -0
  34. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/config_manager.py +0 -0
  35. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/decision/__init__.py +0 -0
  36. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/decision/optimizely_decision.py +0 -0
  37. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/error_handler.py +0 -0
  38. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/event/__init__.py +0 -0
  39. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/event/event_id_normalizer.py +0 -0
  40. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/event/payload.py +0 -0
  41. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/event/user_event_factory.py +0 -0
  42. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/exceptions.py +0 -0
  43. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/helpers/__init__.py +0 -0
  44. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/helpers/audience.py +0 -0
  45. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/helpers/condition_tree_evaluator.py +0 -0
  46. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/helpers/constants.py +0 -0
  47. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/helpers/experiment.py +0 -0
  48. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/helpers/sdk_settings.py +0 -0
  49. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/helpers/validator.py +0 -0
  50. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/lib/__init__.py +0 -0
  51. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/lib/pymmh3.py +0 -0
  52. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/notification_center_registry.py +0 -0
  53. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/odp/__init__.py +0 -0
  54. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/odp/odp_config.py +0 -0
  55. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/odp/odp_event.py +0 -0
  56. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/odp/odp_event_api_manager.py +0 -0
  57. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/odp/odp_event_manager.py +0 -0
  58. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/odp/odp_manager.py +0 -0
  59. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/odp/odp_segment_api_manager.py +0 -0
  60. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/odp/odp_segment_manager.py +0 -0
  61. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/optimizely_config.py +0 -0
  62. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/optimizely_factory.py +0 -0
  63. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/optimizely_user_context.py +0 -0
  64. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely/py.typed +0 -0
  65. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely_sdk.egg-info/SOURCES.txt +0 -0
  66. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely_sdk.egg-info/dependency_links.txt +0 -0
  67. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely_sdk.egg-info/requires.txt +6 -6
  68. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/optimizely_sdk.egg-info/top_level.txt +0 -0
  69. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/requirements/core.txt +0 -0
  70. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/requirements/docs.txt +0 -0
  71. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/requirements/test.txt +0 -0
  72. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/requirements/typing.txt +0 -0
  73. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/setup.cfg +0 -0
  74. {optimizely_sdk-5.6.0 → optimizely_sdk-5.8.0}/setup.py +0 -0
@@ -1,5 +1,32 @@
1
1
  # Optimizely Python SDK Changelog
2
2
 
3
+ ## 5.8.0
4
+ October 30th, 2026
5
+
6
+ ### Changed
7
+
8
+ **Local Holdouts: rule evaluation continues after a holdout hit**:
9
+ When a user matches a local holdout on a rule, the SDK now excludes the user from that rule only
10
+ and continues evaluating the remaining rules, instead of returning the holdout variation and skipping
11
+ the rest of the flag. Each local holdout hit sends its own holdout impression event (using the
12
+ existing event format), regardless of `sendFlagDecisions`. Global holdouts are unchanged.
13
+
14
+ - Continue rule evaluation after local holdout hit ([#525](https://github.com/optimizely/python-sdk/pull/525))
15
+ - Remove dead Python 3.7 typing guards ([#526](https://github.com/optimizely/python-sdk/pull/526))
16
+
17
+ ---
18
+
19
+ ## 5.7.0
20
+ September 2nd, 2026
21
+
22
+ ### New Features
23
+
24
+ **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.
25
+
26
+ - Add holdout exclusion logic for Targeted Delivery rules ([#522](https://github.com/optimizely/python-sdk/pull/522))
27
+
28
+ ---
29
+
3
30
  ## 5.6.0
4
31
  July 10th, 2026
5
32
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optimizely-sdk
3
- Version: 5.6.0
3
+ Version: 5.8.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: funcsigs>=0.4; extra == "test"
28
+ Requires-Dist: python-coveralls; extra == "test"
29
+ Requires-Dist: requests>=2.21; extra == "test"
27
30
  Requires-Dist: pytest>=6.2.0; extra == "test"
28
- Requires-Dist: coverage; extra == "test"
29
- Requires-Dist: pytest-cov; extra == "test"
30
31
  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
32
  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
33
  Requires-Dist: idna>=2.10; extra == "test"
34
+ Requires-Dist: coverage; extra == "test"
35
+ Requires-Dist: pytest-cov; extra == "test"
37
36
  Requires-Dist: ruff>=0.15.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,33 @@ 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.8.0
308
+ October 30th, 2026
309
+
310
+ ### Changed
311
+
312
+ **Local Holdouts: rule evaluation continues after a holdout hit**:
313
+ When a user matches a local holdout on a rule, the SDK now excludes the user from that rule only
314
+ and continues evaluating the remaining rules, instead of returning the holdout variation and skipping
315
+ the rest of the flag. Each local holdout hit sends its own holdout impression event (using the
316
+ existing event format), regardless of `sendFlagDecisions`. Global holdouts are unchanged.
317
+
318
+ - Continue rule evaluation after local holdout hit ([#525](https://github.com/optimizely/python-sdk/pull/525))
319
+ - Remove dead Python 3.7 typing guards ([#526](https://github.com/optimizely/python-sdk/pull/526))
320
+
321
+ ---
322
+
323
+ ## 5.7.0
324
+ September 2nd, 2026
325
+
326
+ ### New Features
327
+
328
+ **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.
329
+
330
+ - Add holdout exclusion logic for Targeted Delivery rules ([#522](https://github.com/optimizely/python-sdk/pull/522))
331
+
332
+ ---
333
+
307
334
  ## 5.6.0
308
335
  July 10th, 2026
309
336
 
@@ -12,19 +12,12 @@
12
12
  # limitations under the License.
13
13
 
14
14
  from __future__ import annotations
15
- from typing import Optional, TYPE_CHECKING, cast
15
+ from typing import Optional, TYPE_CHECKING, cast, Final
16
16
  import math
17
- from sys import version_info
18
17
 
19
18
  from .lib import pymmh3 as mmh3
20
19
 
21
20
 
22
- if version_info < (3, 8):
23
- from typing_extensions import Final
24
- else:
25
- from typing import Final
26
-
27
-
28
21
  if TYPE_CHECKING:
29
22
  # prevent circular dependenacy by skipping import at runtime
30
23
  from .project_config import ProjectConfig
@@ -11,12 +11,7 @@
11
11
  # See the License for the specific language governing permissions and
12
12
  # limitations under the License.
13
13
 
14
- from sys import version_info
15
-
16
- if version_info < (3, 8):
17
- from typing_extensions import Final
18
- else:
19
- from typing import Final
14
+ from typing import Final
20
15
 
21
16
 
22
17
  class OptimizelyDecideOption:
@@ -11,12 +11,7 @@
11
11
  # See the License for the specific language governing permissions and
12
12
  # limitations under the License.
13
13
 
14
- from sys import version_info
15
-
16
- if version_info < (3, 8):
17
- from typing_extensions import Final
18
- else:
19
- from typing import Final
14
+ from typing import Final
20
15
 
21
16
 
22
17
  class OptimizelyDecisionMessage:
@@ -1,4 +1,4 @@
1
- # Copyright 2017-2022, Optimizely
1
+ # Copyright 2017-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
@@ -64,15 +64,12 @@ 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 # Deprecated: first entry of holdout_decisions.
69
+ holdout_decisions: List[Decision]
70
70
 
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
- """
71
+
72
+ class DecisionResult(_DecisionResultOptional):
76
73
  decision: Decision
77
74
  error: bool
78
75
  reasons: List[str]
@@ -556,7 +553,11 @@ class DecisionService:
556
553
  }
557
554
 
558
555
  def get_variation_for_rollout(
559
- self, project_config: ProjectConfig, feature: entities.FeatureFlag, user_context: OptimizelyUserContext
556
+ self,
557
+ project_config: ProjectConfig,
558
+ feature: entities.FeatureFlag,
559
+ user_context: OptimizelyUserContext,
560
+ holdout_decisions: Optional[list[Decision]] = None
560
561
  ) -> tuple[Decision, list[str]]:
561
562
  """ Determine which experiment/variation the user is in for a given rollout.
562
563
  Returns the variation of the first experiment the user qualifies for.
@@ -566,7 +567,7 @@ class DecisionService:
566
567
  flagKey: Feature key.
567
568
  rollout: Rollout for which we are getting the variation.
568
569
  user: ID and attributes for user.
569
- options: Decide options.
570
+ holdout_decisions: If provided, local holdout decisions for skipped rules are appended to it.
570
571
 
571
572
  Returns:
572
573
  Decision namedtuple consisting of experiment and variation for the user and
@@ -610,23 +611,14 @@ class DecisionService:
610
611
  return Decision(experiment=rule, variation=forced_decision_variation,
611
612
  source=enums.DecisionSources.ROLLOUT, cmab_uuid=None), decide_reasons
612
613
 
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
614
+ local_holdout = self._get_local_holdout_decision(
615
+ project_config, rule, user_context, 'delivery', decide_reasons
616
+ )
617
+ if local_holdout is not None:
618
+ if holdout_decisions is not None:
619
+ holdout_decisions.append(local_holdout)
620
+ index += 1
621
+ continue
630
622
 
631
623
  bucketing_id, bucket_reasons = self._get_bucketing_id(user_id, attributes)
632
624
  decide_reasons += bucket_reasons
@@ -751,6 +743,10 @@ class DecisionService:
751
743
  reasons = decide_reasons.copy() if decide_reasons else []
752
744
  user_id = user_context.user_id
753
745
 
746
+ global_holdout_result: DecisionResult | None = None
747
+ global_holdout_key: str | None = None
748
+ holdout_decisions: list[Decision] = []
749
+
754
750
  # Check global holdouts (flag level — before any rules are evaluated)
755
751
  global_holdouts = project_config.get_global_holdouts()
756
752
  for holdout in global_holdouts:
@@ -758,7 +754,6 @@ class DecisionService:
758
754
  reasons.extend(holdout_decision['reasons'])
759
755
 
760
756
  decision = holdout_decision['decision']
761
- # Check if user was bucketed into holdout (has a variation)
762
757
  if decision.variation is None:
763
758
  continue
764
759
 
@@ -768,18 +763,34 @@ class DecisionService:
768
763
  )
769
764
  self.logger.info(message)
770
765
  reasons.append(message)
771
- return {
772
- 'decision': holdout_decision['decision'],
773
- 'error': False,
774
- 'reasons': reasons
775
- }
776
766
 
777
- # If no global holdout decision, check experiments then rollouts
767
+ if not holdout.exclude_targeted_deliveries:
768
+ return {
769
+ 'decision': holdout_decision['decision'],
770
+ 'error': False,
771
+ 'reasons': reasons
772
+ }
773
+
774
+ message = (
775
+ f"Holdout '{holdout.key}' excludes targeted deliveries. "
776
+ f"Targeted delivery rules will be evaluated."
777
+ )
778
+ self.logger.info(message)
779
+ reasons.append(message)
780
+ global_holdout_result = holdout_decision
781
+ global_holdout_key = holdout.key
782
+ holdout_decisions.append(holdout_decision['decision'])
783
+ break
784
+
785
+ # Check experiments then rollouts
778
786
  if feature_flag.experimentIds:
779
787
  for experiment_id in feature_flag.experimentIds:
780
788
  experiment = project_config.get_experiment_from_id(experiment_id)
781
789
 
782
790
  if experiment:
791
+ if global_holdout_result is not None and experiment.type != enums.ExperimentTypes.td:
792
+ continue
793
+
783
794
  # Check for forced decision
784
795
  optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(
785
796
  feature_flag.key, experiment.key)
@@ -790,33 +801,22 @@ class DecisionService:
790
801
  if forced_decision_variation:
791
802
  decision = Decision(experiment, forced_decision_variation,
792
803
  enums.DecisionSources.FEATURE_TEST, None)
793
- return {
804
+ result: DecisionResult = {
794
805
  'decision': decision,
795
806
  'error': False,
796
807
  'reasons': reasons
797
808
  }
809
+ if holdout_decisions:
810
+ result['holdout_decisions'] = holdout_decisions
811
+ result['holdout_decision'] = holdout_decisions[0]
812
+ return result
798
813
 
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
- }
814
+ local_holdout = self._get_local_holdout_decision(
815
+ project_config, experiment, user_context, 'experiment', reasons
816
+ )
817
+ if local_holdout is not None:
818
+ holdout_decisions.append(local_holdout)
819
+ continue
820
820
 
821
821
  # Get variation for experiment
822
822
  variation_result = self.get_variation(
@@ -837,15 +837,27 @@ class DecisionService:
837
837
  decision = Decision(experiment, variation_result['variation'],
838
838
  enums.DecisionSources.FEATURE_TEST,
839
839
  variation_result['cmab_uuid'])
840
- return {
840
+ result = {
841
841
  'decision': decision,
842
842
  'error': False,
843
843
  'reasons': reasons
844
844
  }
845
+ if holdout_decisions:
846
+ result['holdout_decisions'] = holdout_decisions
847
+ result['holdout_decision'] = holdout_decisions[0]
848
+ return result
849
+
850
+ if global_holdout_result is not None:
851
+ message = (
852
+ f"Holdout \"{global_holdout_key}\" has excludeTargetedDeliveries enabled, "
853
+ f"continuing to rollout evaluation."
854
+ )
855
+ self.logger.info(message)
856
+ reasons.append(message)
845
857
 
846
858
  # If no experiment decision, check rollouts
847
859
  rollout_decision, rollout_reasons = self.get_variation_for_rollout(
848
- project_config, feature_flag, user_context
860
+ project_config, feature_flag, user_context, holdout_decisions
849
861
  )
850
862
  if rollout_reasons:
851
863
  reasons.extend(rollout_reasons)
@@ -863,11 +875,37 @@ class DecisionService:
863
875
  else:
864
876
  self.logger.debug(f'User "{user_id}" not bucketed into any rollout for feature "{feature_flag.key}".')
865
877
 
866
- return {
878
+ final_result: DecisionResult = {
867
879
  'decision': rollout_decision,
868
880
  'error': False,
869
881
  'reasons': reasons
870
882
  }
883
+ if holdout_decisions:
884
+ final_result['holdout_decisions'] = holdout_decisions
885
+ final_result['holdout_decision'] = holdout_decisions[0]
886
+ return final_result
887
+
888
+ def _get_local_holdout_decision(
889
+ self,
890
+ project_config: ProjectConfig,
891
+ rule: entities.Experiment,
892
+ user_context: OptimizelyUserContext,
893
+ rule_type: str,
894
+ reasons: list[str]
895
+ ) -> Optional[Decision]:
896
+ """Returns the first local holdout decision the user is bucketed into for the rule, if any."""
897
+ for holdout in project_config.get_holdouts_for_rule(rule.id):
898
+ holdout_result = self.get_variation_for_holdout(holdout, user_context, project_config)
899
+ reasons.extend(holdout_result['reasons'])
900
+ if holdout_result['decision'].variation is not None:
901
+ message = (
902
+ f"The user '{user_context.user_id}' is bucketed into local holdout '{holdout.key}' "
903
+ f"for {rule_type} rule '{rule.key}'. Skipping the rule."
904
+ )
905
+ self.logger.info(message)
906
+ reasons.append(message)
907
+ return holdout_result['decision']
908
+ return None
871
909
 
872
910
  def get_variation_for_holdout(
873
911
  self,
@@ -11,13 +11,7 @@
11
11
  # See the License for the specific language governing permissions and
12
12
  # limitations under the License.
13
13
  from __future__ import annotations
14
- from typing import TYPE_CHECKING, Any, Optional, Sequence
15
- from sys import version_info
16
-
17
- if version_info < (3, 8):
18
- from typing_extensions import Final
19
- else:
20
- from typing import Final
14
+ from typing import TYPE_CHECKING, Any, Optional, Sequence, Final
21
15
 
22
16
 
23
17
  if TYPE_CHECKING:
@@ -224,6 +218,7 @@ class Holdout(BaseEntity):
224
218
  audienceIds: list[str],
225
219
  audienceConditions: Optional[Sequence[str | list[str]]] = None,
226
220
  includedRules: Optional[list[str]] = None,
221
+ excludeTargetedDeliveries: bool = False,
227
222
  **kwargs: Any
228
223
  ):
229
224
  self.id = id
@@ -236,6 +231,7 @@ class Holdout(BaseEntity):
236
231
  # Per-rule targeting for local holdouts. Scope comes from the datafile
237
232
  # section, not this field; ProjectConfig strips it on 'holdouts' entries.
238
233
  self.included_rules: Optional[list[str]] = includedRules
234
+ self.exclude_targeted_deliveries: bool = excludeTargetedDeliveries
239
235
 
240
236
  def get_audience_conditions_or_ids(self) -> Sequence[str | list[str]]:
241
237
  """Returns audienceConditions if present, otherwise audienceIds.
@@ -12,8 +12,7 @@
12
12
  # limitations under the License.
13
13
 
14
14
  from __future__ import annotations
15
- from typing import TYPE_CHECKING, Optional, Sequence, cast, List
16
- from sys import version_info
15
+ from typing import TYPE_CHECKING, Optional, Sequence, cast, List, Final
17
16
  from optimizely import entities
18
17
  from optimizely.helpers import enums
19
18
  from optimizely.helpers import event_tag_utils
@@ -23,11 +22,6 @@ from . import log_event
23
22
  from . import payload
24
23
  from . import user_event
25
24
 
26
- if version_info < (3, 8):
27
- from typing_extensions import Final
28
- else:
29
- from typing import Final
30
-
31
25
  if TYPE_CHECKING:
32
26
  # prevent circular dependenacy by skipping import at runtime
33
27
  from optimizely.project_config import ProjectConfig
@@ -17,10 +17,9 @@ import numbers
17
17
  import threading
18
18
  import time
19
19
 
20
- from typing import Optional
20
+ from typing import Optional, Final
21
21
  from datetime import timedelta
22
22
  import queue
23
- from sys import version_info
24
23
 
25
24
  from optimizely import logger as _logging
26
25
  from optimizely import notification_center as _notification_center
@@ -31,12 +30,6 @@ from .event_factory import EventFactory
31
30
  from .user_event import UserEvent
32
31
 
33
32
 
34
- if version_info < (3, 8):
35
- from typing_extensions import Final
36
- else:
37
- from typing import Final
38
-
39
-
40
33
  class BaseEventProcessor(ABC):
41
34
  """ Class encapsulating event processing. Override with your own implementation. """
42
35
 
@@ -12,17 +12,10 @@
12
12
  # limitations under the License.
13
13
 
14
14
  from __future__ import annotations
15
- from typing import Optional, Any
16
- from sys import version_info
15
+ from typing import Optional, Any, Literal
17
16
  from optimizely import event_builder
18
17
 
19
18
 
20
- if version_info < (3, 8):
21
- from typing_extensions import Literal
22
- else:
23
- from typing import Literal
24
-
25
-
26
19
  class LogEvent(event_builder.Event):
27
20
  """ Representation of an event which can be sent to Optimizely events API. """
28
21
 
@@ -13,18 +13,11 @@
13
13
  from __future__ import annotations
14
14
  import time
15
15
  import uuid
16
- from typing import TYPE_CHECKING, Optional
17
- from sys import version_info
16
+ from typing import TYPE_CHECKING, Optional, Final
18
17
 
19
18
  from optimizely import version
20
19
 
21
20
 
22
- if version_info < (3, 8):
23
- from typing_extensions import Final
24
- else:
25
- from typing import Final
26
-
27
-
28
21
  if TYPE_CHECKING:
29
22
  # prevent circular dependenacy by skipping import at runtime
30
23
  from optimizely.entities import Experiment, Variation, Event, Holdout
@@ -13,9 +13,8 @@
13
13
 
14
14
  from __future__ import annotations
15
15
  import time
16
- from typing import TYPE_CHECKING, Any, Optional
16
+ from typing import TYPE_CHECKING, Any, Optional, Final, Literal
17
17
  import uuid
18
- from sys import version_info
19
18
 
20
19
  from . import version
21
20
  from .event import event_id_normalizer
@@ -23,11 +22,6 @@ from .helpers import enums
23
22
  from .helpers import event_tag_utils
24
23
  from .helpers import validator
25
24
 
26
- if version_info < (3, 8):
27
- from typing_extensions import Final, Literal
28
- else:
29
- from typing import Final, Literal
30
-
31
25
  if TYPE_CHECKING:
32
26
  # prevent circular dependenacy by skipping import at runtime
33
27
  from .entities import Experiment
@@ -13,7 +13,7 @@
13
13
 
14
14
  import json
15
15
  import logging
16
- from sys import version_info
16
+ from typing import Protocol
17
17
 
18
18
  import requests
19
19
  from requests import exceptions as request_exception
@@ -23,11 +23,6 @@ from urllib3.util.retry import Retry
23
23
  from . import event_builder
24
24
  from .helpers.enums import HTTPVerbs, EventDispatchConfig
25
25
 
26
- if version_info < (3, 8):
27
- from typing_extensions import Protocol
28
- else:
29
- from typing import Protocol
30
-
31
26
 
32
27
  class CustomEventDispatcher(Protocol):
33
28
  """Interface for a custom event dispatcher and required method `dispatch_event`. """
@@ -14,8 +14,7 @@
14
14
  from __future__ import annotations
15
15
  import json
16
16
  import numbers
17
- from typing import TYPE_CHECKING, Any, Callable, Optional
18
- from sys import version_info
17
+ from typing import TYPE_CHECKING, Any, Callable, Optional, Literal, Final
19
18
 
20
19
  from . import validator
21
20
  from optimizely import optimizely_user_context
@@ -29,12 +28,6 @@ if TYPE_CHECKING:
29
28
  from optimizely.logger import Logger
30
29
 
31
30
 
32
- if version_info < (3, 8):
33
- from typing_extensions import Literal, Final
34
- else:
35
- from typing import Literal, Final
36
-
37
-
38
31
  class ConditionOperatorTypes:
39
32
  AND: Final = 'and'
40
33
  OR: Final = 'or'
@@ -12,12 +12,7 @@
12
12
  # limitations under the License.
13
13
 
14
14
  import logging
15
- from sys import version_info
16
-
17
- if version_info < (3, 8):
18
- from typing_extensions import Final
19
- else:
20
- from typing import Final
15
+ from typing import Final
21
16
 
22
17
 
23
18
  class CommonAudienceEvaluationLogs:
@@ -12,16 +12,10 @@
12
12
  # limitations under the License.
13
13
 
14
14
  from __future__ import annotations
15
- from typing import TYPE_CHECKING, Any, Optional, NewType, Dict
15
+ from typing import TYPE_CHECKING, Any, Optional, NewType, Dict, Final
16
16
  from . import enums
17
17
  import math
18
18
  import numbers
19
- from sys import version_info
20
-
21
- if version_info < (3, 8):
22
- from typing_extensions import Final
23
- else:
24
- from typing import Final
25
19
 
26
20
 
27
21
  if TYPE_CHECKING:
@@ -12,14 +12,7 @@
12
12
  # limitations under the License.
13
13
  from __future__ import annotations
14
14
 
15
- from typing import Literal, Optional, Any
16
- from sys import version_info
17
-
18
-
19
- if version_info < (3, 8):
20
- from typing_extensions import TypedDict
21
- else:
22
- from typing import TypedDict # type: ignore
15
+ from typing import Literal, Optional, Any, TypedDict
23
16
 
24
17
 
25
18
  # Intermediate types for type checking deserialized datafile json before actual class instantiation.
@@ -11,17 +11,11 @@
11
11
  # See the License for the specific language governing permissions and
12
12
  # limitations under the License.
13
13
  import logging
14
- from typing import Any, Optional, Union
14
+ from typing import Any, Optional, Union, Final
15
15
  import warnings
16
- from sys import version_info
17
16
 
18
17
  from .helpers import enums
19
18
 
20
- if version_info < (3, 8):
21
- from typing_extensions import Final
22
- else:
23
- from typing import Final
24
-
25
19
 
26
20
  _DEFAULT_LOG_FORMAT: Final = '%(levelname)-8s %(asctime)s %(filename)s:%(lineno)s:%(message)s'
27
21
 
@@ -12,15 +12,9 @@
12
12
  # limitations under the License.
13
13
 
14
14
  from __future__ import annotations
15
- from typing import Any, Callable, Optional
15
+ from typing import Any, Callable, Optional, Final
16
16
  from .helpers import enums
17
17
  from . import logger as optimizely_logger
18
- from sys import version_info
19
-
20
- if version_info < (3, 8):
21
- from typing_extensions import Final
22
- else:
23
- from typing import Final
24
18
 
25
19
 
26
20
  NOTIFICATION_TYPES: Final = tuple(
@@ -16,13 +16,7 @@ from dataclasses import dataclass, field
16
16
  import threading
17
17
  from time import time
18
18
  from collections import OrderedDict
19
- from typing import Optional, Generic, TypeVar, Hashable
20
- from sys import version_info
21
-
22
- if version_info < (3, 8):
23
- from typing_extensions import Protocol
24
- else:
25
- from typing import Protocol
19
+ from typing import Optional, Generic, TypeVar, Hashable, Protocol
26
20
 
27
21
  # generic type definitions for LRUCache parameters
28
22
  K = TypeVar('K', bound=Hashable, contravariant=True)
@@ -11,12 +11,7 @@
11
11
  # See the License for the specific language governing permissions and
12
12
  # limitations under the License.
13
13
 
14
- from sys import version_info
15
-
16
- if version_info < (3, 8):
17
- from typing_extensions import Final
18
- else:
19
- from typing import Final
14
+ from typing import Final
20
15
 
21
16
 
22
17
  class OptimizelyOdpOption:
@@ -1,4 +1,4 @@
1
- # Copyright 2016-2023, Optimizely
1
+ # Copyright 2016-2023, 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
@@ -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_decisions: Optional[list[Decision]] = None
1249
1250
  ) -> OptimizelyDecision:
1250
1251
  user_id = user_context.user_id
1251
1252
  feature_enabled = False
@@ -1264,6 +1265,27 @@ class Optimizely:
1264
1265
 
1265
1266
  feature_flag = project_config.feature_key_map.get(flag_key)
1266
1267
 
1268
+ # Skipped local holdouts and global holdouts bypassed by exclude_targeted_deliveries
1269
+ if (holdout_decisions
1270
+ and decision_source != DecisionSources.HOLDOUT
1271
+ and OptimizelyDecideOption.DISABLE_DECISION_EVENT not in decide_options):
1272
+ for holdout_decision in holdout_decisions:
1273
+ holdout_enabled = self._get_feature_enabled(holdout_decision.variation)
1274
+ holdout_rule_key = holdout_decision.experiment.key if holdout_decision.experiment else ''
1275
+ self._send_impression_event(
1276
+ project_config,
1277
+ holdout_decision.experiment,
1278
+ holdout_decision.variation,
1279
+ flag_key,
1280
+ holdout_rule_key,
1281
+ str(DecisionSources.HOLDOUT),
1282
+ holdout_enabled,
1283
+ user_id,
1284
+ attributes,
1285
+ holdout_decision.cmab_uuid
1286
+ )
1287
+ decision_event_dispatched = True
1288
+
1267
1289
  # Send impression event if Decision came from a feature
1268
1290
  if OptimizelyDecideOption.DISABLE_DECISION_EVENT not in decide_options:
1269
1291
  if (decision_source == DecisionSources.FEATURE_TEST or
@@ -1448,11 +1470,13 @@ class Optimizely:
1448
1470
  user_context,
1449
1471
  merged_decide_options
1450
1472
  )
1473
+ holdout_decisions: dict[str, Optional[list[Decision]]] = {}
1451
1474
  for i in range(0, len(flags_without_forced_decision)):
1452
1475
  decision = decision_list[i]['decision']
1453
1476
  reasons = decision_list[i]['reasons']
1454
1477
  error = decision_list[i]['error']
1455
1478
  flag_key = flags_without_forced_decision[i].key
1479
+ holdout_decisions[flag_key] = decision_list[i].get('holdout_decisions')
1456
1480
  # store error decision against key and remove key from valid keys
1457
1481
  if error:
1458
1482
  optimizely_decision = OptimizelyDecision.new_error_decision(flags_without_forced_decision[i].key,
@@ -1472,7 +1496,8 @@ class Optimizely:
1472
1496
  flag_decision,
1473
1497
  decision_reasons,
1474
1498
  merged_decide_options,
1475
- project_config
1499
+ project_config,
1500
+ holdout_decisions=holdout_decisions.get(key)
1476
1501
  )
1477
1502
  enabled_flags_only_missing = OptimizelyDecideOption.ENABLED_FLAGS_ONLY not in merged_decide_options
1478
1503
  is_enabled = optimizely_decision.enabled
@@ -12,8 +12,7 @@
12
12
  # limitations under the License.
13
13
  from __future__ import annotations
14
14
  import json
15
- from typing import TYPE_CHECKING, Optional, Type, TypeVar, Union, cast, Any, Iterable, List
16
- from sys import version_info
15
+ from typing import TYPE_CHECKING, Optional, Type, TypeVar, Union, cast, Any, Iterable, List, Final
17
16
 
18
17
  from . import entities
19
18
  from . import exceptions
@@ -21,11 +20,6 @@ from .helpers import condition as condition_helper
21
20
  from .helpers import enums
22
21
  from .helpers import types
23
22
 
24
- if version_info < (3, 8):
25
- from typing_extensions import Final
26
- else:
27
- from typing import Final
28
-
29
23
  if TYPE_CHECKING:
30
24
  # prevent circular dependenacy by skipping import at runtime
31
25
  from .logger import Logger
@@ -12,19 +12,13 @@
12
12
  # limitations under the License.
13
13
 
14
14
  from __future__ import annotations
15
- from typing import Any, Optional
16
- from sys import version_info
15
+ from typing import Any, Optional, Final, TYPE_CHECKING
17
16
  from . import logger as _logging
18
17
 
19
- if version_info < (3, 8):
20
- from typing_extensions import Final
21
- else:
22
- from typing import Final, TYPE_CHECKING
23
-
24
- if TYPE_CHECKING:
25
- # prevent circular dependenacy by skipping import at runtime
26
- from .entities import Experiment, Variation
27
- from optimizely.error_handler import BaseErrorHandler
18
+ if TYPE_CHECKING:
19
+ # prevent circular dependenacy by skipping import at runtime
20
+ from .entities import Experiment, Variation
21
+ from optimizely.error_handler import BaseErrorHandler
28
22
 
29
23
 
30
24
  class UserProfile:
@@ -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, 6, 0)
14
+ version_info = (5, 8, 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.6.0
3
+ Version: 5.8.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: funcsigs>=0.4; extra == "test"
28
+ Requires-Dist: python-coveralls; extra == "test"
29
+ Requires-Dist: requests>=2.21; extra == "test"
27
30
  Requires-Dist: pytest>=6.2.0; extra == "test"
28
- Requires-Dist: coverage; extra == "test"
29
- Requires-Dist: pytest-cov; extra == "test"
30
31
  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
32
  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
33
  Requires-Dist: idna>=2.10; extra == "test"
34
+ Requires-Dist: coverage; extra == "test"
35
+ Requires-Dist: pytest-cov; extra == "test"
37
36
  Requires-Dist: ruff>=0.15.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,33 @@ 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.8.0
308
+ October 30th, 2026
309
+
310
+ ### Changed
311
+
312
+ **Local Holdouts: rule evaluation continues after a holdout hit**:
313
+ When a user matches a local holdout on a rule, the SDK now excludes the user from that rule only
314
+ and continues evaluating the remaining rules, instead of returning the holdout variation and skipping
315
+ the rest of the flag. Each local holdout hit sends its own holdout impression event (using the
316
+ existing event format), regardless of `sendFlagDecisions`. Global holdouts are unchanged.
317
+
318
+ - Continue rule evaluation after local holdout hit ([#525](https://github.com/optimizely/python-sdk/pull/525))
319
+ - Remove dead Python 3.7 typing guards ([#526](https://github.com/optimizely/python-sdk/pull/526))
320
+
321
+ ---
322
+
323
+ ## 5.7.0
324
+ September 2nd, 2026
325
+
326
+ ### New Features
327
+
328
+ **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.
329
+
330
+ - Add holdout exclusion logic for Targeted Delivery rules ([#522](https://github.com/optimizely/python-sdk/pull/522))
331
+
332
+ ---
333
+
307
334
  ## 5.6.0
308
335
  July 10th, 2026
309
336
 
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
+ funcsigs>=0.4
11
+ python-coveralls
12
+ requests>=2.21
10
13
  pytest>=6.2.0
11
- coverage
12
- pytest-cov
13
14
  jsonschema>=3.2.0
14
- pyrsistent>=0.16.0
15
- requests>=2.21
16
- python-coveralls
17
- funcsigs>=0.4
18
15
  idna>=2.10
16
+ coverage
17
+ pytest-cov
19
18
  ruff>=0.15.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