diff --git a/optimizely/decision/__init__.py b/optimizely/decision/__init__.py new file mode 100644 index 00000000..8f0a0bce --- /dev/null +++ b/optimizely/decision/__init__.py @@ -0,0 +1,12 @@ +# 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. diff --git a/optimizely/decision/decide_option.py b/optimizely/decision/decide_option.py new file mode 100644 index 00000000..adc1eb26 --- /dev/null +++ b/optimizely/decision/decide_option.py @@ -0,0 +1,20 @@ +# 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. + + +class DecideOption(object): + DISABLE_DECISION_EVENT = 'DISABLE_DECISION_EVENT' + ENABLED_FLAGS_ONLY = 'ENABLED_FLAGS_ONLY' + IGNORE_USER_PROFILE_SERVICE = 'IGNORE_USER_PROFILE_SERVICE' + INCLUDE_REASONS = 'INCLUDE_REASONS' + EXCLUDE_VARIABLES = 'EXCLUDE_VARIABLES' diff --git a/optimizely/decision/decision.py b/optimizely/decision/decision.py new file mode 100644 index 00000000..19ecb7b0 --- /dev/null +++ b/optimizely/decision/decision.py @@ -0,0 +1,24 @@ +# 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. + + +class Decision(object): + def __init__(self, variation_key=None, enabled=None, + variables=None, rule_key=None, flag_key=None, user_context=None, reasons=None): + self.variation_key = variation_key + self.enabled = enabled or False + self.variables = variables or {} + self.rule_key = rule_key + self.flag_key = flag_key + self.user_context = user_context + self.reasons = reasons or [] diff --git a/optimizely/decision/decision_message.py b/optimizely/decision/decision_message.py new file mode 100644 index 00000000..ea3c48d3 --- /dev/null +++ b/optimizely/decision/decision_message.py @@ -0,0 +1,18 @@ +# 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. + + +class DecisionMessage(object): + SDK_NOT_READY = 'Optimizely SDK not configured properly yet.' + FLAG_KEY_INVALID = 'No flag was found for key "%s".' + VARIABLE_VALUE_INVALID = 'Variable value for key "%s" is invalid or wrong type.' diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 74bde6a2..cd91d6cd 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -20,31 +20,37 @@ from .config_manager import AuthDatafilePollingConfigManager from .config_manager import PollingConfigManager from .config_manager import StaticConfigManager +from .decision.decide_option import DecideOption +from .decision.decision import Decision +from .decision.decision_message import DecisionMessage from .error_handler import NoOpErrorHandler as noop_error_handler from .event import event_factory, user_event_factory from .event.event_processor import ForwardingEventProcessor from .event_dispatcher import EventDispatcher as default_event_dispatcher from .helpers import enums, validator +from .helpers.enums import DecisionSources from .notification_center import NotificationCenter from .optimizely_config import OptimizelyConfigService +from .user_context import UserContext class Optimizely(object): """ Class encapsulating all SDK functionality. """ def __init__( - self, - datafile=None, - event_dispatcher=None, - logger=None, - error_handler=None, - skip_json_validation=False, - user_profile_service=None, - sdk_key=None, - config_manager=None, - notification_center=None, - event_processor=None, - datafile_access_token=None, + self, + datafile=None, + event_dispatcher=None, + logger=None, + error_handler=None, + skip_json_validation=False, + user_profile_service=None, + sdk_key=None, + config_manager=None, + notification_center=None, + event_processor=None, + datafile_access_token=None, + default_decisions=None ): """ Optimizely init method for managing Custom projects. @@ -68,6 +74,7 @@ def __init__( which simply forwards events to the event dispatcher. To enable event batching configure and use optimizely.event.event_processor.BatchEventProcessor. datafile_access_token: Optional string used to fetch authenticated datafile for a secure project environment. + default_decisions: Optional list of decide options used with the decide APIs. """ self.logger_name = '.'.join([__name__, self.__class__.__name__]) self.is_valid = True @@ -79,6 +86,7 @@ def __init__( self.event_processor = event_processor or ForwardingEventProcessor( self.event_dispatcher, logger=self.logger, notification_center=self.notification_center, ) + self.default_decisions = default_decisions or [] try: self._validate_instantiation_options() @@ -192,7 +200,7 @@ def _send_impression_event(self, project_config, experiment, variation, flag_key ) def _get_feature_variable_for_type( - self, project_config, feature_key, variable_key, variable_type, user_id, attributes, + self, project_config, feature_key, variable_key, variable_type, user_id, attributes, ): """ Helper method to determine value for a certain variable attached to a feature flag based on type of variable. @@ -296,7 +304,7 @@ def _get_feature_variable_for_type( return actual_value def _get_all_feature_variables_for_type( - self, project_config, feature_key, user_id, attributes, + self, project_config, feature_key, user_id, attributes, ): """ Helper method to determine value for all variables attached to a feature flag. @@ -913,3 +921,205 @@ def get_optimizely_config(self): return self.config_manager.optimizely_config return OptimizelyConfigService(project_config).get_config() + + def create_user_context(self, user_id, attributes=None): + """ + We do not check for is_valid here as a user context can be created successfully + even when the SDK is not fully configured. + + Args: + user_id: string to use as user id for user context + attributes: dictionary of attributes or None + + Returns: + UserContext instance or None if the user id or attributes are invalid. + """ + if not isinstance(user_id, string_types): + self.logger.error(enums.Errors.INVALID_INPUT.format('user_id')) + return None + + if attributes is not None and type(attributes) is not dict: + self.logger.error(enums.Errors.INVALID_INPUT.format('attributes')) + return None + + user_context = UserContext(self, user_id, attributes) + return user_context + + def decide(self, user_context, key, decide_options=None): + """ + decide calls optimizely decide with feature key provided + Args: + user_context: UserContent with userid and attributes + key: feature key + decide_options: list of DecideOption + + Returns: + Decision object + """ + + # raising on user context as it is internal and not provided directly by the user. + if not isinstance(user_context, UserContext): + raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT.format('user_context')) + + reasons = [] + + # check if SDK is ready + if not self.is_valid: + self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('decide')) + reasons.append(DecisionMessage.SDK_NOT_READY) + return Decision(flag_key=key, user_context=user_context, reasons=reasons) + + # validate that key is a string + if not isinstance(key, string_types): + self.logger.error('Key parameter is invalid') + reasons.append(DecisionMessage.FLAG_KEY_INVALID.format(key)) + return Decision.new(flag_key=key, user_context=user_context, reasons=reasons) + + # validate that key maps to a feature flag + config = self.config_manager.get_config() + if not config: + self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('decide')) + reasons.append(DecisionMessage.SDK_NOT_READY) + return Decision(flag_key=key, user_context=user_context, reasons=reasons) + + feature_flag = None + for flag in config.feature_flags: + if flag['key'] == key: + feature_flag = flag + break + if feature_flag is None: + self.logger.error("No feature flag was found for key '#{key}'.") + reasons.push(DecisionMessage.FLAG_KEY_INVALID.format(key)) + return Decision(flag_key=key, user_context=user_context, reasons=reasons) + + # merge decide_options and default_decide_options + if isinstance(decide_options, list): + decide_options += self.default_decisions + else: + self.logger.debug('Provided decide options is not an array. Using default decide options.') + decide_options = self.default_decisions + + # Create Optimizely Decision Result. + user_id = user_context.user_id + attributes = user_context.user_attributes + variation_key = None + feature_enabled = False + rule_key = None + flag_key = key + all_variables = {} + experiment = None + decision_source = DecisionSources.ROLLOUT + source_info = {} + + decision = self.decision_service.get_variation_for_feature(config, feature_flag, user_context.user_id, + user_context.user_attributes) + # Fill in experiment and variation if returned (rollouts can have featureEnabled variables as well.) + if decision.experiment is not None: + experiment = decision.experiment + source_info["experiment"] = experiment + rule_key = experiment.key + if decision.variation is not None: + variation = decision.variation + variation_key = variation.key + feature_enabled = variation.featureEnabled + decision_source = decision.source + source_info["variation"] = variation + + # Send impression event if Decision came from a feature + # test and decide options doesn't include disableDecisionEvent + if DecideOption.DISABLE_DECISION_EVENT not in decide_options: + if decision_source == DecisionSources.FEATURE_TEST or config.send_flag_decisions: + self._send_impression_event(config, experiment, variation, flag_key, rule_key or '', + feature_enabled, decision_source, + user_id, attributes) + + # Generate all variables map if decide options doesn't include excludeVariables + if DecideOption.EXCLUDE_VARIABLES not in decide_options: + for v in feature_flag['variables']: + project_config = self.config_manager.get_config() + all_variables[v['key']] = self._get_feature_variable_for_type(project_config, feature_flag['key'], + v['key'], v['type'], user_id, attributes) + + # Send notification + self.notification_center.send_notifications( + enums.NotificationTypes.DECISION, + enums.DecisionNotificationTypes.FEATURE, + user_id, + attributes or {}, + { + 'feature_key': key, + 'feature_enabled': feature_enabled, + 'source': decision.source, + 'source_info': source_info, + }, + ) + + include_reasons = [] + if DecideOption.INCLUDE_REASONS in decide_options: + include_reasons = reasons + + return Decision(variation_key=variation_key, enabled=feature_enabled, variables=all_variables, + rule_key=rule_key, + flag_key=flag_key, user_context=user_context, reasons=include_reasons) + + def decide_all(self, user_context, decide_options=None): + """ + decide_all will return a decision for every feature key in the current config + Args: + user_context: UserContent object + decide_options: Array of DecisionOption + + Returns: + A dictionary of feature key to Decision + """ + # raising on user context as it is internal and not provided directly by the user. + if not isinstance(user_context, UserContext): + raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT.format('user_context')) + + # check if SDK is ready + if not self.is_valid: + self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('decide_all')) + return {} + + config = self.config_manager.get_config() + reasons = [] + if not config: + self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('decide')) + reasons.append(DecisionMessage.SDK_NOT_READY) + return Decision(user_context=user_context, reasons=reasons) + + keys = [] + for f in config.feature_flags: + keys.append(f['key']) + + return self.decide_for_keys(user_context, keys, decide_options) + + def decide_for_keys(self, user_context, keys, decide_options=[]): + """ + + Args: + user_context: UserContent + keys: list of feature keys to run decide on. + decide_options: an array of DecisionOption objects + + Returns: + An dictionary of feature key to Decision + """ + # raising on user context as it is internal and not provided directly by the user. + if not isinstance(user_context, UserContext): + raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT.format('user_context')) + + # check if SDK is ready + if not self.is_valid: + self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('decide_for_keys')) + return {} + + enabled_flags_only = DecideOption.ENABLED_FLAGS_ONLY in decide_options + decisions = {} + for key in keys: + decision = self.decide(user_context, key, decide_options) + if enabled_flags_only and not decision.enabled: + continue + decisions[key] = decision + + return decisions diff --git a/optimizely/user_context.py b/optimizely/user_context.py new file mode 100644 index 00000000..98b40a27 --- /dev/null +++ b/optimizely/user_context.py @@ -0,0 +1,103 @@ +# Copyright 2020, Optimizely and contributors +# +# 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. +# +from . import logger as _logging + + +class UserContext(object): + """ + Representation of an Optimizely User Context using which APIs are to be called. + """ + + def __init__(self, optimizely_client, user_id, user_attributes=None): + """ Create an instance of the Optimizely User Context. + + Args: + optimizely_client: client used when calling decisions for this user context + user_id: user id of this user context + user_attributes: user attributes to use for this user context + + Returns: + UserContext instance + """ + + self.client = optimizely_client + self.user_id = user_id + self.user_attributes = user_attributes.copy() if user_attributes else {} + + self.logger_name = '.'.join([__name__, self.__class__.__name__]) + + self.logger = _logging.reset_logger(self.logger_name) + + def set_attribute(self, attribute_key, attribute_value): + """ + sets a attribute by key for this user context. + Args: + attribute_key: key to use for attribute + attribute_value: attribute value + + Returns: + None + """ + self.user_attributes[attribute_key] = attribute_value + + def decide(self, key, options=None): + """ + Call decide on contained Optimizely object + Args: + key: feature key + options: array of DecisionOption + + Returns: + Decision object + """ + if not self.client: + self.logger.error("Optimizely Client invalid") + return None + + return self.client.decide(self, key, options) + + def decide_for_keys(self, keys, options=None): + """ + Call decide_for_keys on contained optimizely object + Args: + keys: array of feature keys + options: array of DecisionOption + + Returns: + Dictionary with feature_key keys and Decision object values + """ + if not self.client: + self.logger.error("Optimizely Client invalid") + return None + + self.client.decide_for_keys(self, keys, options) + + def decide_all(self, options=None): + """ + Call decide_all on contained optimizely instance + Args: + options: Array of DecisionOption objects + + Returns: + Dictionary with feature_key keys and Decision object values + """ + if not self.client: + self.logger.error("Optimizely Client invalid") + return None + + self.client.decide_all(self, options) + + def track_event(self, event_key, event_tags=None): + self.client.track(event_key, self.user_id, self.user_attributes, event_tags) diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index 92952556..e88c308c 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -26,6 +26,7 @@ from optimizely import optimizely_config from optimizely import project_config from optimizely import version +from optimizely.decision.decide_option import DecideOption from optimizely.event.event_factory import EventFactory from optimizely.helpers import enums from . import base @@ -673,6 +674,23 @@ def on_activate(experiment, user_id, attributes, variation, event): self.assertEqual(1, mock_process.call_count) self.assertEqual(True, access_callback[0]) + def test_decide_experiment(self): + """ Test that the feature is enabled for the user if bucketed into variation of a rollout. + Also confirm that no impression event is processed. """ + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + project_config = opt_obj.config_manager.get_config() + + mock_experiment = project_config.get_experiment_from_key('test_experiment') + mock_variation = project_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), + ): + user_context = opt_obj.create_user_context('test_user') + decision = opt_obj.decide(user_context, 'test_feature_in_experiment', [DecideOption.DISABLE_DECISION_EVENT]) + self.assertTrue(decision.enabled, "decision should be enabled") + def test_activate__with_attributes__audience_match(self): """ Test that activate calls process with right params and returns expected variation when attributes are provided and audience conditions are met. """ @@ -4965,3 +4983,13 @@ def test_get_forced_variation__invalid_user_id(self): self.assertIsNone(self.optimizely.get_forced_variation('test_experiment', 99)) mock_client_logging.error.assert_called_once_with('Provided "user_id" is in an invalid format.') + + def test_user_context_invalid_user_id(self): + """ + Tests user context. + """ + user_ids = [5, 5.5, None, True, [], {}] + + for u in user_ids: + uc = self.optimizely.create_user_context(u) + self.assertIsNone(uc, "invalid user id should return none") diff --git a/tests/test_user_context.py b/tests/test_user_context.py new file mode 100644 index 00000000..7137d09c --- /dev/null +++ b/tests/test_user_context.py @@ -0,0 +1,71 @@ +# 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 + +import mock + +from optimizely.decision.decide_option import DecideOption +from optimizely.helpers import enums +from . import base +from optimizely import logger, optimizely, decision_service +from optimizely.user_context import UserContext + + +class UserContextTests(base.BaseTest): + def setUp(self): + base.BaseTest.setUp(self, 'config_dict_with_multiple_experiments') + self.logger = logger.NoOpLogger() + + def test_user_context(self): + """ + tests user context creating and attributes + """ + uc = UserContext(self.optimizely, "test_user") + self.assertEqual(uc.user_attributes, {}, "should have created default empty") + self.assertEqual(uc.user_id, "test_user", "should have same user id") + uc.set_attribute("key", "value") + self.assertEqual(uc.user_attributes["key"], "value", "should have added attribute") + uc.set_attribute("key", "value2") + self.assertEqual(uc.user_attributes["key"], "value2", "should have new attribute") + + def test_decide_feature_test(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + project_config = opt_obj.config_manager.get_config() + + mock_experiment = project_config.get_experiment_from_key('test_experiment') + mock_variation = project_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), + ): + user_context = opt_obj.create_user_context('test_user') + decision = user_context.decide('test_feature_in_experiment', [DecideOption.DISABLE_DECISION_EVENT]) + self.assertTrue(decision.enabled, "decision should be enabled") + + def test_decide_rollout(self): + """ Test that the feature is enabled for the user if bucketed into variation of a rollout. + Also confirm that no impression event is processed. """ + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + project_config = opt_obj.config_manager.get_config() + + mock_rollout = project_config.get_experiment_from_key('test_experiment') + mock_variation = project_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_rollout, mock_variation, enums.DecisionSources.ROLLOUT), + ): + user_context = opt_obj.create_user_context('test_user') + decision = opt_obj.decide(user_context, 'test_feature_in_experiment') + self.assertTrue(decision.enabled) + self.assertEqual(decision.flag_key, 'test_feature_in_experiment')