Skip to content

Commit 2650196

Browse files
Refactor: Align Holdout implementation with Swift SDK
This commit refactors the holdout implementation to use proper entity classes aligned with the Swift SDK architecture, improving type safety and consistency across the codebase. ## Changes Made ### 1. entities.py - Added Holdout entity class with proper type safety - Implemented Holdout.Status constants matching Swift enum - Added is_activated property (matches Swift's isActivated) - Added get_audience_conditions_or_ids() method for consistent audience handling ### 2. project_config.py - Converted holdout storage from dicts to Holdout entities - Changed global_holdouts from dict to list (matching Swift SDK) - Convert holdout variations to Variation entities during initialization - Updated get_holdouts_for_flag() to return list[entities.Holdout] - Updated get_holdout() to return Optional[entities.Holdout] ### 3. decision_service.py - Updated get_variation_for_holdout() to accept Holdout entities - Use is_activated property instead of dict status check - Use get_audience_conditions_or_ids() for audience evaluation - CRITICAL FIX: Return holdout as experiment field in Decision (was None) - Removed HoldoutDict import, now uses entities.Holdout ### 4. bucketer.py - Unified handling of Experiment and Holdout entities - Removed dict-based branching for holdouts - Use entity properties directly (key, id, trafficAllocation) - Simplified variation lookup (now consistent for all entity types) ## Benefits - **Type Safety**: Holdouts are now proper entity classes, not plain dicts - **Consistency**: Holdouts work exactly like Experiments throughout codebase - **Swift Alignment**: Python SDK matches Swift SDK's holdout architecture - **Bug Fix**: Decision.experiment now returns Holdout entity (enables metadata access) - **Maintainability**: Unified code paths reduce complexity ## Alignment with Swift SDK This implementation now matches Swift SDK's approach where: - Holdout is a proper struct/class conforming to ExperimentCore - isActivated is a computed property - Variations are proper Variation entities - Decision returns the holdout as the experiment field - Global holdouts stored as arrays/lists 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
1 parent 53421a6 commit 2650196

File tree

4 files changed

+170
-98
lines changed

4 files changed

+170
-98
lines changed

optimizely/bucketer.py

Lines changed: 21 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -125,14 +125,10 @@ def bucket(
125125
project_config.logger.debug(message)
126126
return None, []
127127

128-
if isinstance(experiment, dict):
129-
# This is a holdout dictionary
130-
experiment_key = experiment.get('key', '')
131-
experiment_id = experiment.get('id', '')
132-
else:
133-
# This is an Experiment object
134-
experiment_key = experiment.key
135-
experiment_id = experiment.id
128+
# Handle both Experiment and Holdout entities (aligned with Swift SDK)
129+
# Both have key and id attributes
130+
experiment_key = experiment.key
131+
experiment_id = experiment.id
136132

137133
if not experiment_key or not experiment_key.strip():
138134
message = 'Invalid entity key provided for bucketing. Returning nil.'
@@ -141,13 +137,10 @@ def bucket(
141137

142138
variation_id, decide_reasons = self.bucket_to_entity_id(project_config, experiment, user_id, bucketing_id)
143139
if variation_id:
144-
if isinstance(experiment, dict):
145-
# For holdouts, find the variation in the holdout's variations array
146-
variations = experiment.get('variations', [])
147-
variation = next((v for v in variations if v.get('id') == variation_id), None)
148-
else:
149-
# For experiments, use the existing method
150-
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
140+
# Use the same variation lookup method for both experiments and holdouts
141+
# This works because we now convert holdout variations to Variation entities
142+
# in project_config.py (matching Swift SDK approach)
143+
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
151144
return variation, decide_reasons
152145

153146
# No variation found - log message for empty traffic range
@@ -176,21 +169,19 @@ def bucket_to_entity_id(
176169
if not experiment:
177170
return None, decide_reasons
178171

179-
# Handle both Experiment objects and holdout dictionaries
180-
if isinstance(experiment, dict):
181-
# This is a holdout dictionary - holdouts don't have groups
182-
experiment_key = experiment.get('key', '')
183-
experiment_id = experiment.get('id', '')
184-
traffic_allocations = experiment.get('trafficAllocation', [])
185-
has_cmab = False
186-
group_policy = None
187-
else:
188-
# This is an Experiment object
189-
experiment_key = experiment.key
190-
experiment_id = experiment.id
191-
traffic_allocations = experiment.trafficAllocation
192-
has_cmab = bool(experiment.cmab)
193-
group_policy = getattr(experiment, 'groupPolicy', None)
172+
# Handle both Experiment and Holdout entities (aligned with Swift SDK)
173+
# Both entities have key, id, and trafficAllocation attributes
174+
from . import entities
175+
176+
experiment_key = experiment.key
177+
experiment_id = experiment.id
178+
traffic_allocations = experiment.trafficAllocation
179+
180+
# Check if this is a Holdout entity
181+
# Holdouts don't have groups or CMAB
182+
is_holdout = isinstance(experiment, entities.Holdout)
183+
has_cmab = False if is_holdout else bool(getattr(experiment, 'cmab', None))
184+
group_policy = None if is_holdout else getattr(experiment, 'groupPolicy', None)
194185

195186
# Determine if experiment is in a mutually exclusive group.
196187
# This will not affect evaluation of rollout rules or holdouts.

optimizely/decision_service.py

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from __future__ import annotations
1515
from typing import TYPE_CHECKING, NamedTuple, Optional, Sequence, List, TypedDict, Union
1616

17-
from optimizely.helpers.types import HoldoutDict, VariationDict
1817

1918
from . import bucketer
2019
from . import entities
@@ -719,7 +718,7 @@ def get_decision_for_flag(
719718
continue
720719

721720
message = (
722-
f"The user '{user_id}' is bucketed into holdout '{holdout['key']}' "
721+
f"The user '{user_id}' is bucketed into holdout '{holdout.key}' "
723722
f"for feature flag '{feature_flag.key}'."
724723
)
725724
self.logger.info(message)
@@ -748,15 +747,17 @@ def get_decision_for_flag(
748747

749748
def get_variation_for_holdout(
750749
self,
751-
holdout: HoldoutDict,
750+
holdout: entities.Holdout,
752751
user_context: OptimizelyUserContext,
753752
project_config: ProjectConfig
754753
) -> DecisionResult:
755754
"""
756755
Get the variation for holdout.
757756
757+
Aligned with Swift SDK's getVariationForHoldout which returns DecisionResponse<Variation>.
758+
758759
Args:
759-
holdout: The holdout configuration (HoldoutDict).
760+
holdout: The holdout configuration (Holdout entity).
760761
user_context: The user context.
761762
project_config: The project config.
762763
@@ -769,9 +770,10 @@ def get_variation_for_holdout(
769770
user_id = user_context.user_id
770771
attributes = user_context.get_user_attributes()
771772

772-
if not holdout or not holdout.get('status') or holdout.get('status') != 'Running':
773-
key = holdout.get('key') if holdout else 'unknown'
774-
message = f"Holdout '{key}' is not running."
773+
# Check if holdout is activated (Running status)
774+
# Aligned with Swift's: guard holdout.isActivated else { return nil }
775+
if not holdout.is_activated:
776+
message = f"Holdout '{holdout.key}' is not running."
775777
self.logger.info(message)
776778
decide_reasons.append(message)
777779
return {
@@ -783,13 +785,14 @@ def get_variation_for_holdout(
783785
bucketing_id, bucketing_id_reasons = self._get_bucketing_id(user_id, attributes)
784786
decide_reasons.extend(bucketing_id_reasons)
785787

786-
# Check audience conditions
787-
audience_conditions = holdout.get('audienceIds')
788+
# Check audience conditions using the same method as experiments
789+
# Holdout now has get_audience_conditions_or_ids() just like Experiment
790+
audience_conditions = holdout.get_audience_conditions_or_ids()
788791
user_meets_audience_conditions, reasons_received = audience_helper.does_user_meet_audience_conditions(
789792
project_config,
790793
audience_conditions,
791794
ExperimentAudienceEvaluationLogs,
792-
holdout.get('key', 'unknown'),
795+
holdout.key,
793796
user_context,
794797
self.logger
795798
)
@@ -798,7 +801,7 @@ def get_variation_for_holdout(
798801
if not user_meets_audience_conditions:
799802
message = (
800803
f"User '{user_id}' does not meet the conditions for holdout "
801-
f"'{holdout['key']}'."
804+
f"'{holdout.key}'."
802805
)
803806
self.logger.debug(message)
804807
decide_reasons.append(message)
@@ -810,23 +813,23 @@ def get_variation_for_holdout(
810813

811814
# Bucket user into holdout variation
812815
variation, bucket_reasons = self.bucketer.bucket(
813-
project_config, holdout, user_id, bucketing_id # type: ignore[arg-type]
816+
project_config, holdout, user_id, bucketing_id
814817
)
815818
decide_reasons.extend(bucket_reasons)
816819

817820
if variation:
818-
# For holdouts, variation is a dict, not a Variation entity
819-
variation_key = variation['key'] if isinstance(variation, dict) else variation.key
820821
message = (
821-
f"The user '{user_id}' is bucketed into variation '{variation_key}' "
822-
f"of holdout '{holdout['key']}'."
822+
f"The user '{user_id}' is bucketed into variation '{variation.key}' "
823+
f"of holdout '{holdout.key}'."
823824
)
824825
self.logger.info(message)
825826
decide_reasons.append(message)
826827

827-
# Create Decision for holdout - experiment is None, source is HOLDOUT
828+
# CRITICAL FIX: Return holdout as the experiment field (matching Swift SDK)
829+
# Swift: return FeatureDecision(experiment: holdout, variation: variation, source: .holdout)
830+
# Previously Python returned experiment=None which was inconsistent
828831
holdout_decision: Decision = Decision(
829-
experiment=None,
832+
experiment=holdout, # Changed from None to holdout
830833
variation=variation,
831834
source=enums.DecisionSources.HOLDOUT,
832835
cmab_uuid=None
@@ -837,7 +840,7 @@ def get_variation_for_holdout(
837840
'reasons': decide_reasons
838841
}
839842

840-
message = f"User '{user_id}' is not bucketed into any variation for holdout '{holdout['key']}'."
843+
message = f"User '{user_id}' is not bucketed into any variation for holdout '{holdout.key}'."
841844
self.logger.info(message)
842845
decide_reasons.append(message)
843846
return {

optimizely/entities.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,3 +194,67 @@ def __init__(self, key: str, host: Optional[str] = None, publicKey: Optional[str
194194
self.key = key
195195
self.host = host
196196
self.publicKey = publicKey
197+
198+
199+
class Holdout(BaseEntity):
200+
"""Holdout entity representing a holdout experiment for measuring incremental impact.
201+
202+
Aligned with Swift SDK implementation where Holdout is a proper entity class
203+
that conforms to ExperimentCore protocol.
204+
"""
205+
206+
class Status:
207+
"""Holdout status constants matching Swift enum Status."""
208+
DRAFT: Final = 'Draft'
209+
RUNNING: Final = 'Running'
210+
CONCLUDED: Final = 'Concluded'
211+
ARCHIVED: Final = 'Archived'
212+
213+
def __init__(
214+
self,
215+
id: str,
216+
key: str,
217+
status: str,
218+
variations: list[VariationDict],
219+
trafficAllocation: list[TrafficAllocation],
220+
audienceIds: list[str],
221+
includedFlags: list[str],
222+
excludedFlags: list[str],
223+
audienceConditions: Optional[Sequence[str | list[str]]] = None,
224+
**kwargs: Any
225+
):
226+
self.id = id
227+
self.key = key
228+
self.status = status
229+
self.variations = variations
230+
self.trafficAllocation = trafficAllocation
231+
self.audienceIds = audienceIds
232+
self.audienceConditions = audienceConditions
233+
self.includedFlags = includedFlags or []
234+
self.excludedFlags = excludedFlags or []
235+
236+
# Not necessary for holdouts but included for compatibility with ExperimentCore pattern
237+
self.layerId = ''
238+
239+
def get_audience_conditions_or_ids(self) -> Sequence[str | list[str]]:
240+
"""Returns audienceConditions if present, otherwise audienceIds.
241+
242+
This matches the Experiment.get_audience_conditions_or_ids() method
243+
and enables holdouts to work with the same audience evaluation logic.
244+
"""
245+
return self.audienceConditions if self.audienceConditions is not None else self.audienceIds
246+
247+
@property
248+
def is_activated(self) -> bool:
249+
"""Check if the holdout is activated (running).
250+
251+
Matches Swift's isActivated computed property:
252+
var isActivated: Bool { return status == .running }
253+
254+
Returns:
255+
True if status is 'Running', False otherwise.
256+
"""
257+
return self.status == self.Status.RUNNING
258+
259+
def __str__(self) -> str:
260+
return self.key

0 commit comments

Comments
 (0)