From 52525dfe6aa97c447d12819762d18a2b042e4ef2 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Tue, 3 Feb 2026 23:21:56 +0600 Subject: [PATCH] [FSSDK-12265] Add experiments field and mapping to Holdout data model - Add experiments field to Holdout entity with default empty list - Add is_local property that returns True when experiments list is not empty - Add experiment_holdouts_map to ProjectConfig for experiment-to-holdout mappings - Add get_holdouts_for_experiment method to retrieve holdouts for a specific experiment - Add comprehensive unit tests covering all new functionality - Ensure backward compatibility with existing holdout functionality This enables experiment-specific holdouts to be identified and retrieved efficiently. --- optimizely/entities.py | 15 ++ optimizely/helpers/types.py | 1 + optimizely/project_config.py | 23 ++ tests/test_config.py | 407 +++++++++++++++++++++++++++++++++++ 4 files changed, 446 insertions(+) diff --git a/optimizely/entities.py b/optimizely/entities.py index 12f4f849..1a42ecb7 100644 --- a/optimizely/entities.py +++ b/optimizely/entities.py @@ -223,6 +223,7 @@ def __init__( includedFlags: Optional[list[str]] = None, excludedFlags: Optional[list[str]] = None, audienceConditions: Optional[Sequence[str | list[str]]] = None, + experiments: Optional[list[str]] = None, **kwargs: Any ): self.id = id @@ -234,6 +235,7 @@ def __init__( self.audienceConditions = audienceConditions self.includedFlags = includedFlags or [] self.excludedFlags = excludedFlags or [] + self.experiments = experiments or [] def get_audience_conditions_or_ids(self) -> Sequence[str | list[str]]: """Returns audienceConditions if present, otherwise audienceIds. @@ -255,6 +257,19 @@ def is_activated(self) -> bool: """ return self.status == self.Status.RUNNING + @property + def is_local(self) -> bool: + """Check if the holdout is local (experiment-specific). + + A holdout is considered local if it targets specific experiments. + Matches Swift's isLocal computed property: + var isLocal: Bool { return !experiments.isEmpty } + + Returns: + True if experiments list is not empty, False otherwise. + """ + return len(self.experiments) > 0 + def __str__(self) -> str: return self.key diff --git a/optimizely/helpers/types.py b/optimizely/helpers/types.py index d4177dc0..83e8241d 100644 --- a/optimizely/helpers/types.py +++ b/optimizely/helpers/types.py @@ -128,3 +128,4 @@ class HoldoutDict(ExperimentDict): holdoutStatus: HoldoutStatus includedFlags: list[str] excludedFlags: list[str] + experiments: list[str] diff --git a/optimizely/project_config.py b/optimizely/project_config.py index 74442d7a..68db44c6 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -97,6 +97,7 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any): self.included_holdouts: dict[str, list[entities.Holdout]] = {} self.excluded_holdouts: dict[str, list[entities.Holdout]] = {} self.flag_holdouts_map: dict[str, list[entities.Holdout]] = {} + self.experiment_holdouts_map: dict[str, list[entities.Holdout]] = {} # Convert holdout dicts to Holdout entities for holdout_data in holdouts_data: @@ -131,6 +132,13 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any): self.included_holdouts[flag_id] = [] self.included_holdouts[flag_id].append(holdout) + # Build experiment-to-holdout mappings for local holdouts + if holdout.experiments: + for experiment_id in holdout.experiments: + if experiment_id not in self.experiment_holdouts_map: + self.experiment_holdouts_map[experiment_id] = [] + self.experiment_holdouts_map[experiment_id].append(holdout) + # Utility maps for quick lookup self.group_id_map: dict[str, entities.Group] = self._generate_key_map(self.groups, 'id', entities.Group) self.experiment_id_map: dict[str, entities.Experiment] = self._generate_key_map( @@ -876,3 +884,18 @@ def get_holdout(self, holdout_id: str) -> Optional[entities.Holdout]: self.logger.error(f'Holdout with ID "{holdout_id}" not found.') return None + + def get_holdouts_for_experiment(self, experiment_id: str) -> list[entities.Holdout]: + """ Helper method to get holdouts targeting a specific experiment. + + Args: + experiment_id: ID of the experiment. + + Returns: + The holdouts that apply to this experiment as Holdout entity objects. + Returns empty list if no holdouts target this experiment. + """ + if not self.holdouts: + return [] + + return self.experiment_holdouts_map.get(experiment_id, []) diff --git a/tests/test_config.py b/tests/test_config.py index 81228feb..d35fe2e8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1545,3 +1545,410 @@ def test_holdout_initialization__only_processes_running_holdouts(self): boolean_feature_id = '91111' included_for_boolean = self.config_with_holdouts.included_holdouts.get(boolean_feature_id) self.assertIsNone(included_for_boolean) + + def test_holdout_experiments_field__defaults_to_empty_list(self): + """ Test that holdout experiments field defaults to empty list. """ + + # Create a holdout without experiments field + config_body = copy.deepcopy(self.config_dict_with_features) + config_body['holdouts'] = [ + { + 'id': 'holdout_no_exp', + 'key': 'no_experiments_holdout', + 'status': 'Running', + 'variations': [{'id': '1', 'key': 'on'}], + 'trafficAllocation': [], + 'audienceIds': [], + 'includedFlags': [], + 'excludedFlags': [] + } + ] + + config_json = json.dumps(config_body) + opt_obj = optimizely.Optimizely(config_json) + config = opt_obj.config_manager.get_config() + + holdout = config.get_holdout('holdout_no_exp') + self.assertIsNotNone(holdout) + self.assertEqual(holdout.experiments, []) + self.assertFalse(holdout.is_local) + + def test_holdout_experiments_field__is_populated_from_datafile(self): + """ Test that holdout experiments field is populated from datafile. """ + + # Create a holdout with experiments field + config_body = copy.deepcopy(self.config_dict_with_features) + config_body['holdouts'] = [ + { + 'id': 'holdout_with_exp', + 'key': 'with_experiments_holdout', + 'status': 'Running', + 'variations': [{'id': '1', 'key': 'on'}], + 'trafficAllocation': [], + 'audienceIds': [], + 'includedFlags': [], + 'excludedFlags': [], + 'experiments': ['111127', '32222'] + } + ] + + config_json = json.dumps(config_body) + opt_obj = optimizely.Optimizely(config_json) + config = opt_obj.config_manager.get_config() + + holdout = config.get_holdout('holdout_with_exp') + self.assertIsNotNone(holdout) + self.assertEqual(holdout.experiments, ['111127', '32222']) + self.assertTrue(holdout.is_local) + + def test_holdout_is_local__returns_true_when_experiments_not_empty(self): + """ Test that is_local returns True when experiments list is not empty. """ + + config_body = copy.deepcopy(self.config_dict_with_features) + config_body['holdouts'] = [ + { + 'id': 'local_holdout', + 'key': 'local_holdout', + 'status': 'Running', + 'variations': [{'id': '1', 'key': 'on'}], + 'trafficAllocation': [], + 'audienceIds': [], + 'includedFlags': [], + 'excludedFlags': [], + 'experiments': ['exp_1', 'exp_2'] + } + ] + + config_json = json.dumps(config_body) + opt_obj = optimizely.Optimizely(config_json) + config = opt_obj.config_manager.get_config() + + holdout = config.get_holdout('local_holdout') + self.assertTrue(holdout.is_local) + + def test_holdout_is_local__returns_false_when_experiments_empty(self): + """ Test that is_local returns False when experiments list is empty. """ + + config_body = copy.deepcopy(self.config_dict_with_features) + config_body['holdouts'] = [ + { + 'id': 'global_holdout', + 'key': 'global_holdout', + 'status': 'Running', + 'variations': [{'id': '1', 'key': 'on'}], + 'trafficAllocation': [], + 'audienceIds': [], + 'includedFlags': [], + 'excludedFlags': [], + 'experiments': [] + } + ] + + config_json = json.dumps(config_body) + opt_obj = optimizely.Optimizely(config_json) + config = opt_obj.config_manager.get_config() + + holdout = config.get_holdout('global_holdout') + self.assertFalse(holdout.is_local) + + def test_get_holdouts_for_experiment__returns_empty_for_non_existent_experiment(self): + """ Test that get_holdouts_for_experiment returns empty list for non-existent experiment. """ + + holdouts = self.project_config.get_holdouts_for_experiment('non_existent_experiment') + self.assertEqual([], holdouts) + + def test_get_holdouts_for_experiment__returns_holdouts_for_valid_experiment(self): + """ Test that get_holdouts_for_experiment returns holdouts for a valid experiment. """ + + config_body = copy.deepcopy(self.config_dict_with_features) + config_body['holdouts'] = [ + { + 'id': 'holdout_exp1', + 'key': 'holdout_exp1', + 'status': 'Running', + 'variations': [{'id': '1', 'key': 'on'}], + 'trafficAllocation': [], + 'audienceIds': [], + 'includedFlags': [], + 'excludedFlags': [], + 'experiments': ['111127'] + } + ] + + config_json = json.dumps(config_body) + opt_obj = optimizely.Optimizely(config_json) + config = opt_obj.config_manager.get_config() + + holdouts = config.get_holdouts_for_experiment('111127') + self.assertEqual(1, len(holdouts)) + self.assertEqual('holdout_exp1', holdouts[0].id) + + def test_get_holdouts_for_experiment__one_holdout_multiple_experiments(self): + """ Test that one holdout targeting multiple experiments appears in all mappings. """ + + config_body = copy.deepcopy(self.config_dict_with_features) + config_body['holdouts'] = [ + { + 'id': 'multi_exp_holdout', + 'key': 'multi_exp_holdout', + 'status': 'Running', + 'variations': [{'id': '1', 'key': 'on'}], + 'trafficAllocation': [], + 'audienceIds': [], + 'includedFlags': [], + 'excludedFlags': [], + 'experiments': ['111127', '32222', '32223'] + } + ] + + config_json = json.dumps(config_body) + opt_obj = optimizely.Optimizely(config_json) + config = opt_obj.config_manager.get_config() + + # Verify holdout appears in all three experiment mappings + holdouts_exp1 = config.get_holdouts_for_experiment('111127') + holdouts_exp2 = config.get_holdouts_for_experiment('32222') + holdouts_exp3 = config.get_holdouts_for_experiment('32223') + + self.assertEqual(1, len(holdouts_exp1)) + self.assertEqual(1, len(holdouts_exp2)) + self.assertEqual(1, len(holdouts_exp3)) + + self.assertEqual('multi_exp_holdout', holdouts_exp1[0].id) + self.assertEqual('multi_exp_holdout', holdouts_exp2[0].id) + self.assertEqual('multi_exp_holdout', holdouts_exp3[0].id) + + def test_get_holdouts_for_experiment__multiple_holdouts_one_experiment(self): + """ Test that multiple holdouts targeting same experiment all appear in mapping. """ + + config_body = copy.deepcopy(self.config_dict_with_features) + config_body['holdouts'] = [ + { + 'id': 'holdout_1_exp', + 'key': 'holdout_1_exp', + 'status': 'Running', + 'variations': [{'id': '1', 'key': 'on'}], + 'trafficAllocation': [], + 'audienceIds': [], + 'includedFlags': [], + 'excludedFlags': [], + 'experiments': ['111127'] + }, + { + 'id': 'holdout_2_exp', + 'key': 'holdout_2_exp', + 'status': 'Running', + 'variations': [{'id': '1', 'key': 'on'}], + 'trafficAllocation': [], + 'audienceIds': [], + 'includedFlags': [], + 'excludedFlags': [], + 'experiments': ['111127'] + }, + { + 'id': 'holdout_3_exp', + 'key': 'holdout_3_exp', + 'status': 'Running', + 'variations': [{'id': '1', 'key': 'on'}], + 'trafficAllocation': [], + 'audienceIds': [], + 'includedFlags': [], + 'excludedFlags': [], + 'experiments': ['111127'] + } + ] + + config_json = json.dumps(config_body) + opt_obj = optimizely.Optimizely(config_json) + config = opt_obj.config_manager.get_config() + + holdouts = config.get_holdouts_for_experiment('111127') + self.assertEqual(3, len(holdouts)) + + holdout_ids = {h.id for h in holdouts} + self.assertEqual({'holdout_1_exp', 'holdout_2_exp', 'holdout_3_exp'}, holdout_ids) + + def test_get_holdouts_for_experiment__maintains_insertion_order(self): + """ Test that get_holdouts_for_experiment maintains insertion order. """ + + config_body = copy.deepcopy(self.config_dict_with_features) + config_body['holdouts'] = [ + { + 'id': 'holdout_first', + 'key': 'holdout_first', + 'status': 'Running', + 'variations': [{'id': '1', 'key': 'on'}], + 'trafficAllocation': [], + 'audienceIds': [], + 'includedFlags': [], + 'excludedFlags': [], + 'experiments': ['exp1'] + }, + { + 'id': 'holdout_second', + 'key': 'holdout_second', + 'status': 'Running', + 'variations': [{'id': '1', 'key': 'on'}], + 'trafficAllocation': [], + 'audienceIds': [], + 'includedFlags': [], + 'excludedFlags': [], + 'experiments': ['exp1'] + }, + { + 'id': 'holdout_third', + 'key': 'holdout_third', + 'status': 'Running', + 'variations': [{'id': '1', 'key': 'on'}], + 'trafficAllocation': [], + 'audienceIds': [], + 'includedFlags': [], + 'excludedFlags': [], + 'experiments': ['exp1'] + } + ] + + config_json = json.dumps(config_body) + opt_obj = optimizely.Optimizely(config_json) + config = opt_obj.config_manager.get_config() + + holdouts = config.get_holdouts_for_experiment('exp1') + self.assertEqual(3, len(holdouts)) + self.assertEqual('holdout_first', holdouts[0].id) + self.assertEqual('holdout_second', holdouts[1].id) + self.assertEqual('holdout_third', holdouts[2].id) + + def test_experiment_holdouts_mapping_independent_of_flag_mapping(self): + """ Test that experiment mapping works independently from flag mapping. """ + + config_body = copy.deepcopy(self.config_dict_with_features) + config_body['holdouts'] = [ + { + 'id': 'flag_holdout', + 'key': 'flag_holdout', + 'status': 'Running', + 'variations': [{'id': '1', 'key': 'on'}], + 'trafficAllocation': [], + 'audienceIds': [], + 'includedFlags': ['91114'], # test_feature_in_experiment_and_rollout + 'excludedFlags': [], + 'experiments': [] + }, + { + 'id': 'exp_holdout_global', + 'key': 'exp_holdout_global', + 'status': 'Running', + 'variations': [{'id': '1', 'key': 'on'}], + 'trafficAllocation': [], + 'audienceIds': [], + 'includedFlags': [], + 'excludedFlags': [], + 'experiments': ['111127'] + }, + { + 'id': 'exp_holdout_specific', + 'key': 'exp_holdout_specific', + 'status': 'Running', + 'variations': [{'id': '1', 'key': 'on'}], + 'trafficAllocation': [], + 'audienceIds': [], + 'includedFlags': ['91114'], + 'excludedFlags': [], + 'experiments': ['111127'] + }, + { + 'id': 'global_holdout', + 'key': 'global_holdout', + 'status': 'Running', + 'variations': [{'id': '1', 'key': 'on'}], + 'trafficAllocation': [], + 'audienceIds': [], + 'includedFlags': [], + 'excludedFlags': [], + 'experiments': [] + } + ] + + config_json = json.dumps(config_body) + opt_obj = optimizely.Optimizely(config_json) + config = opt_obj.config_manager.get_config() + + # Verify flag mapping works correctly + # exp_holdout_global has no includedFlags, so it's global and applies to all flags + # exp_holdout_specific has includedFlags, so it only applies to that flag + flag_holdouts = config.get_holdouts_for_flag('test_feature_in_experiment_and_rollout') + flag_holdout_ids = {h.id for h in flag_holdouts} + + # Should include global (including exp_holdout_global), and flag-specific + self.assertIn('global_holdout', flag_holdout_ids) + self.assertIn('flag_holdout', flag_holdout_ids) + self.assertIn('exp_holdout_global', flag_holdout_ids) # Global because no includedFlags + self.assertIn('exp_holdout_specific', flag_holdout_ids) # Specific to this flag + + # Verify experiment mapping works independently + exp_holdouts = config.get_holdouts_for_experiment('111127') + exp_holdout_ids = {h.id for h in exp_holdouts} + + # Should include both holdouts that target experiment 111127 + self.assertEqual(2, len(exp_holdouts)) + self.assertIn('exp_holdout_global', exp_holdout_ids) + self.assertIn('exp_holdout_specific', exp_holdout_ids) + + # global_holdout has no experiments, so it shouldn't be in experiment mapping + self.assertNotIn('global_holdout', exp_holdout_ids) + # flag_holdout has no experiments, so it shouldn't be in experiment mapping + self.assertNotIn('flag_holdout', exp_holdout_ids) + + def test_holdout_equality__includes_experiments_field(self): + """ Test that holdout equality comparison includes experiments field. """ + + config_body = copy.deepcopy(self.config_dict_with_features) + config_body['holdouts'] = [ + { + 'id': 'holdout_a', + 'key': 'holdout_a', + 'status': 'Running', + 'variations': [{'id': '1', 'key': 'on'}], + 'trafficAllocation': [], + 'audienceIds': [], + 'includedFlags': [], + 'excludedFlags': [], + 'experiments': ['exp1', 'exp2'] + } + ] + + config_json = json.dumps(config_body) + opt_obj = optimizely.Optimizely(config_json) + config = opt_obj.config_manager.get_config() + + holdout1 = config.get_holdout('holdout_a') + + # Create identical holdout + holdout2 = entities.Holdout( + id='holdout_a', + key='holdout_a', + status='Running', + variations=[{'id': '1', 'key': 'on'}], + trafficAllocation=[], + audienceIds=[], + includedFlags=[], + excludedFlags=[], + experiments=['exp1', 'exp2'] + ) + + self.assertEqual(holdout1, holdout2) + + # Create holdout with different experiments + holdout3 = entities.Holdout( + id='holdout_a', + key='holdout_a', + status='Running', + variations=[{'id': '1', 'key': 'on'}], + trafficAllocation=[], + audienceIds=[], + includedFlags=[], + excludedFlags=[], + experiments=['exp3'] + ) + + self.assertNotEqual(holdout1, holdout3)