1212# limitations under the License.
1313
1414from __future__ import annotations
15- from typing import TYPE_CHECKING , NamedTuple , Optional , Sequence , List , TypedDict
15+ from typing import TYPE_CHECKING , NamedTuple , Optional , Sequence , List , TypedDict , Union
16+
17+ from optimizely .helpers .types import HoldoutDict , VariationDict
1618
1719from . import bucketer
1820from . import entities
@@ -59,7 +61,7 @@ class VariationResult(TypedDict):
5961 cmab_uuid : Optional [str ]
6062 error : bool
6163 reasons : List [str ]
62- variation : Optional [entities .Variation ]
64+ variation : Optional [Union [ entities .Variation , VariationDict ] ]
6365
6466
6567class DecisionResult (TypedDict ):
@@ -80,7 +82,7 @@ class Decision(NamedTuple):
8082 """Named tuple containing selected experiment, variation, source and cmab_uuid.
8183 None if no experiment/variation was selected."""
8284 experiment : Optional [entities .Experiment ]
83- variation : Optional [entities .Variation ]
85+ variation : Optional [Union [ entities .Variation , VariationDict ] ]
8486 source : Optional [str ]
8587 cmab_uuid : Optional [str ]
8688
@@ -670,7 +672,179 @@ def get_variation_for_feature(
670672 - 'error': Boolean indicating if an error occurred during the decision process.
671673 - 'reasons': List of log messages representing decision making for the feature.
672674 """
673- return self .get_variations_for_feature_list (project_config , [feature ], user_context , options )[0 ]
675+ holdouts = project_config .get_holdouts_for_flag (feature .key )
676+
677+ if holdouts :
678+ # Has holdouts - use get_decision_for_flag which checks holdouts first
679+ return self .get_decision_for_flag (feature , user_context , project_config , options )
680+ else :
681+ return self .get_variations_for_feature_list (project_config , [feature ], user_context , options )[0 ]
682+
683+ def get_decision_for_flag (
684+ self ,
685+ feature_flag : entities .FeatureFlag ,
686+ user_context : OptimizelyUserContext ,
687+ project_config : ProjectConfig ,
688+ decide_options : Optional [Sequence [str ]] = None ,
689+ user_profile_tracker : Optional [UserProfileTracker ] = None ,
690+ decide_reasons : Optional [list [str ]] = None
691+ ) -> DecisionResult :
692+ """
693+ Get the decision for a single feature flag.
694+ Processes holdouts, experiments, and rollouts in that order.
695+
696+ Args:
697+ feature_flag: The feature flag to get a decision for.
698+ user_context: The user context.
699+ project_config: The project config.
700+ decide_options: Sequence of decide options.
701+ user_profile_tracker: The user profile tracker.
702+ decide_reasons: List of decision reasons to merge.
703+
704+ Returns:
705+ A DecisionResult for the feature flag.
706+ """
707+ reasons = decide_reasons .copy () if decide_reasons else []
708+ user_id = user_context .user_id
709+
710+ # Check holdouts
711+ holdouts = project_config .get_holdouts_for_flag (feature_flag .key )
712+ for holdout in holdouts :
713+ holdout_decision = self .get_variation_for_holdout (holdout , user_context , project_config )
714+ reasons .extend (holdout_decision ['reasons' ])
715+
716+ decision = holdout_decision ['decision' ]
717+ # Check if user was bucketed into holdout (has a variation)
718+ if decision .variation is None :
719+ continue
720+
721+ message = (
722+ f"The user '{ user_id } ' is bucketed into holdout '{ holdout ['key' ]} ' "
723+ f"for feature flag '{ feature_flag .key } '."
724+ )
725+ self .logger .info (message )
726+ reasons .append (message )
727+ return {
728+ 'decision' : holdout_decision ['decision' ],
729+ 'error' : False ,
730+ 'reasons' : reasons
731+ }
732+
733+ # If no holdout decision, fall back to existing experiment/rollout logic
734+ # Use get_variations_for_feature_list which handles experiments and rollouts
735+ fallback_result = self .get_variations_for_feature_list (
736+ project_config , [feature_flag ], user_context , decide_options
737+ )[0 ]
738+
739+ # Merge reasons
740+ if fallback_result .get ('reasons' ):
741+ reasons .extend (fallback_result ['reasons' ])
742+
743+ return {
744+ 'decision' : fallback_result ['decision' ],
745+ 'error' : fallback_result .get ('error' , False ),
746+ 'reasons' : reasons
747+ }
748+
749+ def get_variation_for_holdout (
750+ self ,
751+ holdout : HoldoutDict ,
752+ user_context : OptimizelyUserContext ,
753+ project_config : ProjectConfig
754+ ) -> DecisionResult :
755+ """
756+ Get the variation for holdout.
757+
758+ Args:
759+ holdout: The holdout configuration (HoldoutDict).
760+ user_context: The user context.
761+ project_config: The project config.
762+
763+ Returns:
764+ A DecisionResult for the holdout.
765+ """
766+ from optimizely .helpers .enums import ExperimentAudienceEvaluationLogs
767+
768+ decide_reasons : list [str ] = []
769+ user_id = user_context .user_id
770+ attributes = user_context .get_user_attributes ()
771+
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."
775+ self .logger .info (message )
776+ decide_reasons .append (message )
777+ return {
778+ 'decision' : Decision (None , None , enums .DecisionSources .HOLDOUT , None ),
779+ 'error' : False ,
780+ 'reasons' : decide_reasons
781+ }
782+
783+ bucketing_id , bucketing_id_reasons = self ._get_bucketing_id (user_id , attributes )
784+ decide_reasons .extend (bucketing_id_reasons )
785+
786+ # Check audience conditions
787+ audience_conditions = holdout .get ('audienceIds' )
788+ user_meets_audience_conditions , reasons_received = audience_helper .does_user_meet_audience_conditions (
789+ project_config ,
790+ audience_conditions ,
791+ ExperimentAudienceEvaluationLogs ,
792+ holdout .get ('key' , 'unknown' ),
793+ user_context ,
794+ self .logger
795+ )
796+ decide_reasons .extend (reasons_received )
797+
798+ if not user_meets_audience_conditions :
799+ message = (
800+ f"User '{ user_id } ' does not meet the conditions for holdout "
801+ f"'{ holdout ['key' ]} '."
802+ )
803+ self .logger .debug (message )
804+ decide_reasons .append (message )
805+ return {
806+ 'decision' : Decision (None , None , enums .DecisionSources .HOLDOUT , None ),
807+ 'error' : False ,
808+ 'reasons' : decide_reasons
809+ }
810+
811+ # Bucket user into holdout variation
812+ variation , bucket_reasons = self .bucketer .bucket (
813+ project_config , holdout , user_id , bucketing_id # type: ignore[arg-type]
814+ )
815+ decide_reasons .extend (bucket_reasons )
816+
817+ 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
820+ message = (
821+ f"The user '{ user_id } ' is bucketed into variation '{ variation_key } ' "
822+ f"of holdout '{ holdout ['key' ]} '."
823+ )
824+ self .logger .info (message )
825+ decide_reasons .append (message )
826+
827+ # Create Decision for holdout - experiment is None, source is HOLDOUT
828+ holdout_decision : Decision = Decision (
829+ experiment = None ,
830+ variation = variation ,
831+ source = enums .DecisionSources .HOLDOUT ,
832+ cmab_uuid = None
833+ )
834+ return {
835+ 'decision' : holdout_decision ,
836+ 'error' : False ,
837+ 'reasons' : decide_reasons
838+ }
839+
840+ message = f"User '{ user_id } ' is not bucketed into any variation for holdout '{ holdout ['key' ]} '."
841+ self .logger .info (message )
842+ decide_reasons .append (message )
843+ return {
844+ 'decision' : Decision (None , None , enums .DecisionSources .HOLDOUT , None ),
845+ 'error' : False ,
846+ 'reasons' : decide_reasons
847+ }
674848
675849 def validated_forced_decision (
676850 self ,
@@ -779,7 +953,7 @@ def get_variations_for_feature_list(
779953 if feature .experimentIds :
780954 for experiment_id in feature .experimentIds :
781955 experiment = project_config .get_experiment_from_id (experiment_id )
782- decision_variation = None
956+ decision_variation : Optional [ Union [ entities . Variation , VariationDict ]] = None
783957
784958 if experiment :
785959 optimizely_decision_context = OptimizelyUserContext .OptimizelyDecisionContext (
0 commit comments