optimizely-sdk 5.4.0__tar.gz → 5.5.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 (73) hide show
  1. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/CHANGELOG.md +28 -0
  2. {optimizely-sdk-5.4.0/optimizely_sdk.egg-info → optimizely_sdk-5.5.0}/PKG-INFO +57 -3
  3. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/config_manager.py +6 -9
  4. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/decision_service.py +5 -0
  5. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/entities.py +2 -4
  6. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/event_dispatcher.py +1 -1
  7. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/helpers/enums.py +8 -0
  8. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/helpers/types.py +4 -2
  9. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/helpers/validator.py +2 -2
  10. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/odp/odp_event_manager.py +8 -1
  11. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/project_config.py +69 -37
  12. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/version.py +1 -1
  13. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0/optimizely_sdk.egg-info}/PKG-INFO +57 -3
  14. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely_sdk.egg-info/requires.txt +7 -7
  15. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/requirements/test.txt +1 -1
  16. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/LICENSE +0 -0
  17. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/MANIFEST.in +0 -0
  18. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/README.md +0 -0
  19. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/__init__.py +0 -0
  20. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/bucketer.py +0 -0
  21. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/cmab/__init__.py +0 -0
  22. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/cmab/cmab_client.py +0 -0
  23. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/cmab/cmab_service.py +0 -0
  24. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/decision/__init__.py +0 -0
  25. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/decision/optimizely_decide_option.py +0 -0
  26. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/decision/optimizely_decision.py +0 -0
  27. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/decision/optimizely_decision_message.py +0 -0
  28. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/error_handler.py +0 -0
  29. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/event/__init__.py +0 -0
  30. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/event/event_factory.py +0 -0
  31. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/event/event_processor.py +0 -0
  32. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/event/log_event.py +0 -0
  33. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/event/payload.py +0 -0
  34. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/event/user_event.py +0 -0
  35. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/event/user_event_factory.py +0 -0
  36. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/event_builder.py +0 -0
  37. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/exceptions.py +0 -0
  38. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/helpers/__init__.py +0 -0
  39. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/helpers/audience.py +0 -0
  40. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/helpers/condition.py +0 -0
  41. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/helpers/condition_tree_evaluator.py +0 -0
  42. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/helpers/constants.py +0 -0
  43. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/helpers/event_tag_utils.py +0 -0
  44. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/helpers/experiment.py +0 -0
  45. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/helpers/sdk_settings.py +0 -0
  46. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/lib/__init__.py +0 -0
  47. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/lib/pymmh3.py +0 -0
  48. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/logger.py +0 -0
  49. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/notification_center.py +0 -0
  50. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/notification_center_registry.py +0 -0
  51. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/odp/__init__.py +0 -0
  52. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/odp/lru_cache.py +0 -0
  53. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/odp/odp_config.py +0 -0
  54. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/odp/odp_event.py +0 -0
  55. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/odp/odp_event_api_manager.py +0 -0
  56. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/odp/odp_manager.py +0 -0
  57. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/odp/odp_segment_api_manager.py +0 -0
  58. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/odp/odp_segment_manager.py +0 -0
  59. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/odp/optimizely_odp_option.py +0 -0
  60. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/optimizely.py +0 -0
  61. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/optimizely_config.py +0 -0
  62. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/optimizely_factory.py +0 -0
  63. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/optimizely_user_context.py +0 -0
  64. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/py.typed +0 -0
  65. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely/user_profile.py +0 -0
  66. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely_sdk.egg-info/SOURCES.txt +0 -0
  67. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely_sdk.egg-info/dependency_links.txt +0 -0
  68. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/optimizely_sdk.egg-info/top_level.txt +0 -0
  69. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/requirements/core.txt +0 -0
  70. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/requirements/docs.txt +0 -0
  71. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/requirements/typing.txt +0 -0
  72. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/setup.cfg +0 -0
  73. {optimizely-sdk-5.4.0 → optimizely_sdk-5.5.0}/setup.py +0 -0
@@ -1,5 +1,33 @@
1
1
  # Optimizely Python SDK Changelog
2
2
 
3
+ ## 5.5.0
4
+ April 30th, 2026
5
+
6
+ ### New Features
7
+
8
+ #### **Feature Rollout Support**
9
+
10
+ - Added support for Feature Rollouts, a new rule type that combines Targeted Delivery simplicity with A/B test measurement capabilities. During project config parsing, the "everyone else" variation from the flag's rollout is automatically injected into feature rollout experiments, enabling correct evaluation without changes to decision logic. ([#499](https://github.com/optimizely/python-sdk/pull/499))
11
+ - Removed experiment type validation from config parsing to support flexible rule types. ([#500](https://github.com/optimizely/python-sdk/pull/500))
12
+
13
+ ---
14
+
15
+ ### Enhancements
16
+
17
+ - Added event retry support for reliable event dispatching. ([#475](https://github.com/optimizely/python-sdk/pull/475))
18
+ - Excluded CMAB experiments from UserProfileService to prevent stale cached decisions. ([#474](https://github.com/optimizely/python-sdk/pull/474))
19
+ - Cleaned up flag-level holdout fields for simplified configuration. ([#507](https://github.com/optimizely/python-sdk/pull/507))
20
+
21
+ ---
22
+
23
+ ### Bug Fixes
24
+
25
+ - Fixed `return` in `finally` block silently swallowing exceptions. ([#505](https://github.com/optimizely/python-sdk/pull/505))
26
+ - Fixed `PollingConfigManager` rejecting valid URL-only configuration. ([#497](https://github.com/optimizely/python-sdk/pull/497))
27
+
28
+ ---
29
+
30
+
3
31
  ## 5.4.0
4
32
  December 19th, 2025
5
33
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: optimizely-sdk
3
- Version: 5.4.0
3
+ Version: 5.5.0
4
4
  Summary: Python SDK for Optimizely Feature Experimentation, Optimizely Full Stack (legacy), and Optimizely Rollouts.
5
5
  Home-page: https://github.com/optimizely/python-sdk
6
6
  Author: Optimizely
@@ -17,8 +17,34 @@ Classifier: Programming Language :: Python :: 3.10
17
17
  Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Description-Content-Type: text/markdown
20
- Provides-Extra: test
21
20
  License-File: LICENSE
21
+ Requires-Dist: jsonschema>=3.2.0
22
+ Requires-Dist: pyrsistent>=0.16.0
23
+ Requires-Dist: requests>=2.21
24
+ Requires-Dist: idna>=2.10
25
+ Requires-Dist: rpds-py<0.20.0; python_version < "3.11"
26
+ Provides-Extra: test
27
+ Requires-Dist: 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"
31
+ Requires-Dist: coverage; extra == "test"
32
+ Requires-Dist: pytest-cov; extra == "test"
33
+ Requires-Dist: rpds-py<0.20.0; python_version < "3.11" and extra == "test"
34
+ 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
+ Requires-Dist: pyrsistent>=0.16.0; extra == "test"
38
+ Dynamic: author
39
+ Dynamic: author-email
40
+ Dynamic: classifier
41
+ Dynamic: description
42
+ Dynamic: description-content-type
43
+ Dynamic: home-page
44
+ Dynamic: license-file
45
+ Dynamic: provides-extra
46
+ Dynamic: requires-dist
47
+ Dynamic: summary
22
48
 
23
49
  Optimizely Feature Experimentation is A/B testing and feature management for product development teams. Experiment in any application. Make every feature on your roadmap an opportunity to learn. Learn more at https://www.optimizely.com/products/experiment/feature-experimentation/ or see our documentation at https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/welcome. # Optimizely Python SDK
24
50
 
@@ -278,6 +304,34 @@ idna (BSD 3-Clause License https://github.com/kjd/idna/blob/master/LICENSE.md)
278
304
  - Swift - https://github.com/optimizely/swift-sdk
279
305
  # Optimizely Python SDK Changelog
280
306
 
307
+ ## 5.5.0
308
+ April 30th, 2026
309
+
310
+ ### New Features
311
+
312
+ #### **Feature Rollout Support**
313
+
314
+ - Added support for Feature Rollouts, a new rule type that combines Targeted Delivery simplicity with A/B test measurement capabilities. During project config parsing, the "everyone else" variation from the flag's rollout is automatically injected into feature rollout experiments, enabling correct evaluation without changes to decision logic. ([#499](https://github.com/optimizely/python-sdk/pull/499))
315
+ - Removed experiment type validation from config parsing to support flexible rule types. ([#500](https://github.com/optimizely/python-sdk/pull/500))
316
+
317
+ ---
318
+
319
+ ### Enhancements
320
+
321
+ - Added event retry support for reliable event dispatching. ([#475](https://github.com/optimizely/python-sdk/pull/475))
322
+ - Excluded CMAB experiments from UserProfileService to prevent stale cached decisions. ([#474](https://github.com/optimizely/python-sdk/pull/474))
323
+ - Cleaned up flag-level holdout fields for simplified configuration. ([#507](https://github.com/optimizely/python-sdk/pull/507))
324
+
325
+ ---
326
+
327
+ ### Bug Fixes
328
+
329
+ - Fixed `return` in `finally` block silently swallowing exceptions. ([#505](https://github.com/optimizely/python-sdk/pull/505))
330
+ - Fixed `PollingConfigManager` rejecting valid URL-only configuration. ([#497](https://github.com/optimizely/python-sdk/pull/497))
331
+
332
+ ---
333
+
334
+
281
335
  ## 5.4.0
282
336
  December 19th, 2025
283
337
 
@@ -145,14 +145,14 @@ class StaticConfigManager(BaseConfigManager):
145
145
  except optimizely_exceptions.UnsupportedDatafileVersionException as error:
146
146
  error_msg = error.args[0]
147
147
  error_to_handle = error
148
- except:
148
+ except Exception:
149
149
  error_msg = enums.Errors.INVALID_INPUT.format('datafile')
150
150
  error_to_handle = optimizely_exceptions.InvalidInputException(error_msg)
151
- finally:
152
- if error_msg or config is None:
153
- self.logger.error(error_msg)
154
- self.error_handler.handle_error(error_to_handle or Exception('Unknown Error'))
155
- return
151
+
152
+ if error_msg or config is None:
153
+ self.logger.error(error_msg)
154
+ self.error_handler.handle_error(error_to_handle or Exception('Unknown Error'))
155
+ return
156
156
 
157
157
  previous_revision = self._config.get_revision() if self._config else None
158
158
 
@@ -236,9 +236,6 @@ class PollingConfigManager(StaticConfigManager):
236
236
  )
237
237
  self._sdk_key = sdk_key or self._sdk_key
238
238
 
239
- if self._sdk_key is None:
240
- raise optimizely_exceptions.InvalidInputException(enums.Errors.MISSING_SDK_KEY)
241
-
242
239
  self.datafile_url = self.get_datafile_url(
243
240
  self._sdk_key, url, url_template or self.DATAFILE_URL_TEMPLATE
244
241
  )
@@ -515,6 +515,11 @@ class DecisionService:
515
515
  'reasons': decide_reasons,
516
516
  'variation': None
517
517
  }
518
+ ignore_user_profile = True
519
+ self.logger.debug(
520
+ f'Skipping user profile service for CMAB experiment "{experiment.key}". '
521
+ f'CMAB decisions are dynamic and not stored for sticky bucketing.'
522
+ )
518
523
  variation_id = cmab_decision['variation_id'] if cmab_decision else None
519
524
  cmab_uuid = cmab_decision['cmab_uuid'] if cmab_decision else None
520
525
  variation = project_config.get_variation_from_id(experiment_key=experiment.key,
@@ -87,6 +87,7 @@ class Experiment(BaseEntity):
87
87
  groupId: Optional[str] = None,
88
88
  groupPolicy: Optional[str] = None,
89
89
  cmab: Optional[CmabDict] = None,
90
+ type: Optional[str] = None,
90
91
  **kwargs: Any
91
92
  ):
92
93
  self.id = id
@@ -101,6 +102,7 @@ class Experiment(BaseEntity):
101
102
  self.groupId = groupId
102
103
  self.groupPolicy = groupPolicy
103
104
  self.cmab = cmab
105
+ self.type = type
104
106
 
105
107
  def get_audience_conditions_or_ids(self) -> Sequence[str | list[str]]:
106
108
  """ Returns audienceConditions if present, otherwise audienceIds. """
@@ -220,8 +222,6 @@ class Holdout(BaseEntity):
220
222
  variations: list[VariationDict],
221
223
  trafficAllocation: list[TrafficAllocation],
222
224
  audienceIds: list[str],
223
- includedFlags: Optional[list[str]] = None,
224
- excludedFlags: Optional[list[str]] = None,
225
225
  audienceConditions: Optional[Sequence[str | list[str]]] = None,
226
226
  **kwargs: Any
227
227
  ):
@@ -232,8 +232,6 @@ class Holdout(BaseEntity):
232
232
  self.trafficAllocation = trafficAllocation
233
233
  self.audienceIds = audienceIds
234
234
  self.audienceConditions = audienceConditions
235
- self.includedFlags = includedFlags or []
236
- self.excludedFlags = excludedFlags or []
237
235
 
238
236
  def get_audience_conditions_or_ids(self) -> Sequence[str | list[str]]:
239
237
  """Returns audienceConditions if present, otherwise audienceIds.
@@ -49,7 +49,7 @@ class EventDispatcher:
49
49
  session = requests.Session()
50
50
 
51
51
  retries = Retry(total=EventDispatchConfig.RETRIES,
52
- backoff_factor=0.1,
52
+ backoff_factor=0.2,
53
53
  status_forcelist=[500, 502, 503, 504])
54
54
  adapter = HTTPAdapter(max_retries=retries)
55
55
 
@@ -229,6 +229,14 @@ class OdpManagerConfig:
229
229
  EVENT_TYPE: Final = 'fullstack'
230
230
 
231
231
 
232
+ class ExperimentTypes:
233
+ ab: Final = 'ab'
234
+ mab: Final = 'mab'
235
+ cmab: Final = 'cmab'
236
+ td: Final = 'td'
237
+ fr: Final = 'fr'
238
+
239
+
232
240
  class OdpSegmentsCacheConfig:
233
241
  """ODP Segment Cache configs."""
234
242
  DEFAULT_CAPACITY: Final = 10_000
@@ -117,6 +117,10 @@ class CmabDict(BaseEntity):
117
117
  trafficAllocation: int
118
118
 
119
119
 
120
+ ExperimentType = Literal[
121
+ 'ab', 'mab', 'cmab', 'td', 'fr'
122
+ ]
123
+
120
124
  HoldoutStatus = Literal['Draft', 'Running', 'Concluded', 'Archived']
121
125
 
122
126
 
@@ -126,5 +130,3 @@ class HoldoutDict(ExperimentDict):
126
130
  Extends ExperimentDict with holdout-specific properties.
127
131
  """
128
132
  holdoutStatus: HoldoutStatus
129
- includedFlags: list[str]
130
- excludedFlags: list[str]
@@ -193,7 +193,7 @@ def is_user_profile_valid(user_profile: dict[str, Any]) -> bool:
193
193
  if not user_profile:
194
194
  return False
195
195
 
196
- if not type(user_profile) is dict:
196
+ if type(user_profile) is not dict:
197
197
  return False
198
198
 
199
199
  if UserProfile.USER_ID_KEY not in user_profile:
@@ -203,7 +203,7 @@ def is_user_profile_valid(user_profile: dict[str, Any]) -> bool:
203
203
  return False
204
204
 
205
205
  experiment_bucket_map = user_profile.get(UserProfile.EXPERIMENT_BUCKET_MAP_KEY)
206
- if not type(experiment_bucket_map) is dict:
206
+ if type(experiment_bucket_map) is not dict:
207
207
  return False
208
208
 
209
209
  for decision in experiment_bucket_map.values():
@@ -163,6 +163,8 @@ class OdpEventManager:
163
163
 
164
164
  self.logger.debug(f'ODP event queue: flushing batch size {batch_len}.')
165
165
  should_retry = False
166
+ initial_retry_interval = 0.2 # 200ms
167
+ max_retry_interval = 1.0 # 1 second
166
168
 
167
169
  for i in range(1 + self.retry_count):
168
170
  try:
@@ -176,7 +178,12 @@ class OdpEventManager:
176
178
  if not should_retry:
177
179
  break
178
180
  if i < self.retry_count:
179
- self.logger.debug('Error dispatching ODP events, scheduled to retry.')
181
+ # Exponential backoff: 200ms, 400ms, 800ms, ... capped at 1s
182
+ delay = initial_retry_interval * (2 ** i)
183
+ if delay > max_retry_interval:
184
+ delay = max_retry_interval
185
+ self.logger.debug(f'Error dispatching ODP events, retrying after {delay}s.')
186
+ time.sleep(delay)
180
187
 
181
188
  if should_retry:
182
189
  self.logger.error(Errors.ODP_EVENT_FAILED.format(f'Failed after {i} retries: {self._current_batch}'))
@@ -93,9 +93,6 @@ class ProjectConfig:
93
93
  holdouts_data: list[types.HoldoutDict] = config.get('holdouts', [])
94
94
  self.holdouts: list[entities.Holdout] = []
95
95
  self.holdout_id_map: dict[str, entities.Holdout] = {}
96
- self.global_holdouts: list[entities.Holdout] = []
97
- self.included_holdouts: dict[str, list[entities.Holdout]] = {}
98
- self.excluded_holdouts: dict[str, list[entities.Holdout]] = {}
99
96
  self.flag_holdouts_map: dict[str, list[entities.Holdout]] = {}
100
97
 
101
98
  # Convert holdout dicts to Holdout entities
@@ -104,33 +101,13 @@ class ProjectConfig:
104
101
  holdout = entities.Holdout(**holdout_data)
105
102
  self.holdouts.append(holdout)
106
103
 
107
- # Only process Running holdouts but doing it here for efficiency like the original Python implementation)
104
+ # Only process Running holdouts
108
105
  if not holdout.is_activated:
109
106
  continue
110
107
 
111
108
  # Map by ID for quick lookup
112
109
  self.holdout_id_map[holdout.id] = holdout
113
110
 
114
- # Categorize as global vs flag-specific
115
- # Global holdouts: apply to all flags unless explicitly excluded
116
- # Flag-specific holdouts: only apply to explicitly included flags
117
- if not holdout.includedFlags:
118
- # This is a global holdout
119
- self.global_holdouts.append(holdout)
120
-
121
- # Track which flags this global holdout excludes
122
- if holdout.excludedFlags:
123
- for flag_id in holdout.excludedFlags:
124
- if flag_id not in self.excluded_holdouts:
125
- self.excluded_holdouts[flag_id] = []
126
- self.excluded_holdouts[flag_id].append(holdout)
127
- else:
128
- # This holdout applies to specific flags only
129
- for flag_id in holdout.includedFlags:
130
- if flag_id not in self.included_holdouts:
131
- self.included_holdouts[flag_id] = []
132
- self.included_holdouts[flag_id].append(holdout)
133
-
134
111
  # Utility maps for quick lookup
135
112
  self.group_id_map: dict[str, entities.Group] = self._generate_key_map(self.groups, 'id', entities.Group)
136
113
  self.experiment_id_map: dict[str, entities.Experiment] = self._generate_key_map(
@@ -232,19 +209,39 @@ class ProjectConfig:
232
209
  self.experiment_feature_map[exp_id] = [feature.id]
233
210
  rules.append(self.experiment_id_map[exp_id])
234
211
 
235
- flag_id = feature.id
236
- applicable_holdouts: list[entities.Holdout] = []
237
-
238
- # Add global holdouts first, excluding any that are explicitly excluded for this flag
239
- excluded_holdouts = self.excluded_holdouts.get(flag_id, [])
240
- for holdout in self.global_holdouts:
241
- if holdout not in excluded_holdouts:
242
- applicable_holdouts.append(holdout)
243
-
244
- # Add flag-specific local holdouts AFTER global holdouts
245
- if flag_id in self.included_holdouts:
246
- applicable_holdouts.extend(self.included_holdouts[flag_id])
247
-
212
+ # Feature Rollout support: inject the "everyone else" variation
213
+ # into any experiment with type == "feature_rollout"
214
+ everyone_else_variation = self._get_everyone_else_variation(feature)
215
+ if everyone_else_variation is not None:
216
+ for experiment in rules:
217
+ if experiment.type == enums.ExperimentTypes.fr:
218
+ experiment.variations.append({
219
+ 'id': everyone_else_variation.id,
220
+ 'key': everyone_else_variation.key,
221
+ 'featureEnabled': everyone_else_variation.featureEnabled,
222
+ 'variables': cast(
223
+ list[types.VariableDict],
224
+ everyone_else_variation.variables,
225
+ ),
226
+ })
227
+ experiment.trafficAllocation.append({
228
+ 'entityId': everyone_else_variation.id,
229
+ 'endOfRange': 10000,
230
+ })
231
+ self.variation_key_map[experiment.key][everyone_else_variation.key] = everyone_else_variation
232
+ self.variation_id_map[experiment.key][everyone_else_variation.id] = everyone_else_variation
233
+ self.variation_id_map_by_experiment_id[experiment.id][everyone_else_variation.id] = (
234
+ everyone_else_variation
235
+ )
236
+ self.variation_key_map_by_experiment_id[experiment.id][everyone_else_variation.key] = (
237
+ everyone_else_variation
238
+ )
239
+ self.variation_variable_usage_map[everyone_else_variation.id] = self._generate_key_map(
240
+ everyone_else_variation.variables, 'id', entities.Variation.VariableUsage
241
+ )
242
+
243
+ # Map all running holdouts to this flag
244
+ applicable_holdouts = list(self.holdout_id_map.values())
248
245
  if applicable_holdouts:
249
246
  self.flag_holdouts_map[feature.key] = applicable_holdouts
250
247
 
@@ -667,6 +664,41 @@ class ProjectConfig:
667
664
  self.logger.error(f'Rollout with ID "{rollout_id}" is not in datafile.')
668
665
  return None
669
666
 
667
+ def _get_everyone_else_variation(self, flag: entities.FeatureFlag) -> Optional[entities.Variation]:
668
+ """ Get the "everyone else" variation for a feature flag.
669
+
670
+ The "everyone else" rule is the last experiment in the flag's rollout,
671
+ and its first variation is the "everyone else" variation.
672
+
673
+ Args:
674
+ flag: The feature flag to get the everyone else variation for.
675
+
676
+ Returns:
677
+ The "everyone else" Variation entity, or None if not available.
678
+ """
679
+ if not flag.rolloutId:
680
+ return None
681
+
682
+ rollout = self.get_rollout_from_id(flag.rolloutId)
683
+ if not rollout or not rollout.experiments:
684
+ return None
685
+
686
+ everyone_else_rule = rollout.experiments[-1]
687
+ variations = everyone_else_rule.get('variations', [])
688
+ if not variations:
689
+ return None
690
+
691
+ variation_dict = variations[0]
692
+ return entities.Variation(
693
+ id=variation_dict['id'],
694
+ key=variation_dict['key'],
695
+ featureEnabled=bool(variation_dict.get('featureEnabled', False)),
696
+ variables=cast(
697
+ Optional[list[entities.Variable]],
698
+ variation_dict.get('variables'),
699
+ ),
700
+ )
701
+
670
702
  def get_variable_value_for_variation(
671
703
  self, variable: Optional[entities.Variable], variation: Optional[Union[entities.Variation, VariationDict]]
672
704
  ) -> Optional[str]:
@@ -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, 4, 0)
14
+ version_info = (5, 5, 0)
15
15
  __version__ = '.'.join(str(v) for v in version_info)
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: optimizely-sdk
3
- Version: 5.4.0
3
+ Version: 5.5.0
4
4
  Summary: Python SDK for Optimizely Feature Experimentation, Optimizely Full Stack (legacy), and Optimizely Rollouts.
5
5
  Home-page: https://github.com/optimizely/python-sdk
6
6
  Author: Optimizely
@@ -17,8 +17,34 @@ Classifier: Programming Language :: Python :: 3.10
17
17
  Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Description-Content-Type: text/markdown
20
- Provides-Extra: test
21
20
  License-File: LICENSE
21
+ Requires-Dist: jsonschema>=3.2.0
22
+ Requires-Dist: pyrsistent>=0.16.0
23
+ Requires-Dist: requests>=2.21
24
+ Requires-Dist: idna>=2.10
25
+ Requires-Dist: rpds-py<0.20.0; python_version < "3.11"
26
+ Provides-Extra: test
27
+ Requires-Dist: 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"
31
+ Requires-Dist: coverage; extra == "test"
32
+ Requires-Dist: pytest-cov; extra == "test"
33
+ Requires-Dist: rpds-py<0.20.0; python_version < "3.11" and extra == "test"
34
+ 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
+ Requires-Dist: pyrsistent>=0.16.0; extra == "test"
38
+ Dynamic: author
39
+ Dynamic: author-email
40
+ Dynamic: classifier
41
+ Dynamic: description
42
+ Dynamic: description-content-type
43
+ Dynamic: home-page
44
+ Dynamic: license-file
45
+ Dynamic: provides-extra
46
+ Dynamic: requires-dist
47
+ Dynamic: summary
22
48
 
23
49
  Optimizely Feature Experimentation is A/B testing and feature management for product development teams. Experiment in any application. Make every feature on your roadmap an opportunity to learn. Learn more at https://www.optimizely.com/products/experiment/feature-experimentation/ or see our documentation at https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/welcome. # Optimizely Python SDK
24
50
 
@@ -278,6 +304,34 @@ idna (BSD 3-Clause License https://github.com/kjd/idna/blob/master/LICENSE.md)
278
304
  - Swift - https://github.com/optimizely/swift-sdk
279
305
  # Optimizely Python SDK Changelog
280
306
 
307
+ ## 5.5.0
308
+ April 30th, 2026
309
+
310
+ ### New Features
311
+
312
+ #### **Feature Rollout Support**
313
+
314
+ - Added support for Feature Rollouts, a new rule type that combines Targeted Delivery simplicity with A/B test measurement capabilities. During project config parsing, the "everyone else" variation from the flag's rollout is automatically injected into feature rollout experiments, enabling correct evaluation without changes to decision logic. ([#499](https://github.com/optimizely/python-sdk/pull/499))
315
+ - Removed experiment type validation from config parsing to support flexible rule types. ([#500](https://github.com/optimizely/python-sdk/pull/500))
316
+
317
+ ---
318
+
319
+ ### Enhancements
320
+
321
+ - Added event retry support for reliable event dispatching. ([#475](https://github.com/optimizely/python-sdk/pull/475))
322
+ - Excluded CMAB experiments from UserProfileService to prevent stale cached decisions. ([#474](https://github.com/optimizely/python-sdk/pull/474))
323
+ - Cleaned up flag-level holdout fields for simplified configuration. ([#507](https://github.com/optimizely/python-sdk/pull/507))
324
+
325
+ ---
326
+
327
+ ### Bug Fixes
328
+
329
+ - Fixed `return` in `finally` block silently swallowing exceptions. ([#505](https://github.com/optimizely/python-sdk/pull/505))
330
+ - Fixed `PollingConfigManager` rejecting valid URL-only configuration. ([#497](https://github.com/optimizely/python-sdk/pull/497))
331
+
332
+ ---
333
+
334
+
281
335
  ## 5.4.0
282
336
  December 19th, 2025
283
337
 
@@ -7,16 +7,16 @@ idna>=2.10
7
7
  rpds-py<0.20.0
8
8
 
9
9
  [test]
10
- coverage
11
- pyrsistent>=0.16.0
12
- idna>=2.10
13
- pytest>=6.2.0
14
- pytest-cov
15
- funcsigs>=0.4
16
10
  requests>=2.21
11
+ idna>=2.10
12
+ ruff>=0.15.0
17
13
  python-coveralls
14
+ coverage
15
+ pytest-cov
18
16
  jsonschema>=3.2.0
19
- flake8>=4.0.1
17
+ funcsigs>=0.4
18
+ pytest>=6.2.0
19
+ pyrsistent>=0.16.0
20
20
 
21
21
  [test:python_version < "3.11"]
22
22
  rpds-py<0.20.0
@@ -1,5 +1,5 @@
1
1
  coverage
2
- flake8 >= 4.0.1
2
+ ruff >= 0.15.0
3
3
  funcsigs >= 0.4
4
4
  pytest >= 6.2.0
5
5
  pytest-cov
File without changes
File without changes
File without changes
File without changes