diff --git a/optimizely/entities.py b/optimizely/entities.py index 12f4f849..6aa0060d 100644 --- a/optimizely/entities.py +++ b/optimizely/entities.py @@ -22,7 +22,7 @@ if TYPE_CHECKING: # prevent circular dependenacy by skipping import at runtime - from .helpers.types import ExperimentDict, TrafficAllocation, VariableDict, VariationDict, CmabDict + from .helpers.types import ExperimentDict, ExperimentType, TrafficAllocation, VariableDict, VariationDict, CmabDict class BaseEntity: @@ -87,6 +87,7 @@ def __init__( groupId: Optional[str] = None, groupPolicy: Optional[str] = None, cmab: Optional[CmabDict] = None, + type: Optional[ExperimentType] = None, **kwargs: Any ): self.id = id @@ -101,6 +102,7 @@ def __init__( self.groupId = groupId self.groupPolicy = groupPolicy self.cmab = cmab + self.type = type def get_audience_conditions_or_ids(self) -> Sequence[str | list[str]]: """ Returns audienceConditions if present, otherwise audienceIds. """ diff --git a/optimizely/helpers/types.py b/optimizely/helpers/types.py index d4177dc0..80b3db36 100644 --- a/optimizely/helpers/types.py +++ b/optimizely/helpers/types.py @@ -117,6 +117,8 @@ class CmabDict(BaseEntity): trafficAllocation: int +ExperimentType = Literal['a/b', 'mab', 'cmab', 'feature_rollout'] + HoldoutStatus = Literal['Draft', 'Running', 'Concluded', 'Archived'] diff --git a/optimizely/project_config.py b/optimizely/project_config.py index 74442d7a..8a9d6daf 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -232,6 +232,37 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any): self.experiment_feature_map[exp_id] = [feature.id] rules.append(self.experiment_id_map[exp_id]) + # Feature Rollout support: inject the "everyone else" variation + # into any experiment with type == "feature_rollout" + everyone_else_variation = self._get_everyone_else_variation(feature) + if everyone_else_variation is not None: + for experiment in rules: + if experiment.type == 'feature_rollout': + experiment.variations.append({ + 'id': everyone_else_variation.id, + 'key': everyone_else_variation.key, + 'featureEnabled': everyone_else_variation.featureEnabled, + 'variables': cast( + list[types.VariableDict], + everyone_else_variation.variables, + ), + }) + experiment.trafficAllocation.append({ + 'entityId': everyone_else_variation.id, + 'endOfRange': 10000, + }) + self.variation_key_map[experiment.key][everyone_else_variation.key] = everyone_else_variation + self.variation_id_map[experiment.key][everyone_else_variation.id] = everyone_else_variation + self.variation_id_map_by_experiment_id[experiment.id][everyone_else_variation.id] = ( + everyone_else_variation + ) + self.variation_key_map_by_experiment_id[experiment.id][everyone_else_variation.key] = ( + everyone_else_variation + ) + self.variation_variable_usage_map[everyone_else_variation.id] = self._generate_key_map( + everyone_else_variation.variables, 'id', entities.Variation.VariableUsage + ) + flag_id = feature.id applicable_holdouts: list[entities.Holdout] = [] @@ -667,6 +698,41 @@ def get_rollout_from_id(self, rollout_id: str) -> Optional[entities.Layer]: self.logger.error(f'Rollout with ID "{rollout_id}" is not in datafile.') return None + def _get_everyone_else_variation(self, flag: entities.FeatureFlag) -> Optional[entities.Variation]: + """ Get the "everyone else" variation for a feature flag. + + The "everyone else" rule is the last experiment in the flag's rollout, + and its first variation is the "everyone else" variation. + + Args: + flag: The feature flag to get the everyone else variation for. + + Returns: + The "everyone else" Variation entity, or None if not available. + """ + if not flag.rolloutId: + return None + + rollout = self.get_rollout_from_id(flag.rolloutId) + if not rollout or not rollout.experiments: + return None + + everyone_else_rule = rollout.experiments[-1] + variations = everyone_else_rule.get('variations', []) + if not variations: + return None + + variation_dict = variations[0] + return entities.Variation( + id=variation_dict['id'], + key=variation_dict['key'], + featureEnabled=bool(variation_dict.get('featureEnabled', False)), + variables=cast( + Optional[list[entities.Variable]], + variation_dict.get('variables'), + ), + ) + def get_variable_value_for_variation( self, variable: Optional[entities.Variable], variation: Optional[Union[entities.Variation, VariationDict]] ) -> Optional[str]: diff --git a/tests/test_config.py b/tests/test_config.py index 81228feb..5ba61d54 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1545,3 +1545,415 @@ 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) + + +class FeatureRolloutConfigTest(base.BaseTest): + """Tests for Feature Rollout support in ProjectConfig parsing.""" + + def _build_datafile(self, experiments=None, rollouts=None, feature_flags=None): + """Build a minimal valid datafile with the given components.""" + datafile = { + 'version': '4', + 'accountId': '12001', + 'projectId': '111001', + 'revision': '1', + 'experiments': experiments or [], + 'events': [], + 'attributes': [], + 'audiences': [], + 'groups': [], + 'rollouts': rollouts or [], + 'featureFlags': feature_flags or [], + } + return datafile + + def test_experiment_type_field_none_when_missing(self): + """Test that experiments without 'type' field have type=None.""" + datafile = self._build_datafile( + experiments=[ + { + 'id': 'exp_ab', + 'key': 'ab_test_exp', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'layer_1', + 'audienceIds': [], + 'trafficAllocation': [{'entityId': 'var_1', 'endOfRange': 5000}], + 'variations': [{'key': 'var_1', 'id': 'var_1', 'featureEnabled': True}], + }, + ], + feature_flags=[ + { + 'id': 'flag_1', + 'key': 'test_flag', + 'experimentIds': ['exp_ab'], + 'rolloutId': '', + 'variables': [], + }, + ], + ) + + opt = optimizely.Optimizely(json.dumps(datafile)) + config = opt.config_manager.get_config() + + experiment = config.experiment_id_map['exp_ab'] + self.assertIsNone(experiment.type) + + def test_feature_rollout_injects_everyone_else_variation(self): + """Test that feature_rollout experiments get the everyone else variation injected.""" + datafile = self._build_datafile( + experiments=[ + { + 'id': 'exp_fr', + 'key': 'feature_rollout_exp', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'layer_1', + 'audienceIds': [], + 'trafficAllocation': [{'entityId': 'rollout_var', 'endOfRange': 5000}], + 'variations': [ + {'key': 'rollout_var', 'id': 'rollout_var', 'featureEnabled': True} + ], + 'type': 'feature_rollout', + }, + ], + rollouts=[ + { + 'id': 'rollout_1', + 'experiments': [ + { + 'id': 'rollout_targeted_rule', + 'key': 'rollout_targeted_rule', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'rollout_1', + 'audienceIds': ['audience_1'], + 'trafficAllocation': [{'entityId': 'targeted_var', 'endOfRange': 10000}], + 'variations': [ + {'key': 'targeted_var', 'id': 'targeted_var', 'featureEnabled': True} + ], + }, + { + 'id': 'rollout_everyone_else', + 'key': 'rollout_everyone_else', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'rollout_1', + 'audienceIds': [], + 'trafficAllocation': [ + {'entityId': 'everyone_else_var', 'endOfRange': 10000} + ], + 'variations': [ + { + 'key': 'everyone_else_var', + 'id': 'everyone_else_var', + 'featureEnabled': False, + } + ], + }, + ], + } + ], + feature_flags=[ + { + 'id': 'flag_1', + 'key': 'test_flag', + 'experimentIds': ['exp_fr'], + 'rolloutId': 'rollout_1', + 'variables': [], + }, + ], + ) + + opt = optimizely.Optimizely(json.dumps(datafile)) + config = opt.config_manager.get_config() + + experiment = config.experiment_id_map['exp_fr'] + + # Should now have 2 variations: original + everyone else + self.assertEqual(len(experiment.variations), 2) + + # Verify the everyone else variation was appended + variation_ids = [v['id'] if isinstance(v, dict) else v.id for v in experiment.variations] + self.assertIn('everyone_else_var', variation_ids) + + # Verify traffic allocation was appended with endOfRange=10000 + self.assertEqual(len(experiment.trafficAllocation), 2) + last_allocation = experiment.trafficAllocation[-1] + self.assertEqual(last_allocation['entityId'], 'everyone_else_var') + self.assertEqual(last_allocation['endOfRange'], 10000) + + def test_feature_rollout_variation_maps_updated(self): + """Test that variation maps are properly updated after injection.""" + datafile = self._build_datafile( + experiments=[ + { + 'id': 'exp_fr', + 'key': 'feature_rollout_exp', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'layer_1', + 'audienceIds': [], + 'trafficAllocation': [{'entityId': 'rollout_var', 'endOfRange': 5000}], + 'variations': [ + {'key': 'rollout_var', 'id': 'rollout_var', 'featureEnabled': True} + ], + 'type': 'feature_rollout', + }, + ], + rollouts=[ + { + 'id': 'rollout_1', + 'experiments': [ + { + 'id': 'rollout_everyone_else', + 'key': 'rollout_everyone_else', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'rollout_1', + 'audienceIds': [], + 'trafficAllocation': [ + {'entityId': 'everyone_else_var', 'endOfRange': 10000} + ], + 'variations': [ + { + 'key': 'everyone_else_var', + 'id': 'everyone_else_var', + 'featureEnabled': False, + } + ], + }, + ], + } + ], + feature_flags=[ + { + 'id': 'flag_1', + 'key': 'test_flag', + 'experimentIds': ['exp_fr'], + 'rolloutId': 'rollout_1', + 'variables': [], + }, + ], + ) + + opt = optimizely.Optimizely(json.dumps(datafile)) + config = opt.config_manager.get_config() + + # Check variation_key_map is updated + self.assertIn('everyone_else_var', config.variation_key_map['feature_rollout_exp']) + + # Check variation_id_map is updated + self.assertIn('everyone_else_var', config.variation_id_map['feature_rollout_exp']) + + # Check variation_id_map_by_experiment_id is updated + self.assertIn('everyone_else_var', config.variation_id_map_by_experiment_id['exp_fr']) + + # Check variation_key_map_by_experiment_id is updated + self.assertIn('everyone_else_var', config.variation_key_map_by_experiment_id['exp_fr']) + + def test_non_feature_rollout_experiments_unchanged(self): + """Test that experiments without type=feature_rollout are not modified.""" + datafile = self._build_datafile( + experiments=[ + { + 'id': 'exp_ab', + 'key': 'ab_test_exp', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'layer_1', + 'audienceIds': [], + 'trafficAllocation': [{'entityId': 'var_1', 'endOfRange': 5000}], + 'variations': [ + {'key': 'var_1', 'id': 'var_1', 'featureEnabled': True} + ], + 'type': 'a/b', + }, + ], + rollouts=[ + { + 'id': 'rollout_1', + 'experiments': [ + { + 'id': 'rollout_everyone_else', + 'key': 'rollout_everyone_else', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'rollout_1', + 'audienceIds': [], + 'trafficAllocation': [ + {'entityId': 'everyone_else_var', 'endOfRange': 10000} + ], + 'variations': [ + { + 'key': 'everyone_else_var', + 'id': 'everyone_else_var', + 'featureEnabled': False, + } + ], + }, + ], + } + ], + feature_flags=[ + { + 'id': 'flag_1', + 'key': 'test_flag', + 'experimentIds': ['exp_ab'], + 'rolloutId': 'rollout_1', + 'variables': [], + }, + ], + ) + + opt = optimizely.Optimizely(json.dumps(datafile)) + config = opt.config_manager.get_config() + + experiment = config.experiment_id_map['exp_ab'] + + # Should still have only 1 variation + self.assertEqual(len(experiment.variations), 1) + # Should still have only 1 traffic allocation + self.assertEqual(len(experiment.trafficAllocation), 1) + + def test_feature_rollout_with_no_rollout(self): + """Test feature_rollout experiment with empty rolloutId is not modified.""" + datafile = self._build_datafile( + experiments=[ + { + 'id': 'exp_fr', + 'key': 'feature_rollout_exp', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'layer_1', + 'audienceIds': [], + 'trafficAllocation': [{'entityId': 'var_1', 'endOfRange': 5000}], + 'variations': [ + {'key': 'var_1', 'id': 'var_1', 'featureEnabled': True} + ], + 'type': 'feature_rollout', + }, + ], + feature_flags=[ + { + 'id': 'flag_1', + 'key': 'test_flag', + 'experimentIds': ['exp_fr'], + 'rolloutId': '', + 'variables': [], + }, + ], + ) + + opt = optimizely.Optimizely(json.dumps(datafile)) + config = opt.config_manager.get_config() + + experiment = config.experiment_id_map['exp_fr'] + + # Without a rollout, no injection should occur + self.assertEqual(len(experiment.variations), 1) + self.assertEqual(len(experiment.trafficAllocation), 1) + + def test_feature_rollout_everyone_else_is_last_rollout_rule(self): + """Test that the everyone else variation comes from the LAST rollout rule.""" + datafile = self._build_datafile( + experiments=[ + { + 'id': 'exp_fr', + 'key': 'feature_rollout_exp', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'layer_1', + 'audienceIds': [], + 'trafficAllocation': [{'entityId': 'fr_var', 'endOfRange': 5000}], + 'variations': [ + {'key': 'fr_var', 'id': 'fr_var', 'featureEnabled': True} + ], + 'type': 'feature_rollout', + }, + ], + rollouts=[ + { + 'id': 'rollout_1', + 'experiments': [ + { + 'id': 'targeted_rule_1', + 'key': 'targeted_rule_1', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'rollout_1', + 'audienceIds': ['aud_1'], + 'trafficAllocation': [ + {'entityId': 'targeted_var_1', 'endOfRange': 10000} + ], + 'variations': [ + { + 'key': 'targeted_var_1', + 'id': 'targeted_var_1', + 'featureEnabled': True, + } + ], + }, + { + 'id': 'targeted_rule_2', + 'key': 'targeted_rule_2', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'rollout_1', + 'audienceIds': ['aud_2'], + 'trafficAllocation': [ + {'entityId': 'targeted_var_2', 'endOfRange': 10000} + ], + 'variations': [ + { + 'key': 'targeted_var_2', + 'id': 'targeted_var_2', + 'featureEnabled': True, + } + ], + }, + { + 'id': 'everyone_else_rule', + 'key': 'everyone_else_rule', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'rollout_1', + 'audienceIds': [], + 'trafficAllocation': [ + {'entityId': 'correct_everyone_var', 'endOfRange': 10000} + ], + 'variations': [ + { + 'key': 'correct_everyone_var', + 'id': 'correct_everyone_var', + 'featureEnabled': False, + } + ], + }, + ], + } + ], + feature_flags=[ + { + 'id': 'flag_1', + 'key': 'test_flag', + 'experimentIds': ['exp_fr'], + 'rolloutId': 'rollout_1', + 'variables': [], + }, + ], + ) + + opt = optimizely.Optimizely(json.dumps(datafile)) + config = opt.config_manager.get_config() + + experiment = config.experiment_id_map['exp_fr'] + + # Should have injected the correct (last) everyone else variation + variation_ids = [v['id'] if isinstance(v, dict) else v.id for v in experiment.variations] + self.assertIn('correct_everyone_var', variation_ids) + # Should NOT have injected targeted rule variations + self.assertNotIn('targeted_var_1', variation_ids) + self.assertNotIn('targeted_var_2', variation_ids) + +