From 159ee1b2a3c4a811a504289f56ec4a4852b351fe Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 4 Mar 2026 01:46:13 +0600 Subject: [PATCH 1/9] [AI-FSSDK] [FSSDK-12337] Add Feature Rollout support to project config parsing --- optimizely/entities.py | 2 + optimizely/project_config.py | 54 ++ tests/test_feature_rollout.py | 906 ++++++++++++++++++++++++++++++++++ 3 files changed, 962 insertions(+) create mode 100644 tests/test_feature_rollout.py diff --git a/optimizely/entities.py b/optimizely/entities.py index 12f4f849..589ca984 100644 --- a/optimizely/entities.py +++ b/optimizely/entities.py @@ -87,6 +87,7 @@ def __init__( groupId: Optional[str] = None, groupPolicy: Optional[str] = None, cmab: Optional[CmabDict] = None, + type: Optional[str] = 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/project_config.py b/optimizely/project_config.py index 74442d7a..91c84952 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -232,6 +232,34 @@ 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" + rollout_for_flag = ( + None if len(feature.rolloutId) == 0 + else self.rollout_id_map.get(feature.rolloutId) + ) + if rollout_for_flag: + everyone_else_variation = self._get_everyone_else_variation(rollout_for_flag) + if everyone_else_variation is not None: + for experiment in rules: + if getattr(experiment, 'type', None) == 'feature_rollout': + # Append the everyone else variation to the experiment + experiment.variations.append(everyone_else_variation) + # Add traffic allocation entry with endOfRange=10000 + experiment.trafficAllocation.append({ + 'entityId': everyone_else_variation['id'], + 'endOfRange': 10000, + }) + # Update variation maps for this experiment + var_entity = entities.Variation(**everyone_else_variation) + self.variation_key_map[experiment.key][var_entity.key] = var_entity + self.variation_id_map[experiment.key][var_entity.id] = var_entity + self.variation_id_map_by_experiment_id[experiment.id][var_entity.id] = var_entity + self.variation_key_map_by_experiment_id[experiment.id][var_entity.key] = var_entity + self.variation_variable_usage_map[var_entity.id] = self._generate_key_map( + var_entity.variables, 'id', entities.Variation.VariableUsage + ) + flag_id = feature.id applicable_holdouts: list[entities.Holdout] = [] @@ -304,6 +332,32 @@ def _generate_key_map( return key_map + @staticmethod + def _get_everyone_else_variation(rollout: entities.Layer) -> Optional[types.VariationDict]: + """ Get the "everyone else" variation from a rollout. + + The "everyone else" rule is the last experiment in the rollout, + and its first variation is the "everyone else" variation. + + Args: + rollout: The rollout (Layer) entity to get the variation from. + + Returns: + The "everyone else" variation dict, or None if not available. + """ + if not rollout.experiments: + return None + + everyone_else_rule = rollout.experiments[-1] + variations = everyone_else_rule.get('variations', []) if isinstance( + everyone_else_rule, dict + ) else getattr(everyone_else_rule, 'variations', []) + + if not variations: + return None + + return variations[0] + @staticmethod def _deserialize_audience(audience_map: dict[str, entities.Audience]) -> dict[str, entities.Audience]: """ Helper method to de-serialize and populate audience map with the condition list and structure. diff --git a/tests/test_feature_rollout.py b/tests/test_feature_rollout.py new file mode 100644 index 00000000..cdf7fe92 --- /dev/null +++ b/tests/test_feature_rollout.py @@ -0,0 +1,906 @@ +# Copyright 2025, Optimizely +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import copy +import unittest + +from optimizely import entities +from optimizely import optimizely +from optimizely.project_config import ProjectConfig + + +class FeatureRolloutConfigTest(unittest.TestCase): + """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_parsed(self): + """Test that the optional 'type' field is parsed on Experiment entities.""" + datafile = self._build_datafile( + experiments=[ + { + 'id': 'exp_1', + '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', + }, + ], + rollouts=[ + { + 'id': 'rollout_1', + 'experiments': [ + { + 'id': 'rollout_rule_1', + 'key': 'rollout_rule_1', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'rollout_1', + 'audienceIds': [], + 'trafficAllocation': [{'entityId': 'everyone_var', 'endOfRange': 10000}], + 'variations': [ + {'key': 'everyone_var', 'id': 'everyone_var', 'featureEnabled': False} + ], + } + ], + } + ], + feature_flags=[ + { + 'id': 'flag_1', + 'key': 'test_flag', + 'experimentIds': ['exp_1'], + 'rolloutId': 'rollout_1', + 'variables': [], + }, + ], + ) + + opt = optimizely.Optimizely(json.dumps(datafile)) + config = opt.config_manager.get_config() + + experiment = config.experiment_id_map['exp_1'] + self.assertEqual(experiment.type, 'feature_rollout') + + 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_with_empty_rollout_experiments(self): + """Test feature_rollout with a rollout that has no experiments.""" + 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', + }, + ], + rollouts=[ + { + 'id': 'rollout_empty', + 'experiments': [], + } + ], + feature_flags=[ + { + 'id': 'flag_1', + 'key': 'test_flag', + 'experimentIds': ['exp_fr'], + 'rolloutId': 'rollout_empty', + 'variables': [], + }, + ], + ) + + opt = optimizely.Optimizely(json.dumps(datafile)) + config = opt.config_manager.get_config() + + experiment = config.experiment_id_map['exp_fr'] + + # With empty rollout experiments, no injection should occur + self.assertEqual(len(experiment.variations), 1) + self.assertEqual(len(experiment.trafficAllocation), 1) + + def test_feature_rollout_multiple_experiments_mixed_types(self): + """Test a flag with both feature_rollout and regular experiments.""" + datafile = self._build_datafile( + experiments=[ + { + 'id': 'exp_ab', + 'key': 'ab_test', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'layer_1', + 'audienceIds': [], + 'trafficAllocation': [{'entityId': 'ab_var', 'endOfRange': 5000}], + 'variations': [ + {'key': 'ab_var', 'id': 'ab_var', 'featureEnabled': True} + ], + 'type': 'a/b', + }, + { + 'id': 'exp_fr', + 'key': 'feature_rollout_exp', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'layer_2', + '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': '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', 'exp_fr'], + 'rolloutId': 'rollout_1', + 'variables': [], + }, + ], + ) + + opt = optimizely.Optimizely(json.dumps(datafile)) + config = opt.config_manager.get_config() + + # A/B test should not be modified + ab_experiment = config.experiment_id_map['exp_ab'] + self.assertEqual(len(ab_experiment.variations), 1) + self.assertEqual(len(ab_experiment.trafficAllocation), 1) + + # Feature rollout should have the everyone else variation injected + fr_experiment = config.experiment_id_map['exp_fr'] + self.assertEqual(len(fr_experiment.variations), 2) + self.assertEqual(len(fr_experiment.trafficAllocation), 2) + + variation_ids = [v['id'] if isinstance(v, dict) else v.id for v in fr_experiment.variations] + self.assertIn('everyone_else_var', variation_ids) + + 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) + + def test_feature_rollout_flag_variations_map_includes_injected(self): + """Test that flag_variations_map includes the injected everyone else variation.""" + 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': '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() + + flag_variations = config.flag_variations_map.get('test_flag', []) + flag_variation_ids = [v.id for v in flag_variations] + + # The injected variation should be available in flag_variations_map + self.assertIn('everyone_else_var', flag_variation_ids) + self.assertIn('fr_var', flag_variation_ids) + + def test_experiment_type_ab(self): + """Test that experiment with type='a/b' is parsed correctly.""" + datafile = self._build_datafile( + experiments=[ + { + 'id': 'exp_ab', + 'key': 'ab_test', + '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', + }, + ], + 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.assertEqual(experiment.type, 'a/b') + + def test_feature_rollout_with_variables_on_everyone_else(self): + """Test that everyone else variation with variable usages gets properly mapped.""" + 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, + 'variables': [{'id': 'var_100', 'value': 'on'}], + } + ], + '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': 'ee_var', 'endOfRange': 10000} + ], + 'variations': [ + { + 'key': 'ee_var', + 'id': 'ee_var', + 'featureEnabled': False, + 'variables': [{'id': 'var_100', 'value': 'off'}], + } + ], + }, + ], + } + ], + feature_flags=[ + { + 'id': 'flag_1', + 'key': 'test_flag', + 'experimentIds': ['exp_fr'], + 'rolloutId': 'rollout_1', + 'variables': [ + {'id': 'var_100', 'key': 'toggle', 'defaultValue': 'default', 'type': 'string'}, + ], + }, + ], + ) + + opt = optimizely.Optimizely(json.dumps(datafile)) + config = opt.config_manager.get_config() + + # Verify the variation variable usage map is populated for the injected variation + self.assertIn('ee_var', config.variation_variable_usage_map) + variable_usage = config.variation_variable_usage_map['ee_var'] + self.assertIn('var_100', variable_usage) + self.assertEqual(variable_usage['var_100'].value, 'off') + + def test_existing_datafile_not_broken(self): + """Test that existing datafiles without feature_rollout type still work correctly.""" + datafile = self._build_datafile( + experiments=[ + { + 'id': 'exp_1', + 'key': 'regular_exp', + 'status': 'Running', + 'forcedVariations': {'user_1': 'control'}, + 'layerId': 'layer_1', + 'audienceIds': [], + 'trafficAllocation': [ + {'entityId': 'control', 'endOfRange': 5000}, + {'entityId': 'variation', 'endOfRange': 10000}, + ], + 'variations': [ + {'key': 'control', 'id': 'control', 'featureEnabled': False}, + {'key': 'variation', 'id': 'variation', 'featureEnabled': True}, + ], + }, + ], + rollouts=[ + { + 'id': 'rollout_1', + 'experiments': [ + { + 'id': 'rollout_rule', + 'key': 'rollout_rule', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'rollout_1', + 'audienceIds': [], + 'trafficAllocation': [{'entityId': 'rollout_var', 'endOfRange': 10000}], + 'variations': [ + {'key': 'rollout_var', 'id': 'rollout_var', 'featureEnabled': True} + ], + }, + ], + } + ], + feature_flags=[ + { + 'id': 'flag_1', + 'key': 'test_flag', + 'experimentIds': ['exp_1'], + 'rolloutId': 'rollout_1', + 'variables': [], + }, + ], + ) + + opt = optimizely.Optimizely(json.dumps(datafile)) + config = opt.config_manager.get_config() + + # Regular experiment should be unchanged + experiment = config.experiment_id_map['exp_1'] + self.assertEqual(len(experiment.variations), 2) + self.assertEqual(len(experiment.trafficAllocation), 2) + self.assertIsNone(experiment.type) + + def test_get_everyone_else_variation_helper(self): + """Test the _get_everyone_else_variation static method directly.""" + # Create a Layer with multiple experiment dicts + layer = entities.Layer( + id='rollout_1', + experiments=[ + { + 'id': 'rule_1', + 'key': 'rule_1', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'rollout_1', + 'audienceIds': [], + 'trafficAllocation': [], + 'variations': [ + {'key': 'var_1', 'id': 'var_1', 'featureEnabled': True} + ], + }, + { + 'id': 'everyone_else', + 'key': 'everyone_else', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'rollout_1', + 'audienceIds': [], + 'trafficAllocation': [], + 'variations': [ + {'key': 'ee_var', 'id': 'ee_var', 'featureEnabled': False} + ], + }, + ], + ) + + result = ProjectConfig._get_everyone_else_variation(layer) + self.assertIsNotNone(result) + self.assertEqual(result['id'], 'ee_var') + self.assertEqual(result['key'], 'ee_var') + + def test_get_everyone_else_variation_empty_rollout(self): + """Test _get_everyone_else_variation returns None for empty rollout.""" + layer = entities.Layer(id='empty_rollout', experiments=[]) + result = ProjectConfig._get_everyone_else_variation(layer) + self.assertIsNone(result) + + def test_get_everyone_else_variation_no_variations(self): + """Test _get_everyone_else_variation returns None when last rule has no variations.""" + layer = entities.Layer( + id='rollout_1', + experiments=[ + { + 'id': 'rule_1', + 'key': 'rule_1', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'rollout_1', + 'audienceIds': [], + 'trafficAllocation': [], + 'variations': [], + }, + ], + ) + + result = ProjectConfig._get_everyone_else_variation(layer) + self.assertIsNone(result) + + +if __name__ == '__main__': + unittest.main() From 2fba50e0378ba7a246d743549d4b68d4dce7d59b Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 4 Mar 2026 02:07:31 +0600 Subject: [PATCH 2/9] [AI-FSSDK] [FSSDK-12337] Fix test structure, mypy and ruff compliance - Move feature rollout tests from standalone test_feature_rollout.py into test_config.py following module-level testing convention - Use base.BaseTest instead of unittest.TestCase for consistency - Fix mypy strict type errors in Variation construction using cast - All checks pass: ruff, mypy --strict, pytest (941/941) Co-Authored-By: Claude Opus 4.6 (1M context) --- optimizely/project_config.py | 10 +- tests/test_config.py | 881 +++++++++++++++++++++++++++++++++ tests/test_feature_rollout.py | 906 ---------------------------------- 3 files changed, 890 insertions(+), 907 deletions(-) delete mode 100644 tests/test_feature_rollout.py diff --git a/optimizely/project_config.py b/optimizely/project_config.py index 91c84952..5cee3494 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -251,7 +251,15 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any): 'endOfRange': 10000, }) # Update variation maps for this experiment - var_entity = entities.Variation(**everyone_else_variation) + var_entity = entities.Variation( + id=everyone_else_variation['id'], + key=everyone_else_variation['key'], + featureEnabled=bool(everyone_else_variation.get('featureEnabled', False)), + variables=cast( + Optional[list[entities.Variable]], + everyone_else_variation.get('variables'), + ), + ) self.variation_key_map[experiment.key][var_entity.key] = var_entity self.variation_id_map[experiment.key][var_entity.id] = var_entity self.variation_id_map_by_experiment_id[experiment.id][var_entity.id] = var_entity diff --git a/tests/test_config.py b/tests/test_config.py index 81228feb..07e2aca3 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1545,3 +1545,884 @@ 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_parsed(self): + """Test that the optional 'type' field is parsed on Experiment entities.""" + datafile = self._build_datafile( + experiments=[ + { + 'id': 'exp_1', + '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', + }, + ], + rollouts=[ + { + 'id': 'rollout_1', + 'experiments': [ + { + 'id': 'rollout_rule_1', + 'key': 'rollout_rule_1', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'rollout_1', + 'audienceIds': [], + 'trafficAllocation': [{'entityId': 'everyone_var', 'endOfRange': 10000}], + 'variations': [ + {'key': 'everyone_var', 'id': 'everyone_var', 'featureEnabled': False} + ], + } + ], + } + ], + feature_flags=[ + { + 'id': 'flag_1', + 'key': 'test_flag', + 'experimentIds': ['exp_1'], + 'rolloutId': 'rollout_1', + 'variables': [], + }, + ], + ) + + opt = optimizely.Optimizely(json.dumps(datafile)) + config = opt.config_manager.get_config() + + experiment = config.experiment_id_map['exp_1'] + self.assertEqual(experiment.type, 'feature_rollout') + + 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_with_empty_rollout_experiments(self): + """Test feature_rollout with a rollout that has no experiments.""" + 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', + }, + ], + rollouts=[ + { + 'id': 'rollout_empty', + 'experiments': [], + } + ], + feature_flags=[ + { + 'id': 'flag_1', + 'key': 'test_flag', + 'experimentIds': ['exp_fr'], + 'rolloutId': 'rollout_empty', + 'variables': [], + }, + ], + ) + + opt = optimizely.Optimizely(json.dumps(datafile)) + config = opt.config_manager.get_config() + + experiment = config.experiment_id_map['exp_fr'] + + # With empty rollout experiments, no injection should occur + self.assertEqual(len(experiment.variations), 1) + self.assertEqual(len(experiment.trafficAllocation), 1) + + def test_feature_rollout_multiple_experiments_mixed_types(self): + """Test a flag with both feature_rollout and regular experiments.""" + datafile = self._build_datafile( + experiments=[ + { + 'id': 'exp_ab', + 'key': 'ab_test', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'layer_1', + 'audienceIds': [], + 'trafficAllocation': [{'entityId': 'ab_var', 'endOfRange': 5000}], + 'variations': [ + {'key': 'ab_var', 'id': 'ab_var', 'featureEnabled': True} + ], + 'type': 'a/b', + }, + { + 'id': 'exp_fr', + 'key': 'feature_rollout_exp', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'layer_2', + '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': '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', 'exp_fr'], + 'rolloutId': 'rollout_1', + 'variables': [], + }, + ], + ) + + opt = optimizely.Optimizely(json.dumps(datafile)) + config = opt.config_manager.get_config() + + # A/B test should not be modified + ab_experiment = config.experiment_id_map['exp_ab'] + self.assertEqual(len(ab_experiment.variations), 1) + self.assertEqual(len(ab_experiment.trafficAllocation), 1) + + # Feature rollout should have the everyone else variation injected + fr_experiment = config.experiment_id_map['exp_fr'] + self.assertEqual(len(fr_experiment.variations), 2) + self.assertEqual(len(fr_experiment.trafficAllocation), 2) + + variation_ids = [v['id'] if isinstance(v, dict) else v.id for v in fr_experiment.variations] + self.assertIn('everyone_else_var', variation_ids) + + 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) + + def test_feature_rollout_flag_variations_map_includes_injected(self): + """Test that flag_variations_map includes the injected everyone else variation.""" + 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': '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() + + flag_variations = config.flag_variations_map.get('test_flag', []) + flag_variation_ids = [v.id for v in flag_variations] + + # The injected variation should be available in flag_variations_map + self.assertIn('everyone_else_var', flag_variation_ids) + self.assertIn('fr_var', flag_variation_ids) + + def test_experiment_type_ab(self): + """Test that experiment with type='a/b' is parsed correctly.""" + datafile = self._build_datafile( + experiments=[ + { + 'id': 'exp_ab', + 'key': 'ab_test', + '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', + }, + ], + 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.assertEqual(experiment.type, 'a/b') + + def test_feature_rollout_with_variables_on_everyone_else(self): + """Test that everyone else variation with variable usages gets properly mapped.""" + 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, + 'variables': [{'id': 'var_100', 'value': 'on'}], + } + ], + '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': 'ee_var', 'endOfRange': 10000} + ], + 'variations': [ + { + 'key': 'ee_var', + 'id': 'ee_var', + 'featureEnabled': False, + 'variables': [{'id': 'var_100', 'value': 'off'}], + } + ], + }, + ], + } + ], + feature_flags=[ + { + 'id': 'flag_1', + 'key': 'test_flag', + 'experimentIds': ['exp_fr'], + 'rolloutId': 'rollout_1', + 'variables': [ + {'id': 'var_100', 'key': 'toggle', 'defaultValue': 'default', 'type': 'string'}, + ], + }, + ], + ) + + opt = optimizely.Optimizely(json.dumps(datafile)) + config = opt.config_manager.get_config() + + # Verify the variation variable usage map is populated for the injected variation + self.assertIn('ee_var', config.variation_variable_usage_map) + variable_usage = config.variation_variable_usage_map['ee_var'] + self.assertIn('var_100', variable_usage) + self.assertEqual(variable_usage['var_100'].value, 'off') + + def test_existing_datafile_not_broken(self): + """Test that existing datafiles without feature_rollout type still work correctly.""" + datafile = self._build_datafile( + experiments=[ + { + 'id': 'exp_1', + 'key': 'regular_exp', + 'status': 'Running', + 'forcedVariations': {'user_1': 'control'}, + 'layerId': 'layer_1', + 'audienceIds': [], + 'trafficAllocation': [ + {'entityId': 'control', 'endOfRange': 5000}, + {'entityId': 'variation', 'endOfRange': 10000}, + ], + 'variations': [ + {'key': 'control', 'id': 'control', 'featureEnabled': False}, + {'key': 'variation', 'id': 'variation', 'featureEnabled': True}, + ], + }, + ], + rollouts=[ + { + 'id': 'rollout_1', + 'experiments': [ + { + 'id': 'rollout_rule', + 'key': 'rollout_rule', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'rollout_1', + 'audienceIds': [], + 'trafficAllocation': [{'entityId': 'rollout_var', 'endOfRange': 10000}], + 'variations': [ + {'key': 'rollout_var', 'id': 'rollout_var', 'featureEnabled': True} + ], + }, + ], + } + ], + feature_flags=[ + { + 'id': 'flag_1', + 'key': 'test_flag', + 'experimentIds': ['exp_1'], + 'rolloutId': 'rollout_1', + 'variables': [], + }, + ], + ) + + opt = optimizely.Optimizely(json.dumps(datafile)) + config = opt.config_manager.get_config() + + # Regular experiment should be unchanged + experiment = config.experiment_id_map['exp_1'] + self.assertEqual(len(experiment.variations), 2) + self.assertEqual(len(experiment.trafficAllocation), 2) + self.assertIsNone(experiment.type) + + def test_get_everyone_else_variation_helper(self): + """Test the _get_everyone_else_variation static method directly.""" + layer = entities.Layer( + id='rollout_1', + experiments=[ + { + 'id': 'rule_1', + 'key': 'rule_1', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'rollout_1', + 'audienceIds': [], + 'trafficAllocation': [], + 'variations': [ + {'key': 'var_1', 'id': 'var_1', 'featureEnabled': True} + ], + }, + { + 'id': 'everyone_else', + 'key': 'everyone_else', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'rollout_1', + 'audienceIds': [], + 'trafficAllocation': [], + 'variations': [ + {'key': 'ee_var', 'id': 'ee_var', 'featureEnabled': False} + ], + }, + ], + ) + + result = ProjectConfig._get_everyone_else_variation(layer) + self.assertIsNotNone(result) + self.assertEqual(result['id'], 'ee_var') + self.assertEqual(result['key'], 'ee_var') + + def test_get_everyone_else_variation_empty_rollout(self): + """Test _get_everyone_else_variation returns None for empty rollout.""" + layer = entities.Layer(id='empty_rollout', experiments=[]) + result = ProjectConfig._get_everyone_else_variation(layer) + self.assertIsNone(result) + + def test_get_everyone_else_variation_no_variations(self): + """Test _get_everyone_else_variation returns None when last rule has no variations.""" + layer = entities.Layer( + id='rollout_1', + experiments=[ + { + 'id': 'rule_1', + 'key': 'rule_1', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': 'rollout_1', + 'audienceIds': [], + 'trafficAllocation': [], + 'variations': [], + }, + ], + ) + + result = ProjectConfig._get_everyone_else_variation(layer) + self.assertIsNone(result) diff --git a/tests/test_feature_rollout.py b/tests/test_feature_rollout.py deleted file mode 100644 index cdf7fe92..00000000 --- a/tests/test_feature_rollout.py +++ /dev/null @@ -1,906 +0,0 @@ -# Copyright 2025, Optimizely -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json -import copy -import unittest - -from optimizely import entities -from optimizely import optimizely -from optimizely.project_config import ProjectConfig - - -class FeatureRolloutConfigTest(unittest.TestCase): - """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_parsed(self): - """Test that the optional 'type' field is parsed on Experiment entities.""" - datafile = self._build_datafile( - experiments=[ - { - 'id': 'exp_1', - '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', - }, - ], - rollouts=[ - { - 'id': 'rollout_1', - 'experiments': [ - { - 'id': 'rollout_rule_1', - 'key': 'rollout_rule_1', - 'status': 'Running', - 'forcedVariations': {}, - 'layerId': 'rollout_1', - 'audienceIds': [], - 'trafficAllocation': [{'entityId': 'everyone_var', 'endOfRange': 10000}], - 'variations': [ - {'key': 'everyone_var', 'id': 'everyone_var', 'featureEnabled': False} - ], - } - ], - } - ], - feature_flags=[ - { - 'id': 'flag_1', - 'key': 'test_flag', - 'experimentIds': ['exp_1'], - 'rolloutId': 'rollout_1', - 'variables': [], - }, - ], - ) - - opt = optimizely.Optimizely(json.dumps(datafile)) - config = opt.config_manager.get_config() - - experiment = config.experiment_id_map['exp_1'] - self.assertEqual(experiment.type, 'feature_rollout') - - 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_with_empty_rollout_experiments(self): - """Test feature_rollout with a rollout that has no experiments.""" - 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', - }, - ], - rollouts=[ - { - 'id': 'rollout_empty', - 'experiments': [], - } - ], - feature_flags=[ - { - 'id': 'flag_1', - 'key': 'test_flag', - 'experimentIds': ['exp_fr'], - 'rolloutId': 'rollout_empty', - 'variables': [], - }, - ], - ) - - opt = optimizely.Optimizely(json.dumps(datafile)) - config = opt.config_manager.get_config() - - experiment = config.experiment_id_map['exp_fr'] - - # With empty rollout experiments, no injection should occur - self.assertEqual(len(experiment.variations), 1) - self.assertEqual(len(experiment.trafficAllocation), 1) - - def test_feature_rollout_multiple_experiments_mixed_types(self): - """Test a flag with both feature_rollout and regular experiments.""" - datafile = self._build_datafile( - experiments=[ - { - 'id': 'exp_ab', - 'key': 'ab_test', - 'status': 'Running', - 'forcedVariations': {}, - 'layerId': 'layer_1', - 'audienceIds': [], - 'trafficAllocation': [{'entityId': 'ab_var', 'endOfRange': 5000}], - 'variations': [ - {'key': 'ab_var', 'id': 'ab_var', 'featureEnabled': True} - ], - 'type': 'a/b', - }, - { - 'id': 'exp_fr', - 'key': 'feature_rollout_exp', - 'status': 'Running', - 'forcedVariations': {}, - 'layerId': 'layer_2', - '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': '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', 'exp_fr'], - 'rolloutId': 'rollout_1', - 'variables': [], - }, - ], - ) - - opt = optimizely.Optimizely(json.dumps(datafile)) - config = opt.config_manager.get_config() - - # A/B test should not be modified - ab_experiment = config.experiment_id_map['exp_ab'] - self.assertEqual(len(ab_experiment.variations), 1) - self.assertEqual(len(ab_experiment.trafficAllocation), 1) - - # Feature rollout should have the everyone else variation injected - fr_experiment = config.experiment_id_map['exp_fr'] - self.assertEqual(len(fr_experiment.variations), 2) - self.assertEqual(len(fr_experiment.trafficAllocation), 2) - - variation_ids = [v['id'] if isinstance(v, dict) else v.id for v in fr_experiment.variations] - self.assertIn('everyone_else_var', variation_ids) - - 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) - - def test_feature_rollout_flag_variations_map_includes_injected(self): - """Test that flag_variations_map includes the injected everyone else variation.""" - 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': '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() - - flag_variations = config.flag_variations_map.get('test_flag', []) - flag_variation_ids = [v.id for v in flag_variations] - - # The injected variation should be available in flag_variations_map - self.assertIn('everyone_else_var', flag_variation_ids) - self.assertIn('fr_var', flag_variation_ids) - - def test_experiment_type_ab(self): - """Test that experiment with type='a/b' is parsed correctly.""" - datafile = self._build_datafile( - experiments=[ - { - 'id': 'exp_ab', - 'key': 'ab_test', - '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', - }, - ], - 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.assertEqual(experiment.type, 'a/b') - - def test_feature_rollout_with_variables_on_everyone_else(self): - """Test that everyone else variation with variable usages gets properly mapped.""" - 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, - 'variables': [{'id': 'var_100', 'value': 'on'}], - } - ], - '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': 'ee_var', 'endOfRange': 10000} - ], - 'variations': [ - { - 'key': 'ee_var', - 'id': 'ee_var', - 'featureEnabled': False, - 'variables': [{'id': 'var_100', 'value': 'off'}], - } - ], - }, - ], - } - ], - feature_flags=[ - { - 'id': 'flag_1', - 'key': 'test_flag', - 'experimentIds': ['exp_fr'], - 'rolloutId': 'rollout_1', - 'variables': [ - {'id': 'var_100', 'key': 'toggle', 'defaultValue': 'default', 'type': 'string'}, - ], - }, - ], - ) - - opt = optimizely.Optimizely(json.dumps(datafile)) - config = opt.config_manager.get_config() - - # Verify the variation variable usage map is populated for the injected variation - self.assertIn('ee_var', config.variation_variable_usage_map) - variable_usage = config.variation_variable_usage_map['ee_var'] - self.assertIn('var_100', variable_usage) - self.assertEqual(variable_usage['var_100'].value, 'off') - - def test_existing_datafile_not_broken(self): - """Test that existing datafiles without feature_rollout type still work correctly.""" - datafile = self._build_datafile( - experiments=[ - { - 'id': 'exp_1', - 'key': 'regular_exp', - 'status': 'Running', - 'forcedVariations': {'user_1': 'control'}, - 'layerId': 'layer_1', - 'audienceIds': [], - 'trafficAllocation': [ - {'entityId': 'control', 'endOfRange': 5000}, - {'entityId': 'variation', 'endOfRange': 10000}, - ], - 'variations': [ - {'key': 'control', 'id': 'control', 'featureEnabled': False}, - {'key': 'variation', 'id': 'variation', 'featureEnabled': True}, - ], - }, - ], - rollouts=[ - { - 'id': 'rollout_1', - 'experiments': [ - { - 'id': 'rollout_rule', - 'key': 'rollout_rule', - 'status': 'Running', - 'forcedVariations': {}, - 'layerId': 'rollout_1', - 'audienceIds': [], - 'trafficAllocation': [{'entityId': 'rollout_var', 'endOfRange': 10000}], - 'variations': [ - {'key': 'rollout_var', 'id': 'rollout_var', 'featureEnabled': True} - ], - }, - ], - } - ], - feature_flags=[ - { - 'id': 'flag_1', - 'key': 'test_flag', - 'experimentIds': ['exp_1'], - 'rolloutId': 'rollout_1', - 'variables': [], - }, - ], - ) - - opt = optimizely.Optimizely(json.dumps(datafile)) - config = opt.config_manager.get_config() - - # Regular experiment should be unchanged - experiment = config.experiment_id_map['exp_1'] - self.assertEqual(len(experiment.variations), 2) - self.assertEqual(len(experiment.trafficAllocation), 2) - self.assertIsNone(experiment.type) - - def test_get_everyone_else_variation_helper(self): - """Test the _get_everyone_else_variation static method directly.""" - # Create a Layer with multiple experiment dicts - layer = entities.Layer( - id='rollout_1', - experiments=[ - { - 'id': 'rule_1', - 'key': 'rule_1', - 'status': 'Running', - 'forcedVariations': {}, - 'layerId': 'rollout_1', - 'audienceIds': [], - 'trafficAllocation': [], - 'variations': [ - {'key': 'var_1', 'id': 'var_1', 'featureEnabled': True} - ], - }, - { - 'id': 'everyone_else', - 'key': 'everyone_else', - 'status': 'Running', - 'forcedVariations': {}, - 'layerId': 'rollout_1', - 'audienceIds': [], - 'trafficAllocation': [], - 'variations': [ - {'key': 'ee_var', 'id': 'ee_var', 'featureEnabled': False} - ], - }, - ], - ) - - result = ProjectConfig._get_everyone_else_variation(layer) - self.assertIsNotNone(result) - self.assertEqual(result['id'], 'ee_var') - self.assertEqual(result['key'], 'ee_var') - - def test_get_everyone_else_variation_empty_rollout(self): - """Test _get_everyone_else_variation returns None for empty rollout.""" - layer = entities.Layer(id='empty_rollout', experiments=[]) - result = ProjectConfig._get_everyone_else_variation(layer) - self.assertIsNone(result) - - def test_get_everyone_else_variation_no_variations(self): - """Test _get_everyone_else_variation returns None when last rule has no variations.""" - layer = entities.Layer( - id='rollout_1', - experiments=[ - { - 'id': 'rule_1', - 'key': 'rule_1', - 'status': 'Running', - 'forcedVariations': {}, - 'layerId': 'rollout_1', - 'audienceIds': [], - 'trafficAllocation': [], - 'variations': [], - }, - ], - ) - - result = ProjectConfig._get_everyone_else_variation(layer) - self.assertIsNone(result) - - -if __name__ == '__main__': - unittest.main() From d7387ad53cf1e9e882c78d3ae4ded36fdfaaa4f5 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 4 Mar 2026 02:23:29 +0600 Subject: [PATCH 3/9] [AI-FSSDK] [FSSDK-12337] Simplify feature rollout config parsing - Inline _get_everyone_else_variation logic, remove unnecessary static method - Use get_rollout_from_id() to match TDD pseudocode - Remove isinstance check (rollout experiments are always dicts) - Remove 3 unit tests for deleted helper method (edge cases already covered by integration-level tests) Co-Authored-By: Claude Opus 4.6 (1M context) --- optimizely/project_config.py | 42 ++++-------------------- tests/test_config.py | 63 ------------------------------------ 2 files changed, 6 insertions(+), 99 deletions(-) diff --git a/optimizely/project_config.py b/optimizely/project_config.py index 5cee3494..b8c398f1 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -234,23 +234,19 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any): # Feature Rollout support: inject the "everyone else" variation # into any experiment with type == "feature_rollout" - rollout_for_flag = ( - None if len(feature.rolloutId) == 0 - else self.rollout_id_map.get(feature.rolloutId) - ) - if rollout_for_flag: - everyone_else_variation = self._get_everyone_else_variation(rollout_for_flag) - if everyone_else_variation is not None: + rollout_for_flag = self.get_rollout_from_id(feature.rolloutId) if feature.rolloutId else None + if rollout_for_flag and rollout_for_flag.experiments: + everyone_else_rule = rollout_for_flag.experiments[-1] + everyone_else_variations = everyone_else_rule.get('variations', []) + if everyone_else_variations: + everyone_else_variation = everyone_else_variations[0] for experiment in rules: if getattr(experiment, 'type', None) == 'feature_rollout': - # Append the everyone else variation to the experiment experiment.variations.append(everyone_else_variation) - # Add traffic allocation entry with endOfRange=10000 experiment.trafficAllocation.append({ 'entityId': everyone_else_variation['id'], 'endOfRange': 10000, }) - # Update variation maps for this experiment var_entity = entities.Variation( id=everyone_else_variation['id'], key=everyone_else_variation['key'], @@ -340,32 +336,6 @@ def _generate_key_map( return key_map - @staticmethod - def _get_everyone_else_variation(rollout: entities.Layer) -> Optional[types.VariationDict]: - """ Get the "everyone else" variation from a rollout. - - The "everyone else" rule is the last experiment in the rollout, - and its first variation is the "everyone else" variation. - - Args: - rollout: The rollout (Layer) entity to get the variation from. - - Returns: - The "everyone else" variation dict, or None if not available. - """ - if not rollout.experiments: - return None - - everyone_else_rule = rollout.experiments[-1] - variations = everyone_else_rule.get('variations', []) if isinstance( - everyone_else_rule, dict - ) else getattr(everyone_else_rule, 'variations', []) - - if not variations: - return None - - return variations[0] - @staticmethod def _deserialize_audience(audience_map: dict[str, entities.Audience]) -> dict[str, entities.Audience]: """ Helper method to de-serialize and populate audience map with the condition list and structure. diff --git a/tests/test_config.py b/tests/test_config.py index 07e2aca3..4c7d7d79 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2363,66 +2363,3 @@ def test_existing_datafile_not_broken(self): self.assertEqual(len(experiment.trafficAllocation), 2) self.assertIsNone(experiment.type) - def test_get_everyone_else_variation_helper(self): - """Test the _get_everyone_else_variation static method directly.""" - layer = entities.Layer( - id='rollout_1', - experiments=[ - { - 'id': 'rule_1', - 'key': 'rule_1', - 'status': 'Running', - 'forcedVariations': {}, - 'layerId': 'rollout_1', - 'audienceIds': [], - 'trafficAllocation': [], - 'variations': [ - {'key': 'var_1', 'id': 'var_1', 'featureEnabled': True} - ], - }, - { - 'id': 'everyone_else', - 'key': 'everyone_else', - 'status': 'Running', - 'forcedVariations': {}, - 'layerId': 'rollout_1', - 'audienceIds': [], - 'trafficAllocation': [], - 'variations': [ - {'key': 'ee_var', 'id': 'ee_var', 'featureEnabled': False} - ], - }, - ], - ) - - result = ProjectConfig._get_everyone_else_variation(layer) - self.assertIsNotNone(result) - self.assertEqual(result['id'], 'ee_var') - self.assertEqual(result['key'], 'ee_var') - - def test_get_everyone_else_variation_empty_rollout(self): - """Test _get_everyone_else_variation returns None for empty rollout.""" - layer = entities.Layer(id='empty_rollout', experiments=[]) - result = ProjectConfig._get_everyone_else_variation(layer) - self.assertIsNone(result) - - def test_get_everyone_else_variation_no_variations(self): - """Test _get_everyone_else_variation returns None when last rule has no variations.""" - layer = entities.Layer( - id='rollout_1', - experiments=[ - { - 'id': 'rule_1', - 'key': 'rule_1', - 'status': 'Running', - 'forcedVariations': {}, - 'layerId': 'rollout_1', - 'audienceIds': [], - 'trafficAllocation': [], - 'variations': [], - }, - ], - ) - - result = ProjectConfig._get_everyone_else_variation(layer) - self.assertIsNone(result) From ff06d056adba4fd5a30cbd9a0d0274a7a31275e8 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 4 Mar 2026 02:32:48 +0600 Subject: [PATCH 4/9] [AI-FSSDK] [FSSDK-12337] Restore _get_everyone_else_variation as method - Restore as instance method matching TDD pseudocode structure - Takes flag (FeatureFlag) param, calls get_rollout_from_id internally - Caller simplified to: everyone_else_variation = self._get_everyone_else_variation(flag) --- optimizely/project_config.py | 80 +++++++++++++++++++++++------------- 1 file changed, 51 insertions(+), 29 deletions(-) diff --git a/optimizely/project_config.py b/optimizely/project_config.py index b8c398f1..2085b5b7 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -234,35 +234,31 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any): # Feature Rollout support: inject the "everyone else" variation # into any experiment with type == "feature_rollout" - rollout_for_flag = self.get_rollout_from_id(feature.rolloutId) if feature.rolloutId else None - if rollout_for_flag and rollout_for_flag.experiments: - everyone_else_rule = rollout_for_flag.experiments[-1] - everyone_else_variations = everyone_else_rule.get('variations', []) - if everyone_else_variations: - everyone_else_variation = everyone_else_variations[0] - for experiment in rules: - if getattr(experiment, 'type', None) == 'feature_rollout': - experiment.variations.append(everyone_else_variation) - experiment.trafficAllocation.append({ - 'entityId': everyone_else_variation['id'], - 'endOfRange': 10000, - }) - var_entity = entities.Variation( - id=everyone_else_variation['id'], - key=everyone_else_variation['key'], - featureEnabled=bool(everyone_else_variation.get('featureEnabled', False)), - variables=cast( - Optional[list[entities.Variable]], - everyone_else_variation.get('variables'), - ), - ) - self.variation_key_map[experiment.key][var_entity.key] = var_entity - self.variation_id_map[experiment.key][var_entity.id] = var_entity - self.variation_id_map_by_experiment_id[experiment.id][var_entity.id] = var_entity - self.variation_key_map_by_experiment_id[experiment.id][var_entity.key] = var_entity - self.variation_variable_usage_map[var_entity.id] = self._generate_key_map( - var_entity.variables, 'id', entities.Variation.VariableUsage - ) + everyone_else_variation = self._get_everyone_else_variation(feature) + if everyone_else_variation is not None: + for experiment in rules: + if getattr(experiment, 'type', None) == 'feature_rollout': + experiment.variations.append(everyone_else_variation) + experiment.trafficAllocation.append({ + 'entityId': everyone_else_variation['id'], + 'endOfRange': 10000, + }) + var_entity = entities.Variation( + id=everyone_else_variation['id'], + key=everyone_else_variation['key'], + featureEnabled=bool(everyone_else_variation.get('featureEnabled', False)), + variables=cast( + Optional[list[entities.Variable]], + everyone_else_variation.get('variables'), + ), + ) + self.variation_key_map[experiment.key][var_entity.key] = var_entity + self.variation_id_map[experiment.key][var_entity.id] = var_entity + self.variation_id_map_by_experiment_id[experiment.id][var_entity.id] = var_entity + self.variation_key_map_by_experiment_id[experiment.id][var_entity.key] = var_entity + self.variation_variable_usage_map[var_entity.id] = self._generate_key_map( + var_entity.variables, 'id', entities.Variation.VariableUsage + ) flag_id = feature.id applicable_holdouts: list[entities.Holdout] = [] @@ -699,6 +695,32 @@ 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[types.VariationDict]: + """ 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 dict, 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 + + return variations[0] + def get_variable_value_for_variation( self, variable: Optional[entities.Variable], variation: Optional[Union[entities.Variation, VariationDict]] ) -> Optional[str]: From 397bacd3ec07d86cd8f2d3d07aefa4f607caf91f Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 4 Mar 2026 08:49:50 +0600 Subject: [PATCH 5/9] [AI-FSSDK] [FSSDK-12337] Add ExperimentTypes constants for type field - Add ExperimentTypes enum in helpers/enums.py with AB, MAB, CMAB, FEATURE_ROLLOUT - Use ExperimentTypes.FEATURE_ROLLOUT constant in config parsing instead of raw string --- optimizely/helpers/enums.py | 7 +++++++ optimizely/project_config.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/optimizely/helpers/enums.py b/optimizely/helpers/enums.py index 74acdcfa..a234011d 100644 --- a/optimizely/helpers/enums.py +++ b/optimizely/helpers/enums.py @@ -102,6 +102,13 @@ class DecisionSources: HOLDOUT: Final = 'holdout' +class ExperimentTypes: + AB: Final = 'a/b' + MAB: Final = 'mab' + CMAB: Final = 'cmab' + FEATURE_ROLLOUT: Final = 'feature_rollout' + + class Errors: INVALID_ATTRIBUTE: Final = 'Provided attribute is not in datafile.' INVALID_ATTRIBUTE_FORMAT: Final = 'Attributes provided are in an invalid format.' diff --git a/optimizely/project_config.py b/optimizely/project_config.py index 2085b5b7..5222d37c 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -237,7 +237,7 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any): everyone_else_variation = self._get_everyone_else_variation(feature) if everyone_else_variation is not None: for experiment in rules: - if getattr(experiment, 'type', None) == 'feature_rollout': + if getattr(experiment, 'type', None) == enums.ExperimentTypes.FEATURE_ROLLOUT: experiment.variations.append(everyone_else_variation) experiment.trafficAllocation.append({ 'entityId': everyone_else_variation['id'], From d95f1e8054760662f42820a0c3ca193a29de86f2 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 4 Mar 2026 08:55:58 +0600 Subject: [PATCH 6/9] [AI-FSSDK] [FSSDK-12337] Type-restrict Experiment.type to ExperimentType - Add ExperimentType Literal type in helpers/types.py: 'a/b', 'mab', 'cmab', 'feature_rollout' - Change Experiment.type from Optional[str] to Optional[ExperimentType] --- optimizely/entities.py | 4 ++-- optimizely/helpers/types.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/optimizely/entities.py b/optimizely/entities.py index 589ca984..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,7 +87,7 @@ def __init__( groupId: Optional[str] = None, groupPolicy: Optional[str] = None, cmab: Optional[CmabDict] = None, - type: Optional[str] = None, + type: Optional[ExperimentType] = None, **kwargs: Any ): self.id = id 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'] From 3f05ad81e6ea7ffc665a4b47f4c2cbec4dc74078 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 4 Mar 2026 09:01:14 +0600 Subject: [PATCH 7/9] [AI-FSSDK] [FSSDK-12337] Remove ExperimentTypes class, simplify type check - Remove redundant ExperimentTypes class from enums.py (ExperimentType Literal suffices) - Simplify getattr(experiment, 'type', None) to experiment.type --- optimizely/helpers/enums.py | 7 ------- optimizely/project_config.py | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/optimizely/helpers/enums.py b/optimizely/helpers/enums.py index a234011d..74acdcfa 100644 --- a/optimizely/helpers/enums.py +++ b/optimizely/helpers/enums.py @@ -102,13 +102,6 @@ class DecisionSources: HOLDOUT: Final = 'holdout' -class ExperimentTypes: - AB: Final = 'a/b' - MAB: Final = 'mab' - CMAB: Final = 'cmab' - FEATURE_ROLLOUT: Final = 'feature_rollout' - - class Errors: INVALID_ATTRIBUTE: Final = 'Provided attribute is not in datafile.' INVALID_ATTRIBUTE_FORMAT: Final = 'Attributes provided are in an invalid format.' diff --git a/optimizely/project_config.py b/optimizely/project_config.py index 5222d37c..553da5d4 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -237,7 +237,7 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any): everyone_else_variation = self._get_everyone_else_variation(feature) if everyone_else_variation is not None: for experiment in rules: - if getattr(experiment, 'type', None) == enums.ExperimentTypes.FEATURE_ROLLOUT: + if experiment.type == 'feature_rollout': experiment.variations.append(everyone_else_variation) experiment.trafficAllocation.append({ 'entityId': everyone_else_variation['id'], From c73f410227d93ac2c68c8902ffb4a0c9bbb6c1a2 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 4 Mar 2026 23:35:04 +0600 Subject: [PATCH 8/9] [AI-FSSDK] [FSSDK-12337] Return Variation entity from _get_everyone_else_variation - Build Variation entity once in helper, derive dict from it in caller - Addresses PR review comment from jaeopt --- optimizely/project_config.py | 50 ++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/optimizely/project_config.py b/optimizely/project_config.py index 553da5d4..8a9d6daf 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -238,26 +238,29 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any): if everyone_else_variation is not None: for experiment in rules: if experiment.type == 'feature_rollout': - experiment.variations.append(everyone_else_variation) + 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'], + 'entityId': everyone_else_variation.id, 'endOfRange': 10000, }) - var_entity = entities.Variation( - id=everyone_else_variation['id'], - key=everyone_else_variation['key'], - featureEnabled=bool(everyone_else_variation.get('featureEnabled', False)), - variables=cast( - Optional[list[entities.Variable]], - everyone_else_variation.get('variables'), - ), + 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[experiment.key][var_entity.key] = var_entity - self.variation_id_map[experiment.key][var_entity.id] = var_entity - self.variation_id_map_by_experiment_id[experiment.id][var_entity.id] = var_entity - self.variation_key_map_by_experiment_id[experiment.id][var_entity.key] = var_entity - self.variation_variable_usage_map[var_entity.id] = self._generate_key_map( - var_entity.variables, 'id', entities.Variation.VariableUsage + 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 @@ -695,7 +698,7 @@ 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[types.VariationDict]: + 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, @@ -705,7 +708,7 @@ def _get_everyone_else_variation(self, flag: entities.FeatureFlag) -> Optional[t flag: The feature flag to get the everyone else variation for. Returns: - The "everyone else" variation dict, or None if not available. + The "everyone else" Variation entity, or None if not available. """ if not flag.rolloutId: return None @@ -719,7 +722,16 @@ def _get_everyone_else_variation(self, flag: entities.FeatureFlag) -> Optional[t if not variations: return None - return variations[0] + 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]] From 147b0dac221f33010acc17bbc22cbf33e48046d4 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Thu, 5 Mar 2026 06:49:21 +0600 Subject: [PATCH 9/9] [AI-FSSDK] [FSSDK-12337] Remove redundant tests, keep 6 essential ones Removed 7 tests that were covered by other tests: - test_experiment_type_field_parsed (covered by injection test) - test_feature_rollout_with_empty_rollout_experiments (similar to no_rollout) - test_feature_rollout_multiple_experiments_mixed_types (covered by injection + unchanged) - test_feature_rollout_flag_variations_map_includes_injected (subset of maps test) - test_experiment_type_ab (just string assignment) - test_feature_rollout_with_variables_on_everyone_else (edge case) - test_existing_datafile_not_broken (covered by none_when_missing + unchanged) --- tests/test_config.py | 406 ------------------------------------------- 1 file changed, 406 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index 4c7d7d79..5ba61d54 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1567,58 +1567,6 @@ def _build_datafile(self, experiments=None, rollouts=None, feature_flags=None): } return datafile - def test_experiment_type_field_parsed(self): - """Test that the optional 'type' field is parsed on Experiment entities.""" - datafile = self._build_datafile( - experiments=[ - { - 'id': 'exp_1', - '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', - }, - ], - rollouts=[ - { - 'id': 'rollout_1', - 'experiments': [ - { - 'id': 'rollout_rule_1', - 'key': 'rollout_rule_1', - 'status': 'Running', - 'forcedVariations': {}, - 'layerId': 'rollout_1', - 'audienceIds': [], - 'trafficAllocation': [{'entityId': 'everyone_var', 'endOfRange': 10000}], - 'variations': [ - {'key': 'everyone_var', 'id': 'everyone_var', 'featureEnabled': False} - ], - } - ], - } - ], - feature_flags=[ - { - 'id': 'flag_1', - 'key': 'test_flag', - 'experimentIds': ['exp_1'], - 'rolloutId': 'rollout_1', - 'variables': [], - }, - ], - ) - - opt = optimizely.Optimizely(json.dumps(datafile)) - config = opt.config_manager.get_config() - - experiment = config.experiment_id_map['exp_1'] - self.assertEqual(experiment.type, 'feature_rollout') - def test_experiment_type_field_none_when_missing(self): """Test that experiments without 'type' field have type=None.""" datafile = self._build_datafile( @@ -1906,133 +1854,6 @@ def test_feature_rollout_with_no_rollout(self): self.assertEqual(len(experiment.variations), 1) self.assertEqual(len(experiment.trafficAllocation), 1) - def test_feature_rollout_with_empty_rollout_experiments(self): - """Test feature_rollout with a rollout that has no experiments.""" - 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', - }, - ], - rollouts=[ - { - 'id': 'rollout_empty', - 'experiments': [], - } - ], - feature_flags=[ - { - 'id': 'flag_1', - 'key': 'test_flag', - 'experimentIds': ['exp_fr'], - 'rolloutId': 'rollout_empty', - 'variables': [], - }, - ], - ) - - opt = optimizely.Optimizely(json.dumps(datafile)) - config = opt.config_manager.get_config() - - experiment = config.experiment_id_map['exp_fr'] - - # With empty rollout experiments, no injection should occur - self.assertEqual(len(experiment.variations), 1) - self.assertEqual(len(experiment.trafficAllocation), 1) - - def test_feature_rollout_multiple_experiments_mixed_types(self): - """Test a flag with both feature_rollout and regular experiments.""" - datafile = self._build_datafile( - experiments=[ - { - 'id': 'exp_ab', - 'key': 'ab_test', - 'status': 'Running', - 'forcedVariations': {}, - 'layerId': 'layer_1', - 'audienceIds': [], - 'trafficAllocation': [{'entityId': 'ab_var', 'endOfRange': 5000}], - 'variations': [ - {'key': 'ab_var', 'id': 'ab_var', 'featureEnabled': True} - ], - 'type': 'a/b', - }, - { - 'id': 'exp_fr', - 'key': 'feature_rollout_exp', - 'status': 'Running', - 'forcedVariations': {}, - 'layerId': 'layer_2', - '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': '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', 'exp_fr'], - 'rolloutId': 'rollout_1', - 'variables': [], - }, - ], - ) - - opt = optimizely.Optimizely(json.dumps(datafile)) - config = opt.config_manager.get_config() - - # A/B test should not be modified - ab_experiment = config.experiment_id_map['exp_ab'] - self.assertEqual(len(ab_experiment.variations), 1) - self.assertEqual(len(ab_experiment.trafficAllocation), 1) - - # Feature rollout should have the everyone else variation injected - fr_experiment = config.experiment_id_map['exp_fr'] - self.assertEqual(len(fr_experiment.variations), 2) - self.assertEqual(len(fr_experiment.trafficAllocation), 2) - - variation_ids = [v['id'] if isinstance(v, dict) else v.id for v in fr_experiment.variations] - self.assertIn('everyone_else_var', variation_ids) - 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( @@ -2135,231 +1956,4 @@ def test_feature_rollout_everyone_else_is_last_rollout_rule(self): self.assertNotIn('targeted_var_1', variation_ids) self.assertNotIn('targeted_var_2', variation_ids) - def test_feature_rollout_flag_variations_map_includes_injected(self): - """Test that flag_variations_map includes the injected everyone else variation.""" - 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': '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() - - flag_variations = config.flag_variations_map.get('test_flag', []) - flag_variation_ids = [v.id for v in flag_variations] - - # The injected variation should be available in flag_variations_map - self.assertIn('everyone_else_var', flag_variation_ids) - self.assertIn('fr_var', flag_variation_ids) - - def test_experiment_type_ab(self): - """Test that experiment with type='a/b' is parsed correctly.""" - datafile = self._build_datafile( - experiments=[ - { - 'id': 'exp_ab', - 'key': 'ab_test', - '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', - }, - ], - 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.assertEqual(experiment.type, 'a/b') - - def test_feature_rollout_with_variables_on_everyone_else(self): - """Test that everyone else variation with variable usages gets properly mapped.""" - 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, - 'variables': [{'id': 'var_100', 'value': 'on'}], - } - ], - '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': 'ee_var', 'endOfRange': 10000} - ], - 'variations': [ - { - 'key': 'ee_var', - 'id': 'ee_var', - 'featureEnabled': False, - 'variables': [{'id': 'var_100', 'value': 'off'}], - } - ], - }, - ], - } - ], - feature_flags=[ - { - 'id': 'flag_1', - 'key': 'test_flag', - 'experimentIds': ['exp_fr'], - 'rolloutId': 'rollout_1', - 'variables': [ - {'id': 'var_100', 'key': 'toggle', 'defaultValue': 'default', 'type': 'string'}, - ], - }, - ], - ) - - opt = optimizely.Optimizely(json.dumps(datafile)) - config = opt.config_manager.get_config() - - # Verify the variation variable usage map is populated for the injected variation - self.assertIn('ee_var', config.variation_variable_usage_map) - variable_usage = config.variation_variable_usage_map['ee_var'] - self.assertIn('var_100', variable_usage) - self.assertEqual(variable_usage['var_100'].value, 'off') - - def test_existing_datafile_not_broken(self): - """Test that existing datafiles without feature_rollout type still work correctly.""" - datafile = self._build_datafile( - experiments=[ - { - 'id': 'exp_1', - 'key': 'regular_exp', - 'status': 'Running', - 'forcedVariations': {'user_1': 'control'}, - 'layerId': 'layer_1', - 'audienceIds': [], - 'trafficAllocation': [ - {'entityId': 'control', 'endOfRange': 5000}, - {'entityId': 'variation', 'endOfRange': 10000}, - ], - 'variations': [ - {'key': 'control', 'id': 'control', 'featureEnabled': False}, - {'key': 'variation', 'id': 'variation', 'featureEnabled': True}, - ], - }, - ], - rollouts=[ - { - 'id': 'rollout_1', - 'experiments': [ - { - 'id': 'rollout_rule', - 'key': 'rollout_rule', - 'status': 'Running', - 'forcedVariations': {}, - 'layerId': 'rollout_1', - 'audienceIds': [], - 'trafficAllocation': [{'entityId': 'rollout_var', 'endOfRange': 10000}], - 'variations': [ - {'key': 'rollout_var', 'id': 'rollout_var', 'featureEnabled': True} - ], - }, - ], - } - ], - feature_flags=[ - { - 'id': 'flag_1', - 'key': 'test_flag', - 'experimentIds': ['exp_1'], - 'rolloutId': 'rollout_1', - 'variables': [], - }, - ], - ) - - opt = optimizely.Optimizely(json.dumps(datafile)) - config = opt.config_manager.get_config() - - # Regular experiment should be unchanged - experiment = config.experiment_id_map['exp_1'] - self.assertEqual(len(experiment.variations), 2) - self.assertEqual(len(experiment.trafficAllocation), 2) - self.assertIsNone(experiment.type)