From 1ca4fcad98706971de245d38b059ef14c2caa2e9 Mon Sep 17 00:00:00 2001 From: pawels-optimizely <44238966+pawels-optimizely@users.noreply.github.com> Date: Fri, 24 Apr 2020 10:01:08 -0700 Subject: [PATCH] feat: Implement get_all_feature_variables and get_feature_variable_json (#251) * feat: Implement get_all_feature_variables and get_feature_variable_json --- optimizely/entities.py | 1 + optimizely/helpers/enums.py | 1 + optimizely/optimizely.py | 139 +++++++++++++ optimizely/project_config.py | 11 + tests/base.py | 11 + tests/test_config.py | 7 + tests/test_optimizely.py | 359 +++++++++++++++++++++++++++++++- tests/test_optimizely_config.py | 66 ++++++ 8 files changed, 593 insertions(+), 2 deletions(-) diff --git a/optimizely/entities.py b/optimizely/entities.py index 75c73845..054107dc 100644 --- a/optimizely/entities.py +++ b/optimizely/entities.py @@ -101,6 +101,7 @@ class Type(object): BOOLEAN = 'boolean' DOUBLE = 'double' INTEGER = 'integer' + JSON = 'json' STRING = 'string' def __init__(self, id, key, type, defaultValue, **kwargs): diff --git a/optimizely/helpers/enums.py b/optimizely/helpers/enums.py index 3a911417..17da03bb 100644 --- a/optimizely/helpers/enums.py +++ b/optimizely/helpers/enums.py @@ -75,6 +75,7 @@ class DecisionNotificationTypes(object): FEATURE = 'feature' FEATURE_TEST = 'feature-test' FEATURE_VARIABLE = 'feature-variable' + ALL_FEATURE_VARIABLES = 'all-feature-variables' class DecisionSources(object): diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 3e8de499..90d0aae7 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -287,6 +287,97 @@ def _get_feature_variable_for_type( ) return actual_value + def _get_all_feature_variables_for_type( + self, project_config, feature_key, user_id, attributes, + ): + """ Helper method to determine value for all variables attached to a feature flag. + + Args: + project_config: Instance of ProjectConfig. + feature_key: Key of the feature whose variable's value is being accessed. + user_id: ID for user. + attributes: Dict representing user attributes. + + Returns: + Dictionary of all variables. None if: + - Feature key is invalid. + """ + if not validator.is_non_empty_string(feature_key): + self.logger.error(enums.Errors.INVALID_INPUT.format('feature_key')) + return None + + if not isinstance(user_id, string_types): + self.logger.error(enums.Errors.INVALID_INPUT.format('user_id')) + return None + + if not self._validate_user_inputs(attributes): + return None + + feature_flag = project_config.get_feature_from_key(feature_key) + if not feature_flag: + return None + + feature_enabled = False + source_info = {} + + decision = self.decision_service.get_variation_for_feature(project_config, feature_flag, user_id, attributes) + if decision.variation: + + feature_enabled = decision.variation.featureEnabled + if feature_enabled: + self.logger.info( + 'Feature "%s" for variation "%s" is enabled.' % (feature_key, decision.variation.key) + ) + else: + self.logger.info( + 'Feature "%s" for variation "%s" is not enabled.' % (feature_key, decision.variation.key) + ) + else: + self.logger.info( + 'User "%s" is not in any variation or rollout rule. ' + 'Returning default value for all variables of feature flag "%s".' % (user_id, feature_key) + ) + + all_variables = {} + for variable_key in feature_flag.variables: + variable = project_config.get_variable_for_feature(feature_key, variable_key) + variable_value = variable.defaultValue + if feature_enabled: + variable_value = project_config.get_variable_value_for_variation(variable, decision.variation) + self.logger.debug( + 'Got variable value "%s" for variable "%s" of feature flag "%s".' + % (variable_value, variable_key, feature_key) + ) + + try: + actual_value = project_config.get_typecast_value(variable_value, variable.type) + except: + self.logger.error('Unable to cast value. Returning None.') + actual_value = None + + all_variables[variable_key] = actual_value + + if decision.source == enums.DecisionSources.FEATURE_TEST: + source_info = { + 'experiment_key': decision.experiment.key, + 'variation_key': decision.variation.key, + } + + self.notification_center.send_notifications( + enums.NotificationTypes.DECISION, + enums.DecisionNotificationTypes.ALL_FEATURE_VARIABLES, + user_id, + attributes or {}, + { + 'feature_key': feature_key, + 'feature_enabled': feature_enabled, + 'variable_values': all_variables, + 'source': decision.source, + 'source_info': source_info, + }, + ) + return all_variables + def activate(self, experiment_key, user_id, attributes=None): """ Buckets visitor and sends impression event to Optimizely. @@ -672,6 +763,54 @@ def get_feature_variable_string(self, feature_key, variable_key, user_id, attrib project_config, feature_key, variable_key, variable_type, user_id, attributes, ) + def get_feature_variable_json(self, feature_key, variable_key, user_id, attributes=None): + """ Returns value for a certain JSON variable attached to a feature. + + Args: + feature_key: Key of the feature whose variable's value is being accessed. + variable_key: Key of the variable whose value is to be accessed. + user_id: ID for user. + attributes: Dict representing user attributes. + + Returns: + Dictionary object of the variable. None if: + - Feature key is invalid. + - Variable key is invalid. + - Mismatch with type of variable. + """ + + variable_type = entities.Variable.Type.JSON + project_config = self.config_manager.get_config() + if not project_config: + self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('get_feature_variable_json')) + return None + + return self._get_feature_variable_for_type( + project_config, feature_key, variable_key, variable_type, user_id, attributes, + ) + + def get_all_feature_variables(self, feature_key, user_id, attributes=None): + """ Returns dictionary of all variables and their corresponding values in the context of a feature. + + Args: + feature_key: Key of the feature whose variable's value is being accessed. + user_id: ID for user. + attributes: Dict representing user attributes. + + Returns: + Dictionary mapping variable key to variable value. None if: + - Feature key is invalid. + """ + + project_config = self.config_manager.get_config() + if not project_config: + self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('get_all_feature_variables')) + return None + + return self._get_all_feature_variables_for_type( + project_config, feature_key, user_id, attributes, + ) + def set_forced_variation(self, experiment_key, user_id, variation_key): """ Force a user into a variation for a given experiment. diff --git a/optimizely/project_config.py b/optimizely/project_config.py index b944015e..7265dc81 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -107,6 +107,15 @@ def __init__(self, datafile, logger, error_handler): self.feature_key_map = self._generate_key_map(self.feature_flags, 'key', entities.FeatureFlag) + # As we cannot create json variables in datafile directly, here we convert + # the variables of string type and json subType to json type + # This is needed to fully support json variables + for feature in self.feature_key_map: + for variable in self.feature_key_map[feature].variables: + sub_type = variable.get('subType', '') + if variable['type'] == entities.Variable.Type.STRING and sub_type == entities.Variable.Type.JSON: + variable['type'] = entities.Variable.Type.JSON + # Dict containing map of experiment ID to feature ID. # for checking that experiment is a feature experiment or not. self.experiment_feature_map = {} @@ -177,6 +186,8 @@ def get_typecast_value(self, value, type): return int(value) elif type == entities.Variable.Type.DOUBLE: return float(value) + elif type == entities.Variable.Type.JSON: + return json.loads(value) else: return value diff --git a/tests/base.py b/tests/base.py index 48d28857..432d5287 100644 --- a/tests/base.py +++ b/tests/base.py @@ -152,6 +152,8 @@ def setUp(self, config_dict='config_dict'): {'id': '128', 'value': 'prod'}, {'id': '129', 'value': '10.01'}, {'id': '130', 'value': '4242'}, + {'id': '132', 'value': '{"test": 122}'}, + {'id': '133', 'value': '{"true_test": 1.3}'}, ], }, { @@ -163,6 +165,8 @@ def setUp(self, config_dict='config_dict'): {'id': '128', 'value': 'staging'}, {'id': '129', 'value': '10.02'}, {'id': '130', 'value': '4243'}, + {'id': '132', 'value': '{"test": 123}'}, + {'id': '133', 'value': '{"true_test": 1.4}'}, ], }, ], @@ -274,6 +278,7 @@ def setUp(self, config_dict='config_dict'): {'id': '133', 'value': 'Hello audience'}, {'id': '134', 'value': '39.99'}, {'id': '135', 'value': '399'}, + {'id': '136', 'value': '{"field": 12}'}, ], }, { @@ -285,6 +290,7 @@ def setUp(self, config_dict='config_dict'): {'id': '133', 'value': 'environment'}, {'id': '134', 'value': '49.99'}, {'id': '135', 'value': '499'}, + {'id': '136', 'value': '{"field": 123}'}, ], }, ], @@ -324,6 +330,9 @@ def setUp(self, config_dict='config_dict'): {'id': '129', 'key': 'cost', 'defaultValue': '10.99', 'type': 'double'}, {'id': '130', 'key': 'count', 'defaultValue': '999', 'type': 'integer'}, {'id': '131', 'key': 'variable_without_usage', 'defaultValue': '45', 'type': 'integer'}, + {'id': '132', 'key': 'object', 'defaultValue': '{"test": 12}', 'type': 'string', + 'subType': 'json'}, + {'id': '133', 'key': 'true_object', 'defaultValue': '{"true_test": 23.54}', 'type': 'json'}, ], }, { @@ -336,6 +345,8 @@ def setUp(self, config_dict='config_dict'): {'id': '133', 'key': 'message', 'defaultValue': 'Hello', 'type': 'string'}, {'id': '134', 'key': 'price', 'defaultValue': '99.99', 'type': 'double'}, {'id': '135', 'key': 'count', 'defaultValue': '999', 'type': 'integer'}, + {'id': '136', 'key': 'object', 'defaultValue': '{"field": 1}', 'type': 'string', + 'subType': 'json'}, ], }, { diff --git a/tests/test_config.py b/tests/test_config.py index b9ca4ee9..13cf1105 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -284,6 +284,8 @@ def test_init__with_v4_datafile(self): {'id': '128', 'key': 'environment', 'defaultValue': 'devel', 'type': 'string'}, {'id': '129', 'key': 'number_of_days', 'defaultValue': '192', 'type': 'integer'}, {'id': '130', 'key': 'significance_value', 'defaultValue': '0.00098', 'type': 'double'}, + {'id': '131', 'key': 'object', 'defaultValue': '{"field": 12.4}', 'type': 'string', + 'subType': 'json'}, ], }, { @@ -489,6 +491,7 @@ def test_init__with_v4_datafile(self): 'environment': entities.Variable('128', 'environment', 'string', 'devel'), 'number_of_days': entities.Variable('129', 'number_of_days', 'integer', '192'), 'significance_value': entities.Variable('130', 'significance_value', 'double', '0.00098'), + 'object': entities.Variable('131', 'object', 'json', '{"field": 12.4}'), }, ), 'test_feature_in_rollout': entities.FeatureFlag( @@ -814,6 +817,7 @@ def test_get_feature_from_key__valid_feature_key(self): 'message': entities.Variable('133', 'message', 'string', 'Hello'), 'price': entities.Variable('134', 'price', 'double', '99.99'), 'count': entities.Variable('135', 'count', 'integer', '999'), + 'object': entities.Variable('136', 'object', 'json', '{"field": 1}'), }, ) @@ -856,6 +860,7 @@ def test_get_rollout_from_id__valid_rollout_id(self): {'id': '133', 'value': 'Hello audience'}, {'id': '134', 'value': '39.99'}, {'id': '135', 'value': '399'}, + {'id': '136', 'value': '{"field": 12}'}, ], }, { @@ -867,6 +872,7 @@ def test_get_rollout_from_id__valid_rollout_id(self): {'id': '133', 'value': 'environment'}, {'id': '134', 'value': '49.99'}, {'id': '135', 'value': '499'}, + {'id': '136', 'value': '{"field": 123}'}, ], }, ], @@ -893,6 +899,7 @@ def test_get_rollout_from_id__valid_rollout_id(self): }, ], ) + self.assertEqual(expected_rollout, project_config.get_rollout_from_id('211111')) def test_get_rollout_from_id__invalid_rollout_id(self): diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index 4770bcdb..b74afb08 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -2518,6 +2518,101 @@ def test_get_feature_variable_string(self): }, ) + def test_get_feature_variable_json(self): + """ Test that get_feature_variable_json returns dictionary object as expected \ + and broadcasts decision with proper parameters. """ + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('test_experiment') + mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + ), mock.patch.object(opt_obj.config_manager.get_config(), 'logger') as mock_config_logging, mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision: + self.assertEqual( + {"test": 123}, + opt_obj.get_feature_variable_json('test_feature_in_experiment', 'object', 'test_user'), + ) + + mock_config_logging.info.assert_called_once_with( + 'Value for variable "object" for variation "variation" is "{"test": 123}".' + ) + + mock_broadcast_decision.assert_called_once_with( + enums.NotificationTypes.DECISION, + 'feature-variable', + 'test_user', + {}, + { + 'feature_key': 'test_feature_in_experiment', + 'feature_enabled': True, + 'source': 'feature-test', + 'variable_key': 'object', + 'variable_value': {"test": 123}, + 'variable_type': 'json', + 'source_info': {'experiment_key': 'test_experiment', 'variation_key': 'variation'}, + }, + ) + + def test_get_all_feature_variables(self): + """ Test that get_all_feature_variables returns dictionary object as expected \ + and broadcasts decision with proper parameters. """ + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('test_experiment') + mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') + expected_results = { + 'cost': 10.02, + 'count': 4243, + 'environment': 'staging', + 'is_working': True, + 'object': {'test': 123}, + 'true_object': {'true_test': 1.4}, + 'variable_without_usage': 45} + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + ), mock.patch.object(opt_obj.config_manager.get_config(), 'logger') as mock_config_logging, mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision: + self.assertEqual( + expected_results, + opt_obj.get_all_feature_variables('test_feature_in_experiment', 'test_user'), + ) + + self.assertEqual(7, mock_config_logging.info.call_count) + + mock_config_logging.info.assert_has_calls( + [ + mock.call('Value for variable "count" for variation "variation" is "4243".'), + mock.call('Value for variable "is_working" for variation "variation" is "true".'), + mock.call('Variable "variable_without_usage" is not used in variation "variation". \ +Assigning default value "45".'), + mock.call('Value for variable "object" for variation "variation" is "{"test": 123}".'), + mock.call('Value for variable "true_object" for variation "variation" is "{"true_test": 1.4}".'), + mock.call('Value for variable "environment" for variation "variation" is "staging".'), + mock.call('Value for variable "cost" for variation "variation" is "10.02".') + ], any_order=True + ) + + mock_broadcast_decision.assert_called_once_with( + enums.NotificationTypes.DECISION, + 'all-feature-variables', + 'test_user', + {}, + { + 'feature_key': 'test_feature_in_experiment', + 'feature_enabled': True, + 'source': 'feature-test', + 'variable_values': {'count': 4243, 'is_working': True, 'true_object': {'true_test': 1.4}, + 'variable_without_usage': 45, 'object': {'test': 123}, 'environment': 'staging', + 'cost': 10.02}, + 'source_info': {'experiment_key': 'test_experiment', 'variation_key': 'variation'}, + }, + ) + def test_get_feature_variable(self): """ Test that get_feature_variable returns variable value as expected \ and broadcasts decision with proper parameters. """ @@ -2643,6 +2738,36 @@ def test_get_feature_variable(self): 'source_info': {'experiment_key': 'test_experiment', 'variation_key': 'variation'}, }, ) + # JSON + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + ), mock.patch.object(opt_obj.config_manager.get_config(), 'logger') as mock_config_logging, mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision: + self.assertEqual( + {"test": 123}, opt_obj.get_feature_variable('test_feature_in_experiment', 'object', 'test_user'), + ) + + mock_config_logging.info.assert_called_once_with( + 'Value for variable "object" for variation "variation" is "{"test": 123}".' + ) + + mock_broadcast_decision.assert_called_once_with( + enums.NotificationTypes.DECISION, + 'feature-variable', + 'test_user', + {}, + { + 'feature_key': 'test_feature_in_experiment', + 'feature_enabled': True, + 'source': 'feature-test', + 'variable_key': 'object', + 'variable_value': {"test": 123}, + 'variable_type': 'json', + 'source_info': {'experiment_key': 'test_experiment', 'variation_key': 'variation'}, + }, + ) def test_get_feature_variable_boolean_for_feature_in_rollout(self): """ Test that get_feature_variable_boolean returns Boolean value as expected \ @@ -2806,6 +2931,94 @@ def test_get_feature_variable_string_for_feature_in_rollout(self): }, ) + def test_get_feature_variable_json_for_feature_in_rollout(self): + """ Test that get_feature_variable_json returns dictionary object as expected + and broadcasts decision with proper parameters. """ + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('211127') + mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('211127', '211129') + user_attributes = {'test_attribute': 'test_value'} + + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + ), mock.patch.object(opt_obj.config_manager.get_config(), 'logger') as mock_config_logging, mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision: + self.assertTrue( + opt_obj.get_feature_variable_json( + 'test_feature_in_rollout', 'object', 'test_user', attributes=user_attributes, + ) + ) + + mock_config_logging.info.assert_called_once_with( + 'Value for variable "object" for variation "211129" is "{"field": 12}".' + ) + + mock_broadcast_decision.assert_called_once_with( + enums.NotificationTypes.DECISION, + 'feature-variable', + 'test_user', + {'test_attribute': 'test_value'}, + { + 'feature_key': 'test_feature_in_rollout', + 'feature_enabled': True, + 'source': 'rollout', + 'variable_key': 'object', + 'variable_value': {"field": 12}, + 'variable_type': 'json', + 'source_info': {}, + }, + ) + + def test_get_all_feature_variables_for_feature_in_rollout(self): + """ Test that get_all_feature_variables returns dictionary object as expected + and broadcasts decision with proper parameters. """ + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('211127') + mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('211127', '211129') + user_attributes = {'test_attribute': 'test_value'} + + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + ), mock.patch.object(opt_obj.config_manager.get_config(), 'logger') as mock_config_logging, mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision: + self.assertTrue( + opt_obj.get_all_feature_variables( + 'test_feature_in_rollout', 'test_user', attributes=user_attributes, + ) + ) + + self.assertEqual(5, mock_config_logging.info.call_count) + + mock_config_logging.info.assert_has_calls( + [ + mock.call('Value for variable "count" for variation "211129" is "399".'), + mock.call('Value for variable "message" for variation "211129" is "Hello audience".'), + mock.call('Value for variable "object" for variation "211129" is "{"field": 12}".'), + mock.call('Value for variable "price" for variation "211129" is "39.99".'), + mock.call('Value for variable "is_running" for variation "211129" is "true".'), + ], any_order=True + ) + mock_broadcast_decision.assert_called_once_with( + enums.NotificationTypes.DECISION, + 'all-feature-variables', + 'test_user', + {'test_attribute': 'test_value'}, + { + 'feature_key': 'test_feature_in_rollout', + 'feature_enabled': True, + 'variable_values': {'count': 399, 'message': 'Hello audience', 'object': {'field': 12}, + 'price': 39.99, 'is_running': True}, + 'source': 'rollout', + 'source_info': {}, + }, + ) + def test_get_feature_variable_for_feature_in_rollout(self): """ Test that get_feature_variable returns value as expected and broadcasts decision with proper parameters. """ @@ -2941,6 +3154,39 @@ def test_get_feature_variable_for_feature_in_rollout(self): }, ) + # JSON + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + ), mock.patch.object(opt_obj.config_manager.get_config(), 'logger') as mock_config_logging, mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision: + self.assertTrue( + opt_obj.get_feature_variable( + 'test_feature_in_rollout', 'object', 'test_user', attributes=user_attributes, + ) + ) + + mock_config_logging.info.assert_called_once_with( + 'Value for variable "object" for variation "211129" is "{"field": 12}".' + ) + + mock_broadcast_decision.assert_called_once_with( + enums.NotificationTypes.DECISION, + 'feature-variable', + 'test_user', + {'test_attribute': 'test_value'}, + { + 'feature_key': 'test_feature_in_rollout', + 'feature_enabled': True, + 'source': 'rollout', + 'variable_key': 'object', + 'variable_value': {"field": 12}, + 'variable_type': 'json', + 'source_info': {}, + }, + ) + def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_variation(self,): """ Test that get_feature_variable_* returns default value if variable usage not present in variation. """ @@ -3007,6 +3253,20 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va ) mock_config_logger.info.reset_mock() + # JSON + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + ), mock.patch.object(opt_obj.config_manager.get_config(), 'logger') as mock_config_logger: + self.assertEqual( + {"test": 12}, opt_obj.get_feature_variable_json('test_feature_in_experiment', 'object', 'test_user'), + ) + + mock_config_logger.info.assert_called_once_with( + 'Variable "object" is not used in variation "variation". Assigning default value "{"test": 12}".' + ) + mock_config_logger.info.reset_mock() + # Non-typed with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', @@ -3200,6 +3460,40 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): mock_client_logger.info.reset_mock() + # JSON + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision: + self.assertEqual( + {"test": 12}, opt_obj.get_feature_variable_json('test_feature_in_experiment', 'object', 'test_user'), + ) + + mock_client_logger.info.assert_called_once_with( + 'User "test_user" is not in any variation or rollout rule. ' + 'Returning default value for variable "object" of feature flag "test_feature_in_experiment".' + ) + + mock_broadcast_decision.assert_called_once_with( + enums.NotificationTypes.DECISION, + 'feature-variable', + 'test_user', + {}, + { + 'feature_key': 'test_feature_in_experiment', + 'feature_enabled': False, + 'source': 'rollout', + 'variable_key': 'object', + 'variable_value': {"test": 12}, + 'variable_type': 'json', + 'source_info': {}, + }, + ) + + mock_client_logger.info.reset_mock() + # Non-typed with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', @@ -3354,6 +3648,11 @@ def test_get_feature_variable__returns_none_if_none_feature_key(self): mock_client_logger.error.assert_called_with('Provided "feature_key" is in an invalid format.') mock_client_logger.reset_mock() + # Check for json + self.assertIsNone(opt_obj.get_feature_variable_json(None, 'variable_key', 'test_user')) + mock_client_logger.error.assert_called_with('Provided "feature_key" is in an invalid format.') + mock_client_logger.reset_mock() + # Check for non-typed self.assertIsNone(opt_obj.get_feature_variable(None, 'variable_key', 'test_user')) mock_client_logger.error.assert_called_with('Provided "feature_key" is in an invalid format.') @@ -3384,6 +3683,11 @@ def test_get_feature_variable__returns_none_if_none_variable_key(self): mock_client_logger.error.assert_called_with('Provided "variable_key" is in an invalid format.') mock_client_logger.reset_mock() + # Check for json + self.assertIsNone(opt_obj.get_feature_variable_json('feature_key', None, 'test-User')) + mock_client_logger.error.assert_called_with('Provided "variable_key" is in an invalid format.') + mock_client_logger.reset_mock() + # Check for non-typed self.assertIsNone(opt_obj.get_feature_variable('feature_key', None, 'test-User')) mock_client_logger.error.assert_called_with('Provided "variable_key" is in an invalid format.') @@ -3414,6 +3718,11 @@ def test_get_feature_variable__returns_none_if_none_user_id(self): mock_client_logger.error.assert_called_with('Provided "user_id" is in an invalid format.') mock_client_logger.reset_mock() + # Check for json + self.assertIsNone(opt_obj.get_feature_variable_json('feature_key', 'variable_key', None)) + mock_client_logger.error.assert_called_with('Provided "user_id" is in an invalid format.') + mock_client_logger.reset_mock() + # Check for non-typed self.assertIsNone(opt_obj.get_feature_variable('feature_key', 'variable_key', None)) mock_client_logger.error.assert_called_with('Provided "user_id" is in an invalid format.') @@ -3472,6 +3781,17 @@ def test_get_feature_variable__invalid_attributes(self): mock_validator.reset_mock() mock_client_logging.reset_mock() + # get_feature_variable_json + self.assertIsNone( + opt_obj.get_feature_variable_json( + 'test_feature_in_experiment', 'object', 'test_user', attributes='invalid', + ) + ) + mock_validator.assert_called_once_with('invalid') + mock_client_logging.error.assert_called_once_with('Provided attributes are in an invalid format.') + mock_validator.reset_mock() + mock_client_logging.reset_mock() + # get_feature_variable self.assertIsNone( opt_obj.get_feature_variable( @@ -3518,12 +3838,14 @@ def test_get_feature_variable__returns_none_if_invalid_feature_key(self): self.assertIsNone(opt_obj.get_feature_variable_double('invalid_feature', 'cost', 'test_user')) self.assertIsNone(opt_obj.get_feature_variable_integer('invalid_feature', 'count', 'test_user')) self.assertIsNone(opt_obj.get_feature_variable_string('invalid_feature', 'environment', 'test_user')) + self.assertIsNone(opt_obj.get_feature_variable_json('invalid_feature', 'object', 'test_user')) self.assertIsNone(opt_obj.get_feature_variable('invalid_feature', 'is_working', 'test_user')) self.assertIsNone(opt_obj.get_feature_variable('invalid_feature', 'cost', 'test_user')) self.assertIsNone(opt_obj.get_feature_variable('invalid_feature', 'count', 'test_user')) self.assertIsNone(opt_obj.get_feature_variable('invalid_feature', 'environment', 'test_user')) + self.assertIsNone(opt_obj.get_feature_variable('invalid_feature', 'object', 'test_user')) - self.assertEqual(8, mock_config_logger.error.call_count) + self.assertEqual(10, mock_config_logger.error.call_count) mock_config_logger.error.assert_has_calls( [ mock.call('Feature "invalid_feature" is not in datafile.'), @@ -3534,6 +3856,8 @@ def test_get_feature_variable__returns_none_if_invalid_feature_key(self): mock.call('Feature "invalid_feature" is not in datafile.'), mock.call('Feature "invalid_feature" is not in datafile.'), mock.call('Feature "invalid_feature" is not in datafile.'), + mock.call('Feature "invalid_feature" is not in datafile.'), + mock.call('Feature "invalid_feature" is not in datafile.'), ] ) @@ -3554,11 +3878,14 @@ def test_get_feature_variable__returns_none_if_invalid_variable_key(self): self.assertIsNone( opt_obj.get_feature_variable_string('test_feature_in_experiment', 'invalid_variable', 'test_user') ) + self.assertIsNone( + opt_obj.get_feature_variable_json('test_feature_in_experiment', 'invalid_variable', 'test_user') + ) self.assertIsNone( opt_obj.get_feature_variable('test_feature_in_experiment', 'invalid_variable', 'test_user') ) - self.assertEqual(5, mock_config_logger.error.call_count) + self.assertEqual(6, mock_config_logger.error.call_count) mock_config_logger.error.assert_has_calls( [ mock.call('Variable with key "invalid_variable" not found in the datafile.'), @@ -3566,6 +3893,7 @@ def test_get_feature_variable__returns_none_if_invalid_variable_key(self): mock.call('Variable with key "invalid_variable" not found in the datafile.'), mock.call('Variable with key "invalid_variable" not found in the datafile.'), mock.call('Variable with key "invalid_variable" not found in the datafile.'), + mock.call('Variable with key "invalid_variable" not found in the datafile.'), ] ) @@ -3633,6 +3961,20 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self 'Returning the default variable value "devel".' ) + # JSON + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: + self.assertEqual( + {"test": 12}, opt_obj.get_feature_variable_json('test_feature_in_experiment', 'object', 'test_user'), + ) + + mock_client_logger.info.assert_called_once_with( + 'Feature "test_feature_in_experiment" for variation "control" is not enabled. ' + 'Returning the default variable value "{"test": 12}".' + ) + # Non-typed with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', @@ -3745,6 +4087,19 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r 'Returning the default variable value "Hello".' ) + # JSON + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: + self.assertEqual( + {"field": 1}, opt_obj.get_feature_variable_json('test_feature_in_rollout', 'object', 'test_user'), + ) + mock_client_logger.info.assert_called_once_with( + 'Feature "test_feature_in_rollout" for variation "211229" is not enabled. ' + 'Returning the default variable value "{"field": 1}".' + ) + # Non-typed with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', diff --git a/tests/test_optimizely_config.py b/tests/test_optimizely_config.py index 495325ea..098b6a29 100644 --- a/tests/test_optimizely_config.py +++ b/tests/test_optimizely_config.py @@ -77,6 +77,18 @@ def setUp(self): 'id': '129', 'value': '10.99' }, + 'object': { + 'id': '132', + 'key': 'object', + 'type': 'json', + 'value': '{"test": 12}' + }, + 'true_object': { + 'id': '133', + 'key': 'true_object', + 'type': 'json', + 'value': '{"true_test": 23.54}' + }, 'variable_without_usage': { 'key': 'variable_without_usage', 'type': 'integer', @@ -114,6 +126,18 @@ def setUp(self): 'id': '129', 'value': '10.02' }, + 'object': { + 'id': '132', + 'key': 'object', + 'type': 'json', + 'value': '{"test": 123}' + }, + 'true_object': { + 'id': '133', + 'key': 'true_object', + 'type': 'json', + 'value': '{"true_test": 1.4}' + }, 'variable_without_usage': { 'key': 'variable_without_usage', 'type': 'integer', @@ -201,6 +225,18 @@ def setUp(self): 'id': '129', 'value': '10.99' }, + 'object': { + 'id': '132', + 'key': 'object', + 'type': 'json', + 'value': '{"test": 12}' + }, + 'true_object': { + 'id': '133', + 'key': 'true_object', + 'type': 'json', + 'value': '{"true_test": 23.54}' + }, 'variable_without_usage': { 'key': 'variable_without_usage', 'type': 'integer', @@ -237,6 +273,18 @@ def setUp(self): 'id': '129', 'value': '10.99' }, + 'object': { + 'id': '132', + 'key': 'object', + 'type': 'json', + 'value': '{"test": 12}' + }, + 'true_object': { + 'id': '133', + 'key': 'true_object', + 'type': 'json', + 'value': '{"true_test": 23.54}' + }, 'variable_without_usage': { 'key': 'variable_without_usage', 'type': 'integer', @@ -274,6 +322,18 @@ def setUp(self): 'id': '129', 'value': '10.02' }, + 'object': { + 'id': '132', + 'key': 'object', + 'type': 'json', + 'value': '{"test": 123}' + }, + 'true_object': { + 'id': '133', + 'key': 'true_object', + 'type': 'json', + 'value': '{"true_test": 1.4}' + }, 'variable_without_usage': { 'key': 'variable_without_usage', 'type': 'integer', @@ -318,6 +378,12 @@ def setUp(self): 'type': 'boolean', 'id': '132', 'value': 'false' + }, + 'object': { + 'id': '136', + 'key': 'object', + 'type': 'json', + 'value': '{"field": 1}' } }, 'experiments_map': {