diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index ba82adb8..72496edc 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -1,4 +1,4 @@ -# Copyright 2016-2019, Optimizely +# Copyright 2016-2020, 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 @@ -25,6 +25,7 @@ from .event_dispatcher import EventDispatcher as default_event_dispatcher from .helpers import enums, validator from .notification_center import NotificationCenter +from .optimizely_config import OptimizelyConfigService class Optimizely(object): @@ -733,3 +734,21 @@ def get_forced_variation(self, experiment_key, user_id): forced_variation = self.decision_service.get_forced_variation(project_config, experiment_key, user_id) return forced_variation.key if forced_variation else None + + def get_optimizely_config(self): + """ Gets OptimizelyConfig instance for the current project config. + + Returns: + OptimizelyConfig instance. None if the optimizely instance is invalid or + project config isn't available. + """ + if not self.is_valid: + self.logger.error(enums.Errors.INVALID_OPTIMIZELY.format('get_optimizely_config')) + return None + + project_config = self.config_manager.get_config() + if not project_config: + self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('get_optimizely_config')) + return None + + return OptimizelyConfigService(project_config).get_config() diff --git a/optimizely/optimizely_config.py b/optimizely/optimizely_config.py new file mode 100644 index 00000000..9fcc0948 --- /dev/null +++ b/optimizely/optimizely_config.py @@ -0,0 +1,225 @@ +# Copyright 2020, 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 copy + +from .project_config import ProjectConfig + + +class OptimizelyConfig(object): + def __init__(self, revision, experiments_map, features_map): + self.revision = revision + self.experiments_map = experiments_map + self.features_map = features_map + + +class OptimizelyExperiment(object): + def __init__(self, id, key, variations_map): + self.id = id + self.key = key + self.variations_map = variations_map + + +class OptimizelyFeature(object): + def __init__(self, id, key, experiments_map, variables_map): + self.id = id + self.key = key + self.experiments_map = experiments_map + self.variables_map = variables_map + + +class OptimizelyVariation(object): + def __init__(self, id, key, feature_enabled, variables_map): + self.id = id + self.key = key + self.feature_enabled = feature_enabled + self.variables_map = variables_map + + +class OptimizelyVariable(object): + def __init__(self, id, key, variable_type, value): + self.id = id + self.key = key + self.type = variable_type + self.value = value + + +class OptimizelyConfigService(object): + """ Class encapsulating methods to be used in creating instance of OptimizelyConfig. """ + + def __init__(self, project_config): + """ + Args: + project_config ProjectConfig + """ + self.is_valid = True + + if not isinstance(project_config, ProjectConfig): + self.is_valid = False + return + + self.experiments = project_config.experiments + self.feature_flags = project_config.feature_flags + self.groups = project_config.groups + self.revision = project_config.revision + + self._create_lookup_maps() + + def get_config(self): + """ Gets instance of OptimizelyConfig + + Returns: + Optimizely Config instance or None if OptimizelyConfigService is invalid. + """ + + if not self.is_valid: + return None + + experiments_key_map, experiments_id_map = self._get_experiments_maps() + features_map = self._get_features_map(experiments_id_map) + + return OptimizelyConfig(self.revision, experiments_key_map, features_map) + + def _create_lookup_maps(self): + """ Creates lookup maps to avoid redundant iteration of config objects. """ + + self.exp_id_to_feature_map = {} + self.feature_key_variable_key_to_variable_map = {} + self.feature_key_variable_id_to_variable_map = {} + + for feature in self.feature_flags: + for experiment_id in feature['experimentIds']: + self.exp_id_to_feature_map[experiment_id] = feature + + variables_key_map = {} + variables_id_map = {} + for variable in feature.get('variables', []): + opt_variable = OptimizelyVariable( + variable['id'], variable['key'], variable['type'], variable['defaultValue'] + ) + variables_key_map[variable['key']] = opt_variable + variables_id_map[variable['id']] = opt_variable + + self.feature_key_variable_key_to_variable_map[feature['key']] = variables_key_map + self.feature_key_variable_id_to_variable_map[feature['key']] = variables_id_map + + def _get_variables_map(self, experiment, variation): + """ Gets variables map for given experiment and variation. + + Args: + experiment dict -- Experiment parsed from the datafile. + variation dict -- Variation of the given experiment. + + Returns: + dict - Map of variable key to OptimizelyVariable for the given variation. + """ + feature_flag = self.exp_id_to_feature_map.get(experiment['id'], None) + if feature_flag is None: + return {} + + # set default variables for each variation + variables_map = {} + variables_map = copy.deepcopy(self.feature_key_variable_key_to_variable_map[feature_flag['key']]) + + # set variation specific variable value if any + if variation.get('featureEnabled'): + for variable in variation.get('variables', []): + feature_variable = self.feature_key_variable_id_to_variable_map[feature_flag['key']][variable['id']] + variables_map[feature_variable.key].value = variable['value'] + + return variables_map + + def _get_variations_map(self, experiment): + """ Gets variation map for the given experiment. + + Args: + experiment dict -- Experiment parsed from the datafile. + + Returns: + dict -- Map of variation key to OptimizelyVariation. + """ + variations_map = {} + + for variation in experiment.get('variations', []): + variables_map = self._get_variables_map(experiment, variation) + feature_enabled = variation.get('featureEnabled', None) + + optly_variation = OptimizelyVariation( + variation['id'], variation['key'], feature_enabled, variables_map + ) + + variations_map[variation['key']] = optly_variation + + return variations_map + + def _get_all_experiments(self): + """ Gets all experiments in the project config. + + Returns: + list -- List of dicts of experiments. + """ + experiments = self.experiments + + for group in self.groups: + experiments = experiments + group['experiments'] + + return experiments + + def _get_experiments_maps(self): + """ Gets maps for all the experiments in the project config. + + Returns: + dict, dict -- experiment key/id to OptimizelyExperiment maps. + """ + # Key map is required for the OptimizelyConfig response. + experiments_key_map = {} + # Id map comes in handy to figure out feature experiment. + experiments_id_map = {} + + all_experiments = self._get_all_experiments() + for exp in all_experiments: + optly_exp = OptimizelyExperiment( + exp['id'], exp['key'], self._get_variations_map(exp) + ) + + experiments_key_map[exp['key']] = optly_exp + experiments_id_map[exp['id']] = optly_exp + + return experiments_key_map, experiments_id_map + + def _get_features_map(self, experiments_id_map): + """ Gets features map for the project config. + + Args: + experiments_id_map dict -- experiment id to OptimizelyExperiment map + + Returns: + dict -- feaure key to OptimizelyFeature map + """ + features_map = {} + + for feature in self.feature_flags: + exp_map = {} + for experiment_id in feature.get('experimentIds', []): + optly_exp = experiments_id_map[experiment_id] + exp_map[optly_exp.key] = optly_exp + + variables_map = self.feature_key_variable_key_to_variable_map[feature['key']] + + optly_feature = OptimizelyFeature( + feature['id'], feature['key'], exp_map, variables_map + ) + + features_map[feature['key']] = optly_feature + + return features_map diff --git a/tests/base.py b/tests/base.py index 2b2e2802..48d28857 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,4 +1,4 @@ -# Copyright 2016-2019, Optimizely +# Copyright 2016-2020, 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 @@ -182,14 +182,12 @@ def setUp(self, config_dict='config_dict'): { 'id': '122239', 'key': 'control', - 'featureEnabled': True, - 'variables': [{'id': '155551', 'value': '42.42'}], + 'variables': [], }, { 'id': '122240', 'key': 'variation', - 'featureEnabled': True, - 'variables': [{'id': '155551', 'value': '13.37'}], + 'variables': [], }, ], }, diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index 39978451..44bbf27e 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -1,4 +1,4 @@ -# Copyright 2016-2019, Optimizely +# Copyright 2016-2020, 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 @@ -23,6 +23,7 @@ from optimizely import exceptions from optimizely import logger from optimizely import optimizely +from optimizely import optimizely_config from optimizely import project_config from optimizely import version from optimizely.event.event_factory import EventFactory @@ -3911,6 +3912,39 @@ def test_get_feature_variable_returns__default_value__complex_audience_match(sel self.assertEqual(10, opt_obj.get_feature_variable_integer('feat2_with_var', 'z', 'user1', {})) self.assertEqual(10, opt_obj.get_feature_variable('feat2_with_var', 'z', 'user1', {})) + def test_get_optimizely_config__invalid_object(self): + """ Test that get_optimizely_config logs error if Optimizely instance is invalid. """ + + class InvalidConfigManager(object): + pass + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict), config_manager=InvalidConfigManager()) + + with mock.patch.object(opt_obj, 'logger') as mock_client_logging: + self.assertIsNone(opt_obj.get_optimizely_config()) + + mock_client_logging.error.assert_called_once_with( + 'Optimizely instance is not valid. Failing "get_optimizely_config".') + + def test_get_optimizely_config__invalid_config(self): + """ Test that get_optimizely_config logs error if config is invalid. """ + + opt_obj = optimizely.Optimizely('invalid_datafile') + + with mock.patch.object(opt_obj, 'logger') as mock_client_logging: + self.assertIsNone(opt_obj.get_optimizely_config()) + + mock_client_logging.error.assert_called_once_with( + 'Invalid config. Optimizely instance is not valid. ' 'Failing "get_optimizely_config".' + ) + + def test_get_optimizely_config_returns_instance_of_optimizely_config(self): + """ Test that get_optimizely_config returns an instance of OptimizelyConfig. """ + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + opt_config = opt_obj.get_optimizely_config() + self.assertIsInstance(opt_config, optimizely_config.OptimizelyConfig) + class OptimizelyWithExceptionTest(base.BaseTest): def setUp(self): diff --git a/tests/test_optimizely_config.py b/tests/test_optimizely_config.py new file mode 100644 index 00000000..495325ea --- /dev/null +++ b/tests/test_optimizely_config.py @@ -0,0 +1,473 @@ +# Copyright 2020, 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 + +from optimizely import optimizely +from optimizely import optimizely_config +from . import base + + +class OptimizelyConfigTest(base.BaseTest): + def setUp(self): + base.BaseTest.setUp(self) + opt_instance = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + self.project_config = opt_instance.config_manager.get_config() + self.opt_config_service = optimizely_config.OptimizelyConfigService(self.project_config) + + self.expected_config = { + 'experiments_map': { + 'test_experiment2': { + 'variations_map': { + 'control': { + 'variables_map': { + + }, + 'id': '122239', + 'key': 'control', + 'feature_enabled': None + }, + 'variation': { + 'variables_map': { + + }, + 'id': '122240', + 'key': 'variation', + 'feature_enabled': None + } + }, + 'id': '111133', + 'key': 'test_experiment2' + }, + 'test_experiment': { + 'variations_map': { + 'control': { + 'variables_map': { + 'environment': { + 'key': 'environment', + 'type': 'string', + 'id': '128', + 'value': 'devel' + }, + 'count': { + 'key': 'count', + 'type': 'integer', + 'id': '130', + 'value': '999' + }, + 'is_working': { + 'key': 'is_working', + 'type': 'boolean', + 'id': '127', + 'value': 'true' + }, + 'cost': { + 'key': 'cost', + 'type': 'double', + 'id': '129', + 'value': '10.99' + }, + 'variable_without_usage': { + 'key': 'variable_without_usage', + 'type': 'integer', + 'id': '131', + 'value': '45' + } + }, + 'id': '111128', + 'key': 'control', + 'feature_enabled': False + }, + 'variation': { + 'variables_map': { + 'environment': { + 'key': 'environment', + 'type': 'string', + 'id': '128', + 'value': 'staging' + }, + 'count': { + 'key': 'count', + 'type': 'integer', + 'id': '130', + 'value': '4243' + }, + 'is_working': { + 'key': 'is_working', + 'type': 'boolean', + 'id': '127', + 'value': 'true' + }, + 'cost': { + 'key': 'cost', + 'type': 'double', + 'id': '129', + 'value': '10.02' + }, + 'variable_without_usage': { + 'key': 'variable_without_usage', + 'type': 'integer', + 'id': '131', + 'value': '45' + } + }, + 'id': '111129', + 'key': 'variation', + 'feature_enabled': True + } + }, + 'id': '111127', + 'key': 'test_experiment' + }, + 'group_exp_1': { + 'variations_map': { + 'group_exp_1_variation': { + 'variables_map': { + + }, + 'id': '28902', + 'key': 'group_exp_1_variation', + 'feature_enabled': None + }, + 'group_exp_1_control': { + 'variables_map': { + + }, + 'id': '28901', + 'key': 'group_exp_1_control', + 'feature_enabled': None + } + }, + 'id': '32222', + 'key': 'group_exp_1' + }, + 'group_exp_2': { + 'variations_map': { + 'group_exp_2_variation': { + 'variables_map': { + + }, + 'id': '28906', + 'key': 'group_exp_2_variation', + 'feature_enabled': None + }, + 'group_exp_2_control': { + 'variables_map': { + + }, + 'id': '28905', + 'key': 'group_exp_2_control', + 'feature_enabled': None + } + }, + 'id': '32223', + 'key': 'group_exp_2' + } + }, + 'features_map': { + 'test_feature_in_experiment': { + 'variables_map': { + 'environment': { + 'key': 'environment', + 'type': 'string', + 'id': '128', + 'value': 'devel' + }, + 'count': { + 'key': 'count', + 'type': 'integer', + 'id': '130', + 'value': '999' + }, + 'is_working': { + 'key': 'is_working', + 'type': 'boolean', + 'id': '127', + 'value': 'true' + }, + 'cost': { + 'key': 'cost', + 'type': 'double', + 'id': '129', + 'value': '10.99' + }, + 'variable_without_usage': { + 'key': 'variable_without_usage', + 'type': 'integer', + 'id': '131', + 'value': '45' + } + }, + 'experiments_map': { + 'test_experiment': { + 'variations_map': { + 'control': { + 'variables_map': { + 'environment': { + 'key': 'environment', + 'type': 'string', + 'id': '128', + 'value': 'devel' + }, + 'count': { + 'key': 'count', + 'type': 'integer', + 'id': '130', + 'value': '999' + }, + 'is_working': { + 'key': 'is_working', + 'type': 'boolean', + 'id': '127', + 'value': 'true' + }, + 'cost': { + 'key': 'cost', + 'type': 'double', + 'id': '129', + 'value': '10.99' + }, + 'variable_without_usage': { + 'key': 'variable_without_usage', + 'type': 'integer', + 'id': '131', + 'value': '45' + } + }, + 'id': '111128', + 'key': 'control', + 'feature_enabled': False + }, + 'variation': { + 'variables_map': { + 'environment': { + 'key': 'environment', + 'type': 'string', + 'id': '128', + 'value': 'staging' + }, + 'count': { + 'key': 'count', + 'type': 'integer', + 'id': '130', + 'value': '4243' + }, + 'is_working': { + 'key': 'is_working', + 'type': 'boolean', + 'id': '127', + 'value': 'true' + }, + 'cost': { + 'key': 'cost', + 'type': 'double', + 'id': '129', + 'value': '10.02' + }, + 'variable_without_usage': { + 'key': 'variable_without_usage', + 'type': 'integer', + 'id': '131', + 'value': '45' + } + }, + 'id': '111129', + 'key': 'variation', + 'feature_enabled': True + } + }, + 'id': '111127', + 'key': 'test_experiment' + } + }, + 'id': '91111', + 'key': 'test_feature_in_experiment' + }, + 'test_feature_in_rollout': { + 'variables_map': { + 'count': { + 'key': 'count', + 'type': 'integer', + 'id': '135', + 'value': '999' + }, + 'message': { + 'key': 'message', + 'type': 'string', + 'id': '133', + 'value': 'Hello' + }, + 'price': { + 'key': 'price', + 'type': 'double', + 'id': '134', + 'value': '99.99' + }, + 'is_running': { + 'key': 'is_running', + 'type': 'boolean', + 'id': '132', + 'value': 'false' + } + }, + 'experiments_map': { + + }, + 'id': '91112', + 'key': 'test_feature_in_rollout' + }, + 'test_feature_in_group': { + 'variables_map': { + + }, + 'experiments_map': { + 'group_exp_1': { + 'variations_map': { + 'group_exp_1_variation': { + 'variables_map': { + + }, + 'id': '28902', + 'key': 'group_exp_1_variation', + 'feature_enabled': None + }, + 'group_exp_1_control': { + 'variables_map': { + + }, + 'id': '28901', + 'key': 'group_exp_1_control', + 'feature_enabled': None + } + }, + 'id': '32222', + 'key': 'group_exp_1' + } + }, + 'id': '91113', + 'key': 'test_feature_in_group' + }, + 'test_feature_in_experiment_and_rollout': { + 'variables_map': { + + }, + 'experiments_map': { + 'group_exp_2': { + 'variations_map': { + 'group_exp_2_variation': { + 'variables_map': { + + }, + 'id': '28906', + 'key': 'group_exp_2_variation', + 'feature_enabled': None + }, + 'group_exp_2_control': { + 'variables_map': { + + }, + 'id': '28905', + 'key': 'group_exp_2_control', + 'feature_enabled': None + } + }, + 'id': '32223', + 'key': 'group_exp_2' + } + }, + 'id': '91114', + 'key': 'test_feature_in_experiment_and_rollout' + } + }, + 'revision': '1' + } + + self.actual_config = self.opt_config_service.get_config() + self.actual_config_dict = self.to_dict(self.actual_config) + + def to_dict(self, obj): + return json.loads(json.dumps(obj, default=lambda o: o.__dict__)) + + def test__get_config(self): + """ Test that get_config returns an expected instance of OptimizelyConfig. """ + + self.assertIsInstance(self.actual_config, optimizely_config.OptimizelyConfig) + self.assertEqual(self.expected_config, self.actual_config_dict) + + def test__get_config__invalid_project_config(self): + """ Test that get_config returns None when invalid project config supplied. """ + + opt_service = optimizely_config.OptimizelyConfigService({"key": "invalid"}) + self.assertIsNone(opt_service.get_config()) + + def test__get_experiments_maps(self): + """ Test that get_experiments_map returns expected experiment key and id maps. """ + + actual_key_map, actual_id_map = self.opt_config_service._get_experiments_maps() + expected_key_map = self.expected_config['experiments_map'] + + self.assertIsInstance(actual_key_map, dict) + for exp in actual_key_map.values(): + self.assertIsInstance(exp, optimizely_config.OptimizelyExperiment) + + self.assertEqual(expected_key_map, self.to_dict(actual_key_map)) + + expected_id_map = {} + for exp in expected_key_map.values(): + expected_id_map[exp['id']] = exp + + self.assertEqual(expected_id_map, self.to_dict(actual_id_map)) + + def test__get_features_map(self): + """ Test that get_features_map returns expected features map. """ + + exp_key_map, exp_id_map = self.opt_config_service._get_experiments_maps() + + actual_feature_map = self.opt_config_service._get_features_map(exp_id_map) + expected_feature_map = self.expected_config['features_map'] + + self.assertIsInstance(actual_feature_map, dict) + for feat in actual_feature_map.values(): + self.assertIsInstance(feat, optimizely_config.OptimizelyFeature) + + self.assertEqual(expected_feature_map, self.to_dict(actual_feature_map)) + + def test__get_variations_map(self): + """ Test that get_variations_map returns expected variations map. """ + + experiment = self.project_config.experiments[0] + actual_variations_map = self.opt_config_service._get_variations_map(experiment) + + expected_variations_map = self.expected_config['experiments_map']['test_experiment']['variations_map'] + + self.assertIsInstance(actual_variations_map, dict) + for variation in actual_variations_map.values(): + self.assertIsInstance(variation, optimizely_config.OptimizelyVariation) + + self.assertEqual(expected_variations_map, self.to_dict(actual_variations_map)) + + def test__get_variables_map(self): + """ Test that get_variables_map returns expected variables map. """ + + experiment = self.project_config.experiments[0] + variation = experiment['variations'][0] + actual_variables_map = self.opt_config_service._get_variables_map(experiment, variation) + + expected_variations_map = self.expected_config['experiments_map']['test_experiment']['variations_map'] + expected_variables_map = expected_variations_map['control']['variables_map'] + + self.assertIsInstance(actual_variables_map, dict) + for variable in actual_variables_map.values(): + self.assertIsInstance(variable, optimizely_config.OptimizelyVariable) + + self.assertEqual(expected_variables_map, self.to_dict(actual_variables_map))