2828if TYPE_CHECKING :
2929 # prevent circular dependenacy by skipping import at runtime
3030 from .project_config import ProjectConfig
31- from .entities import Experiment , Variation
32- from .helpers .types import TrafficAllocation
31+ from .entities import Experiment , Variation , Holdout
32+ from .helpers .types import TrafficAllocation , VariationDict
3333
3434
3535MAX_TRAFFIC_VALUE : Final = 10000
@@ -104,8 +104,8 @@ def find_bucket(
104104
105105 def bucket (
106106 self , project_config : ProjectConfig ,
107- experiment : Experiment , user_id : str , bucketing_id : str
108- ) -> tuple [Optional [Variation ], list [str ]]:
107+ experiment : Experiment | Holdout , user_id : str , bucketing_id : str
108+ ) -> tuple [Optional [Variation | VariationDict ], list [str ]]:
109109 """ For a given experiment and bucketing ID determines variation to be shown to user.
110110
111111 Args:
@@ -125,14 +125,9 @@ 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
129+ experiment_key = experiment .key
130+ experiment_id = experiment .id
136131
137132 if not experiment_key or not experiment_key .strip ():
138133 message = 'Invalid entity key provided for bucketing. Returning nil.'
@@ -141,13 +136,7 @@ def bucket(
141136
142137 variation_id , decide_reasons = self .bucket_to_entity_id (project_config , experiment , user_id , bucketing_id )
143138 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 )
139+ variation = project_config .get_variation_from_id_by_experiment_id (experiment_id , variation_id )
151140 return variation , decide_reasons
152141
153142 # No variation found - log message for empty traffic range
@@ -158,7 +147,7 @@ def bucket(
158147
159148 def bucket_to_entity_id (
160149 self , project_config : ProjectConfig ,
161- experiment : Experiment , user_id : str , bucketing_id : str
150+ experiment : Experiment | Holdout , user_id : str , bucketing_id : str
162151 ) -> tuple [Optional [str ], list [str ]]:
163152 """
164153 For a given experiment and bucketing ID determines variation ID to be shown to user.
@@ -176,58 +165,52 @@ def bucket_to_entity_id(
176165 if not experiment :
177166 return None , decide_reasons
178167
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 )
168+ # Handle both Experiment and Holdout entities
169+ # Both entities have key, id, and trafficAllocation attributes
170+ from . import entities
171+
172+ experiment_key = experiment .key
173+ experiment_id = experiment .id
174+ traffic_allocations = experiment .trafficAllocation
175+
176+ # Determine if experiment is in a mutually exclusive group
177+ # Holdouts don't have groupId or groupPolicy - use isinstance for type narrowing
178+ if isinstance (experiment , entities .Experiment ):
193179 group_policy = getattr (experiment , 'groupPolicy' , None )
180+ if group_policy and group_policy in GROUP_POLICIES :
181+ group = project_config .get_group (experiment .groupId )
194182
195- # Determine if experiment is in a mutually exclusive group.
196- # This will not affect evaluation of rollout rules or holdouts.
197- if group_policy and group_policy in GROUP_POLICIES :
198- group = project_config .get_group (experiment .groupId )
183+ if not group :
184+ return None , decide_reasons
199185
200- if not group :
201- return None , decide_reasons
186+ user_experiment_id = self .find_bucket (
187+ project_config , bucketing_id , experiment .groupId , group .trafficAllocation ,
188+ )
202189
203- user_experiment_id = self .find_bucket (
204- project_config , bucketing_id , experiment .groupId , group .trafficAllocation ,
205- )
190+ if not user_experiment_id :
191+ message = f'User "{ user_id } " is in no experiment.'
192+ project_config .logger .info (message )
193+ decide_reasons .append (message )
194+ return None , decide_reasons
206195
207- if not user_experiment_id :
208- message = f'User "{ user_id } " is in no experiment.'
209- project_config .logger .info (message )
210- decide_reasons .append (message )
211- return None , decide_reasons
196+ if user_experiment_id != experiment_id :
197+ message = f'User "{ user_id } " is not in experiment " { experiment_key } " of group { experiment . groupId } .'
198+ project_config .logger .info (message )
199+ decide_reasons .append (message )
200+ return None , decide_reasons
212201
213- if user_experiment_id != experiment_id :
214- message = f'User "{ user_id } " is not in experiment "{ experiment_key } " of group { experiment .groupId } .'
202+ message = f'User "{ user_id } " is in experiment { experiment_key } of group { experiment .groupId } .'
215203 project_config .logger .info (message )
216204 decide_reasons .append (message )
217- return None , decide_reasons
218-
219- message = f'User "{ user_id } " is in experiment { experiment_key } of group { experiment .groupId } .'
220- project_config .logger .info (message )
221- decide_reasons .append (message )
222-
223- if has_cmab :
224- if experiment .cmab :
225- traffic_allocations = [
226- {
227- "entityId" : "$" ,
228- "endOfRange" : experiment .cmab ['trafficAllocation' ]
229- }
230- ]
205+
206+ # Holdouts don't have cmab - use isinstance for type narrowing
207+ if isinstance (experiment , entities .Experiment ) and experiment .cmab :
208+ traffic_allocations = [
209+ {
210+ "entityId" : "$" ,
211+ "endOfRange" : experiment .cmab ['trafficAllocation' ]
212+ }
213+ ]
231214
232215 # Bucket user if not in white-list and in group (if any)
233216 variation_id = self .find_bucket (project_config , bucketing_id ,
0 commit comments