optimizely-sdk 5.7.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.7.0 → optimizely_sdk-5.8.0}/CHANGELOG.md +16 -0
  2. {optimizely_sdk-5.7.0/optimizely_sdk.egg-info → optimizely_sdk-5.8.0}/PKG-INFO +24 -8
  3. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/bucketer.py +1 -8
  4. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/decision/optimizely_decide_option.py +1 -6
  5. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/decision/optimizely_decision_message.py +1 -6
  6. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/decision_service.py +57 -47
  7. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/entities.py +1 -7
  8. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/event/event_factory.py +1 -7
  9. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/event/event_processor.py +1 -8
  10. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/event/log_event.py +1 -8
  11. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/event/user_event.py +1 -8
  12. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/event_builder.py +1 -7
  13. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/event_dispatcher.py +1 -6
  14. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/helpers/condition.py +1 -8
  15. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/helpers/enums.py +1 -6
  16. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/helpers/event_tag_utils.py +1 -7
  17. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/helpers/types.py +1 -8
  18. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/logger.py +1 -7
  19. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/notification_center.py +1 -7
  20. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/odp/lru_cache.py +1 -7
  21. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/odp/optimizely_odp_option.py +1 -6
  22. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/optimizely.py +22 -21
  23. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/project_config.py +1 -7
  24. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/user_profile.py +5 -11
  25. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/version.py +1 -1
  26. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0/optimizely_sdk.egg-info}/PKG-INFO +24 -8
  27. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/LICENSE +0 -0
  28. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/MANIFEST.in +0 -0
  29. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/README.md +0 -0
  30. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/__init__.py +0 -0
  31. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/cmab/__init__.py +0 -0
  32. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/cmab/cmab_client.py +0 -0
  33. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/cmab/cmab_service.py +0 -0
  34. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/config_manager.py +0 -0
  35. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/decision/__init__.py +0 -0
  36. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/decision/optimizely_decision.py +0 -0
  37. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/error_handler.py +0 -0
  38. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/event/__init__.py +0 -0
  39. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/event/event_id_normalizer.py +0 -0
  40. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/event/payload.py +0 -0
  41. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/event/user_event_factory.py +0 -0
  42. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/exceptions.py +0 -0
  43. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/helpers/__init__.py +0 -0
  44. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/helpers/audience.py +0 -0
  45. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/helpers/condition_tree_evaluator.py +0 -0
  46. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/helpers/constants.py +0 -0
  47. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/helpers/experiment.py +0 -0
  48. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/helpers/sdk_settings.py +0 -0
  49. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/helpers/validator.py +0 -0
  50. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/lib/__init__.py +0 -0
  51. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/lib/pymmh3.py +0 -0
  52. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/notification_center_registry.py +0 -0
  53. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/odp/__init__.py +0 -0
  54. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/odp/odp_config.py +0 -0
  55. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/odp/odp_event.py +0 -0
  56. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/odp/odp_event_api_manager.py +0 -0
  57. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/odp/odp_event_manager.py +0 -0
  58. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/odp/odp_manager.py +0 -0
  59. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/odp/odp_segment_api_manager.py +0 -0
  60. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/odp/odp_segment_manager.py +0 -0
  61. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/optimizely_config.py +0 -0
  62. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/optimizely_factory.py +0 -0
  63. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/optimizely_user_context.py +0 -0
  64. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely/py.typed +0 -0
  65. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely_sdk.egg-info/SOURCES.txt +0 -0
  66. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely_sdk.egg-info/dependency_links.txt +0 -0
  67. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely_sdk.egg-info/requires.txt +6 -6
  68. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/optimizely_sdk.egg-info/top_level.txt +0 -0
  69. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/requirements/core.txt +0 -0
  70. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/requirements/docs.txt +0 -0
  71. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/requirements/test.txt +0 -0
  72. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/requirements/typing.txt +0 -0
  73. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/setup.cfg +0 -0
  74. {optimizely_sdk-5.7.0 → optimizely_sdk-5.8.0}/setup.py +0 -0
@@ -1,5 +1,21 @@
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
+
3
19
  ## 5.7.0
4
20
  September 2nd, 2026
5
21
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optimizely-sdk
3
- Version: 5.7.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
@@ -25,16 +25,16 @@ Requires-Dist: idna>=2.10
25
25
  Requires-Dist: rpds-py<0.20.0; python_version < "3.11"
26
26
  Provides-Extra: test
27
27
  Requires-Dist: funcsigs>=0.4; extra == "test"
28
+ Requires-Dist: python-coveralls; extra == "test"
29
+ Requires-Dist: requests>=2.21; extra == "test"
30
+ Requires-Dist: pytest>=6.2.0; extra == "test"
31
+ Requires-Dist: jsonschema>=3.2.0; extra == "test"
32
+ Requires-Dist: rpds-py<0.20.0; python_version < "3.11" and extra == "test"
28
33
  Requires-Dist: idna>=2.10; extra == "test"
34
+ Requires-Dist: coverage; extra == "test"
35
+ Requires-Dist: pytest-cov; extra == "test"
29
36
  Requires-Dist: ruff>=0.15.0; extra == "test"
30
37
  Requires-Dist: pyrsistent>=0.16.0; extra == "test"
31
- Requires-Dist: rpds-py<0.20.0; python_version < "3.11" and extra == "test"
32
- Requires-Dist: pytest-cov; extra == "test"
33
- Requires-Dist: requests>=2.21; extra == "test"
34
- Requires-Dist: coverage; extra == "test"
35
- Requires-Dist: python-coveralls; extra == "test"
36
- Requires-Dist: jsonschema>=3.2.0; extra == "test"
37
- Requires-Dist: pytest>=6.2.0; extra == "test"
38
38
  Dynamic: author
39
39
  Dynamic: author-email
40
40
  Dynamic: classifier
@@ -304,6 +304,22 @@ 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
+
307
323
  ## 5.7.0
308
324
  September 2nd, 2026
309
325
 
@@ -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
@@ -65,7 +65,8 @@ class VariationResult(TypedDict):
65
65
 
66
66
 
67
67
  class _DecisionResultOptional(TypedDict, total=False):
68
- holdout_decision: Decision
68
+ holdout_decision: Decision # Deprecated: first entry of holdout_decisions.
69
+ holdout_decisions: List[Decision]
69
70
 
70
71
 
71
72
  class DecisionResult(_DecisionResultOptional):
@@ -552,7 +553,11 @@ class DecisionService:
552
553
  }
553
554
 
554
555
  def get_variation_for_rollout(
555
- 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
556
561
  ) -> tuple[Decision, list[str]]:
557
562
  """ Determine which experiment/variation the user is in for a given rollout.
558
563
  Returns the variation of the first experiment the user qualifies for.
@@ -562,7 +567,7 @@ class DecisionService:
562
567
  flagKey: Feature key.
563
568
  rollout: Rollout for which we are getting the variation.
564
569
  user: ID and attributes for user.
565
- options: Decide options.
570
+ holdout_decisions: If provided, local holdout decisions for skipped rules are appended to it.
566
571
 
567
572
  Returns:
568
573
  Decision namedtuple consisting of experiment and variation for the user and
@@ -606,22 +611,14 @@ class DecisionService:
606
611
  return Decision(experiment=rule, variation=forced_decision_variation,
607
612
  source=enums.DecisionSources.ROLLOUT, cmab_uuid=None), decide_reasons
608
613
 
609
- local_holdouts = project_config.get_holdouts_for_rule(rule.id)
610
- for holdout in local_holdouts:
611
- local_holdout_decision = self.get_variation_for_holdout(
612
- holdout, user_context, project_config
613
- )
614
- decide_reasons.extend(local_holdout_decision['reasons'])
615
-
616
- local_decision = local_holdout_decision['decision']
617
- if local_decision.variation is not None:
618
- message = (
619
- f"The user '{user_id}' is bucketed into local holdout '{holdout.key}' "
620
- f"for delivery rule '{rule.key}'."
621
- )
622
- self.logger.info(message)
623
- decide_reasons.append(message)
624
- return local_decision, decide_reasons
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
625
622
 
626
623
  bucketing_id, bucket_reasons = self._get_bucketing_id(user_id, attributes)
627
624
  decide_reasons += bucket_reasons
@@ -748,6 +745,7 @@ class DecisionService:
748
745
 
749
746
  global_holdout_result: DecisionResult | None = None
750
747
  global_holdout_key: str | None = None
748
+ holdout_decisions: list[Decision] = []
751
749
 
752
750
  # Check global holdouts (flag level — before any rules are evaluated)
753
751
  global_holdouts = project_config.get_global_holdouts()
@@ -781,6 +779,7 @@ class DecisionService:
781
779
  reasons.append(message)
782
780
  global_holdout_result = holdout_decision
783
781
  global_holdout_key = holdout.key
782
+ holdout_decisions.append(holdout_decision['decision'])
784
783
  break
785
784
 
786
785
  # Check experiments then rollouts
@@ -807,30 +806,17 @@ class DecisionService:
807
806
  'error': False,
808
807
  'reasons': reasons
809
808
  }
810
- if global_holdout_result is not None:
811
- result['holdout_decision'] = global_holdout_result['decision']
809
+ if holdout_decisions:
810
+ result['holdout_decisions'] = holdout_decisions
811
+ result['holdout_decision'] = holdout_decisions[0]
812
812
  return result
813
813
 
814
- local_holdouts = project_config.get_holdouts_for_rule(experiment.id)
815
- for holdout in local_holdouts:
816
- local_holdout_decision = self.get_variation_for_holdout(
817
- holdout, user_context, project_config
818
- )
819
- reasons.extend(local_holdout_decision['reasons'])
820
-
821
- local_decision = local_holdout_decision['decision']
822
- if local_decision.variation is not None:
823
- message = (
824
- f"The user '{user_id}' is bucketed into local holdout '{holdout.key}' "
825
- f"for experiment rule '{experiment.key}'."
826
- )
827
- self.logger.info(message)
828
- reasons.append(message)
829
- return {
830
- 'decision': local_holdout_decision['decision'],
831
- 'error': False,
832
- 'reasons': reasons
833
- }
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
834
820
 
835
821
  # Get variation for experiment
836
822
  variation_result = self.get_variation(
@@ -856,8 +842,9 @@ class DecisionService:
856
842
  'error': False,
857
843
  'reasons': reasons
858
844
  }
859
- if global_holdout_result is not None:
860
- result['holdout_decision'] = global_holdout_result['decision']
845
+ if holdout_decisions:
846
+ result['holdout_decisions'] = holdout_decisions
847
+ result['holdout_decision'] = holdout_decisions[0]
861
848
  return result
862
849
 
863
850
  if global_holdout_result is not None:
@@ -870,7 +857,7 @@ class DecisionService:
870
857
 
871
858
  # If no experiment decision, check rollouts
872
859
  rollout_decision, rollout_reasons = self.get_variation_for_rollout(
873
- project_config, feature_flag, user_context
860
+ project_config, feature_flag, user_context, holdout_decisions
874
861
  )
875
862
  if rollout_reasons:
876
863
  reasons.extend(rollout_reasons)
@@ -893,10 +880,33 @@ class DecisionService:
893
880
  'error': False,
894
881
  'reasons': reasons
895
882
  }
896
- if global_holdout_result is not None:
897
- final_result['holdout_decision'] = global_holdout_result['decision']
883
+ if holdout_decisions:
884
+ final_result['holdout_decisions'] = holdout_decisions
885
+ final_result['holdout_decision'] = holdout_decisions[0]
898
886
  return final_result
899
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
909
+
900
910
  def get_variation_for_holdout(
901
911
  self,
902
912
  holdout: entities.Holdout,
@@ -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:
@@ -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
@@ -1246,7 +1246,7 @@ class Optimizely:
1246
1246
  decision_reasons: Optional[list[str]],
1247
1247
  decide_options: list[str],
1248
1248
  project_config: ProjectConfig,
1249
- holdout_decision: Optional[Decision] = None
1249
+ holdout_decisions: Optional[list[Decision]] = None
1250
1250
  ) -> OptimizelyDecision:
1251
1251
  user_id = user_context.user_id
1252
1252
  feature_enabled = False
@@ -1265,24 +1265,25 @@ class Optimizely:
1265
1265
 
1266
1266
  feature_flag = project_config.feature_key_map.get(flag_key)
1267
1267
 
1268
- # Send holdout impression when user was bucketed into a holdout bypassed due to exclude_targeted_deliveries
1269
- if (holdout_decision is not None
1268
+ # Skipped local holdouts and global holdouts bypassed by exclude_targeted_deliveries
1269
+ if (holdout_decisions
1270
1270
  and decision_source != DecisionSources.HOLDOUT
1271
1271
  and OptimizelyDecideOption.DISABLE_DECISION_EVENT not in decide_options):
1272
- holdout_enabled = self._get_feature_enabled(holdout_decision.variation)
1273
- holdout_rule_key = holdout_decision.experiment.key if holdout_decision.experiment else ''
1274
- self._send_impression_event(
1275
- project_config,
1276
- holdout_decision.experiment,
1277
- holdout_decision.variation,
1278
- flag_key,
1279
- holdout_rule_key,
1280
- str(DecisionSources.HOLDOUT),
1281
- holdout_enabled,
1282
- user_id,
1283
- attributes,
1284
- holdout_decision.cmab_uuid
1285
- )
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
+ )
1286
1287
  decision_event_dispatched = True
1287
1288
 
1288
1289
  # Send impression event if Decision came from a feature
@@ -1469,13 +1470,13 @@ class Optimizely:
1469
1470
  user_context,
1470
1471
  merged_decide_options
1471
1472
  )
1472
- holdout_decisions: dict[str, Optional[Decision]] = {}
1473
+ holdout_decisions: dict[str, Optional[list[Decision]]] = {}
1473
1474
  for i in range(0, len(flags_without_forced_decision)):
1474
1475
  decision = decision_list[i]['decision']
1475
1476
  reasons = decision_list[i]['reasons']
1476
1477
  error = decision_list[i]['error']
1477
1478
  flag_key = flags_without_forced_decision[i].key
1478
- holdout_decisions[flag_key] = decision_list[i].get('holdout_decision')
1479
+ holdout_decisions[flag_key] = decision_list[i].get('holdout_decisions')
1479
1480
  # store error decision against key and remove key from valid keys
1480
1481
  if error:
1481
1482
  optimizely_decision = OptimizelyDecision.new_error_decision(flags_without_forced_decision[i].key,
@@ -1496,7 +1497,7 @@ class Optimizely:
1496
1497
  decision_reasons,
1497
1498
  merged_decide_options,
1498
1499
  project_config,
1499
- holdout_decision=holdout_decisions.get(key)
1500
+ holdout_decisions=holdout_decisions.get(key)
1500
1501
  )
1501
1502
  enabled_flags_only_missing = OptimizelyDecideOption.ENABLED_FLAGS_ONLY not in merged_decide_options
1502
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, 7, 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.7.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
@@ -25,16 +25,16 @@ Requires-Dist: idna>=2.10
25
25
  Requires-Dist: rpds-py<0.20.0; python_version < "3.11"
26
26
  Provides-Extra: test
27
27
  Requires-Dist: funcsigs>=0.4; extra == "test"
28
+ Requires-Dist: python-coveralls; extra == "test"
29
+ Requires-Dist: requests>=2.21; extra == "test"
30
+ Requires-Dist: pytest>=6.2.0; extra == "test"
31
+ Requires-Dist: jsonschema>=3.2.0; extra == "test"
32
+ Requires-Dist: rpds-py<0.20.0; python_version < "3.11" and extra == "test"
28
33
  Requires-Dist: idna>=2.10; extra == "test"
34
+ Requires-Dist: coverage; extra == "test"
35
+ Requires-Dist: pytest-cov; extra == "test"
29
36
  Requires-Dist: ruff>=0.15.0; extra == "test"
30
37
  Requires-Dist: pyrsistent>=0.16.0; extra == "test"
31
- Requires-Dist: rpds-py<0.20.0; python_version < "3.11" and extra == "test"
32
- Requires-Dist: pytest-cov; extra == "test"
33
- Requires-Dist: requests>=2.21; extra == "test"
34
- Requires-Dist: coverage; extra == "test"
35
- Requires-Dist: python-coveralls; extra == "test"
36
- Requires-Dist: jsonschema>=3.2.0; extra == "test"
37
- Requires-Dist: pytest>=6.2.0; extra == "test"
38
38
  Dynamic: author
39
39
  Dynamic: author-email
40
40
  Dynamic: classifier
@@ -304,6 +304,22 @@ 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
+
307
323
  ## 5.7.0
308
324
  September 2nd, 2026
309
325
 
File without changes
File without changes
@@ -8,15 +8,15 @@ rpds-py<0.20.0
8
8
 
9
9
  [test]
10
10
  funcsigs>=0.4
11
+ python-coveralls
12
+ requests>=2.21
13
+ pytest>=6.2.0
14
+ jsonschema>=3.2.0
11
15
  idna>=2.10
16
+ coverage
17
+ pytest-cov
12
18
  ruff>=0.15.0
13
19
  pyrsistent>=0.16.0
14
- pytest-cov
15
- requests>=2.21
16
- coverage
17
- python-coveralls
18
- jsonschema>=3.2.0
19
- pytest>=6.2.0
20
20
 
21
21
  [test:python_version < "3.11"]
22
22
  rpds-py<0.20.0
File without changes
File without changes