From 35a6eac5d83677a7301cf9d4632e4ef167440c17 Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Mon, 9 Nov 2020 10:06:05 -0800 Subject: [PATCH 01/21] add user context --- optimizely/optimizely.py | 24 +++++++++++ optimizely/user_context.py | 84 ++++++++++++++++++++++++++++++++++++++ tests/test_optimizely.py | 11 +++++ tests/test_user_context.py | 35 ++++++++++++++++ 4 files changed, 154 insertions(+) create mode 100644 optimizely/user_context.py create mode 100644 tests/test_user_context.py diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 400db190..f02759fe 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -27,6 +27,7 @@ from .helpers import enums, validator from .notification_center import NotificationCenter from .optimizely_config import OptimizelyConfigService +from .user_context import UserContext class Optimizely(object): @@ -911,3 +912,26 @@ 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: + attributes: + + Returns: + + """ + 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 diff --git a/optimizely/user_context.py b/optimizely/user_context.py new file mode 100644 index 00000000..38db392d --- /dev/null +++ b/optimizely/user_context.py @@ -0,0 +1,84 @@ +# +# 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. +# + + +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 {} + + 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): + """ + TODO: call optimizely_clieint.decide + Args: + key: + options: + + Returns: + + """ + + def decide_for_keys(self, keys, options=None): + """ + TODO: call optimizely_client.decide_for_keys + Args: + keys: + options: + + Returns: + + """ + + def decide_all(self, options=None): + """ + TODO: call optimize_client.decide_all + Args: + options: + + Returns: + + """ + + def track_event(self, event_key, event_tags=None): + self.optimizely_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 4e3b9cfe..b5fca1bc 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -28,6 +28,7 @@ from optimizely import version from optimizely.event.event_factory import EventFactory from optimizely.helpers import enums +from optimizely.user_context import UserContext from . import base @@ -4957,3 +4958,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..95b57cc2 --- /dev/null +++ b/tests/test_user_context.py @@ -0,0 +1,35 @@ +# Copyright 2019, 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. + +from . import base +from optimizely import logger +from optimizely.user_context import UserContext +from optimizely.optimizely import Optimizely + + +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") From 509ff50ab7ad689f0878ccb2cd951e17e576cf03 Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Mon, 9 Nov 2020 11:09:59 -0800 Subject: [PATCH 02/21] cleanup lint --- tests/test_optimizely.py | 1 - tests/test_user_context.py | 1 - 2 files changed, 2 deletions(-) diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index b5fca1bc..a6267936 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -28,7 +28,6 @@ from optimizely import version from optimizely.event.event_factory import EventFactory from optimizely.helpers import enums -from optimizely.user_context import UserContext from . import base diff --git a/tests/test_user_context.py b/tests/test_user_context.py index 95b57cc2..d9a8b573 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -14,7 +14,6 @@ from . import base from optimizely import logger from optimizely.user_context import UserContext -from optimizely.optimizely import Optimizely class UserContextTests(base.BaseTest): From 0ef34ee875b9789c6165d94ae19a41cc240452b9 Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Mon, 9 Nov 2020 11:14:06 -0800 Subject: [PATCH 03/21] update comments --- optimizely/optimizely.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index f02759fe..70bf343d 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -919,11 +919,11 @@ def create_user_context(self, user_id, attributes=None): even when the SDK is not fully configured. Args: - user_id: - attributes: + 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')) From 61fe35614b4e0e31ccfad0ec94e6186c09b7d711 Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Wed, 25 Nov 2020 11:51:16 -0800 Subject: [PATCH 04/21] fixes from ali's comments --- optimizely/user_context.py | 1 - tests/test_user_context.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/optimizely/user_context.py b/optimizely/user_context.py index 38db392d..56984317 100644 --- a/optimizely/user_context.py +++ b/optimizely/user_context.py @@ -1,4 +1,3 @@ -# # Copyright 2020, Optimizely and contributors # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/tests/test_user_context.py b/tests/test_user_context.py index d9a8b573..713bb3db 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -1,4 +1,4 @@ -# Copyright 2019, Optimizely +# 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 From c8cabbd443e67421ae1f9122ea03c6d1d49caefd Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Wed, 25 Nov 2020 12:42:06 -0800 Subject: [PATCH 05/21] add all decide objects --- optimizely/decision/__init__.py | 12 ++++++++++++ optimizely/decision/decide_option.py | 20 ++++++++++++++++++++ optimizely/decision/decision.py | 24 ++++++++++++++++++++++++ optimizely/decision/decision_message.py | 18 ++++++++++++++++++ 4 files changed, 74 insertions(+) create mode 100644 optimizely/decision/__init__.py create mode 100644 optimizely/decision/decide_option.py create mode 100644 optimizely/decision/decision.py create mode 100644 optimizely/decision/decision_message.py 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.' From dc226c1b2cd45f2c80636413a428e2f2e66e295b Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Wed, 25 Nov 2020 16:28:50 -0800 Subject: [PATCH 06/21] add decide apis --- optimizely/optimizely.py | 168 +++++++++++++++++++++++++++++++++++---- 1 file changed, 154 insertions(+), 14 deletions(-) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 70bf343d..b8ff213f 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -20,11 +20,15 @@ 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 @@ -34,18 +38,19 @@ 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. @@ -69,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 @@ -80,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 +199,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 +303,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. @@ -935,3 +942,136 @@ def create_user_context(self, user_id, attributes=None): user_context = UserContext(self, user_id, attributes) return user_context + + def decide(self, user_context, key, decide_options=None): + # raising on user context as it is internal and not provided directly by the user. + if not isinstance(user_context, UserContext): + raise + + 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.project_config + feature_flag = config.get_feature_flag_from_key(key) + 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 = {} + decision_event_dispatched = False + experiment = None + decision_source = DecisionSources.ROLLOUT + source_info = {} + + decision = self.decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes, + decide_options, reasons) + + # Send impression event if Decision came from a feature test and decide options doesn't include disableDecisionEvent + if decision.source == enums.DecisionSources.FEATURE_TEST: + experiment = decision.experiment + rule_key = experiment['key'] + variation = decision['variation'] + variation_key = variation['key'] + feature_enabled = variation['featureEnabled'] + decision_source = decision.source + source_info["variation"] = variation + source_info["experiment"] = experiment + + 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_key or '', flag_key, rule_key or '', + feature_enabled, decision_source, + user_id, attributes) + decision_event_dispatched = True + + # 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): + # raising on user context as it is internal and not provided directly by the user. + if not isinstance(user_context, UserContext): + raise + + # check if SDK is ready + if not self.is_valid: + self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('decide_all')) + return {} + + keys = [] + for f in self.project_config: + keys.append(f['key']) + + return self.decide_for_keys(user_context, keys, decide_options) + + def decide_for_keys(self, user_context, keys, decide_options=[]): + # raising on user context as it is internal and not provided directly by the user. + if not isinstance(user_context, UserContext): + raise + + # 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 From fd99ec1c21485b9bf428ae0b5a8112dc4d77045d Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Wed, 25 Nov 2020 16:42:10 -0800 Subject: [PATCH 07/21] fix lint errors --- optimizely/optimizely.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index b8ff213f..db708c34 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -985,7 +985,6 @@ def decide(self, user_context, key, decide_options=None): rule_key = None flag_key = key all_variables = {} - decision_event_dispatched = False experiment = None decision_source = DecisionSources.ROLLOUT source_info = {} @@ -993,7 +992,8 @@ def decide(self, user_context, key, decide_options=None): decision = self.decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes, decide_options, reasons) - # Send impression event if Decision came from a feature test and decide options doesn't include disableDecisionEvent + # Send impression event if Decision came from a feature + # test and decide options doesn't include disableDecisionEvent if decision.source == enums.DecisionSources.FEATURE_TEST: experiment = decision.experiment rule_key = experiment['key'] @@ -1009,7 +1009,6 @@ def decide(self, user_context, key, decide_options=None): self._send_impression_event(config, experiment, variation_key or '', flag_key, rule_key or '', feature_enabled, decision_source, user_id, attributes) - decision_event_dispatched = True # Generate all variables map if decide options doesn't include excludeVariables if DecideOption.EXCLUDE_VARIABLES not in decide_options: From 19b7faec8c80284ad364fc14a3442677b494f461 Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Wed, 2 Dec 2020 16:15:14 -0800 Subject: [PATCH 08/21] added unit tests and cleanup decide --- optimizely/optimizely.py | 83 +++++++++++++++++++++++++++++--------- optimizely/user_context.py | 45 +++++++++++++++------ tests/test_optimizely.py | 21 ++++++++++ tests/test_user_context.py | 41 ++++++++++++++++++- 4 files changed, 157 insertions(+), 33 deletions(-) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index aac0e7df..d140f92f 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -946,9 +946,20 @@ def create_user_context(self, user_id, attributes=None): 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 + raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT.format('user_context')) reasons = [] @@ -965,9 +976,16 @@ def decide(self, user_context, key, decide_options=None): return Decision.new(flag_key=key, user_context=user_context, reasons=reasons) # validate that key maps to a feature flag - config = self.project_config - feature_flag = config.get_feature_flag_from_key(key) - if feature_flag is None: + 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) + + result = filter(lambda x: x['key'] == key, config.feature_flags) + if len(result) > 0: + feature_flag = result[0] + else: 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) @@ -991,24 +1009,25 @@ def decide(self, user_context, key, decide_options=None): decision_source = DecisionSources.ROLLOUT source_info = {} - decision = self.decision_service.get_variation_for_feature(config, feature_flag, user_id, attributes, - decide_options, reasons) - - # Send impression event if Decision came from a feature - # test and decide options doesn't include disableDecisionEvent - if decision.source == enums.DecisionSources.FEATURE_TEST: + 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 - rule_key = experiment['key'] - variation = decision['variation'] - variation_key = variation['key'] - feature_enabled = variation['featureEnabled'] + 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 - source_info["experiment"] = experiment + # 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_key or '', flag_key, rule_key or '', + self._send_impression_event(config, experiment, variation, flag_key, rule_key or '', feature_enabled, decision_source, user_id, attributes) @@ -1042,25 +1061,51 @@ def decide(self, user_context, key, decide_options=None): 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 + 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 self.project_config: + 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 + raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT.format('user_context')) # check if SDK is ready if not self.is_valid: diff --git a/optimizely/user_context.py b/optimizely/user_context.py index 56984317..ca391495 100644 --- a/optimizely/user_context.py +++ b/optimizely/user_context.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # - +from . import logger as _logging class UserContext(object): """ @@ -35,6 +35,10 @@ def __init__(self, optimizely_client, user_id, user_attributes=None): 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. @@ -49,35 +53,50 @@ def set_attribute(self, attribute_key, attribute_value): def decide(self, key, options=None): """ - TODO: call optimizely_clieint.decide + Call decide on contained Optimizely object Args: - key: - options: + 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): """ - TODO: call optimizely_client.decide_for_keys + Call decide_for_keys on contained optimizely object Args: - keys: - options: + 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): """ - TODO: call optimize_client.decide_all + Call decide_all on contained optimizely instance Args: - options: + 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.optimizely_client.track(event_key, self.user_id, self.user_attributes, event_tags) + 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 90062f50..8e97748f 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,26 @@ 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() + feature = project_config.get_feature_from_key('test_feature_in_experiment') + + 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), + ) as mock_decision, mock.patch( + 'optimizely.event.event_processor.ForwardingEventProcessor.process' + ) as mock_process: + 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. """ diff --git a/tests/test_user_context.py b/tests/test_user_context.py index 713bb3db..31e2070b 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -10,9 +10,14 @@ # 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 +from optimizely import logger, optimizely, decision_service from optimizely.user_context import UserContext @@ -32,3 +37,37 @@ def test_user_context(self): 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() + feature = project_config.get_feature_from_key('test_feature_in_experiment') + + 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') + From ffa40d6c08cee392ba8f6fc95d5dcaee180953bc Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Wed, 2 Dec 2020 16:22:26 -0800 Subject: [PATCH 09/21] cleanup lint errors --- tests/test_optimizely.py | 5 +---- tests/test_user_context.py | 2 -- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index 8e97748f..e88c308c 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -680,16 +680,13 @@ def test_decide_experiment(self): opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) project_config = opt_obj.config_manager.get_config() - feature = project_config.get_feature_from_key('test_feature_in_experiment') 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), - ) as mock_decision, mock.patch( - 'optimizely.event.event_processor.ForwardingEventProcessor.process' - ) as mock_process: + ): 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") diff --git a/tests/test_user_context.py b/tests/test_user_context.py index 31e2070b..7137d09c 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -41,7 +41,6 @@ def test_user_context(self): 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() - feature = project_config.get_feature_from_key('test_feature_in_experiment') mock_experiment = project_config.get_experiment_from_key('test_experiment') mock_variation = project_config.get_variation_from_id('test_experiment', '111129') @@ -70,4 +69,3 @@ def test_decide_rollout(self): decision = opt_obj.decide(user_context, 'test_feature_in_experiment') self.assertTrue(decision.enabled) self.assertEqual(decision.flag_key, 'test_feature_in_experiment') - From 13ec677c5ae04054c23d3fc5d6b1b11f283903ae Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Wed, 2 Dec 2020 16:26:14 -0800 Subject: [PATCH 10/21] fix lint --- optimizely/user_context.py | 1 + 1 file changed, 1 insertion(+) diff --git a/optimizely/user_context.py b/optimizely/user_context.py index ca391495..98b40a27 100644 --- a/optimizely/user_context.py +++ b/optimizely/user_context.py @@ -14,6 +14,7 @@ # from . import logger as _logging + class UserContext(object): """ Representation of an Optimizely User Context using which APIs are to be called. From 5feca2cf8ee865dc967ec3759ae49621b2edf5b5 Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Wed, 2 Dec 2020 16:43:17 -0800 Subject: [PATCH 11/21] remove reduce to work with python 3.x --- optimizely/optimizely.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index d140f92f..cd91d6cd 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -982,10 +982,12 @@ def decide(self, user_context, key, decide_options=None): reasons.append(DecisionMessage.SDK_NOT_READY) return Decision(flag_key=key, user_context=user_context, reasons=reasons) - result = filter(lambda x: x['key'] == key, config.feature_flags) - if len(result) > 0: - feature_flag = result[0] - else: + 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) From 4c4b4a4a69facbfdc57f71a5fd7cac01b62e6626 Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Thu, 3 Dec 2020 11:05:26 -0800 Subject: [PATCH 12/21] add more unit tests for user context --- optimizely/optimizely.py | 25 ++++++++--------- tests/test_user_context.py | 55 ++++++++++++++++++++++++++++++-------- 2 files changed, 57 insertions(+), 23 deletions(-) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index cd91d6cd..014f1fc8 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -982,14 +982,10 @@ def decide(self, user_context, key, decide_options=None): 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 + feature_flag = config.get_feature_from_key(key) if feature_flag is None: self.logger.error("No feature flag was found for key '#{key}'.") - reasons.push(DecisionMessage.FLAG_KEY_INVALID.format(key)) + reasons.append(DecisionMessage.FLAG_KEY_INVALID.format(key)) return Decision(flag_key=key, user_context=user_context, reasons=reasons) # merge decide_options and default_decide_options @@ -1003,6 +999,7 @@ def decide(self, user_context, key, decide_options=None): user_id = user_context.user_id attributes = user_context.user_attributes variation_key = None + variation = None feature_enabled = False rule_key = None flag_key = key @@ -1035,10 +1032,11 @@ def decide(self, user_context, key, decide_options=None): # 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) + project_config = self.config_manager.get_config() + for key in feature_flag.variables: + v = feature_flag.variables[key] + 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( @@ -1094,7 +1092,7 @@ def decide_all(self, user_context, decide_options=None): return self.decide_for_keys(user_context, keys, decide_options) - def decide_for_keys(self, user_context, keys, decide_options=[]): + def decide_for_keys(self, user_context, keys, decide_options=None): """ Args: @@ -1114,7 +1112,10 @@ def decide_for_keys(self, user_context, keys, decide_options=[]): self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('decide_for_keys')) return {} - enabled_flags_only = DecideOption.ENABLED_FLAGS_ONLY in decide_options + enabled_flags_only = False + if decide_options is not None: + enabled_flags_only = DecideOption.ENABLED_FLAGS_ONLY in decide_options + decisions = {} for key in keys: decision = self.decide(user_context, key, decide_options) diff --git a/tests/test_user_context.py b/tests/test_user_context.py index 7137d09c..fd3aff00 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -57,15 +57,48 @@ def test_decide_rollout(self): 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') + user_context = opt_obj.create_user_context('test_user') + decision = opt_obj.decide(user_context, 'test_feature_in_rollout') + self.assertFalse(decision.enabled) + self.assertEqual(decision.flag_key, 'test_feature_in_rollout') + + def test_decide_for_keys(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)) + + user_context = opt_obj.create_user_context('test_user') + decisions = opt_obj.decide_for_keys(user_context, ['test_feature_in_rollout', 'test_feature_in_experiment']) + self.assertTrue(len(decisions) == 2) + + self.assertFalse(decisions['test_feature_in_rollout'].enabled) + self.assertEqual(decisions['test_feature_in_rollout'].flag_key, 'test_feature_in_rollout') + + self.assertFalse(decisions['test_feature_in_experiment'].enabled) + self.assertEqual(decisions['test_feature_in_experiment'].flag_key, 'test_feature_in_experiment') + + def test_decide_all(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)) + + user_context = opt_obj.create_user_context('test_user') + decisions = opt_obj.decide_all(user_context) + self.assertTrue(len(decisions) == 4) + + self.assertFalse(decisions['test_feature_in_rollout'].enabled) + self.assertEqual(decisions['test_feature_in_rollout'].flag_key, 'test_feature_in_rollout') + + self.assertFalse(decisions['test_feature_in_experiment'].enabled) + self.assertEqual(decisions['test_feature_in_experiment'].flag_key, 'test_feature_in_experiment') + + self.assertFalse(decisions['test_feature_in_group'].enabled) + self.assertEqual(decisions['test_feature_in_group'].flag_key, 'test_feature_in_group') + + self.assertFalse(decisions['test_feature_in_experiment_and_rollout'].enabled) + self.assertEqual(decisions['test_feature_in_experiment_and_rollout'].flag_key, + 'test_feature_in_experiment_and_rollout') + From 460c904ba1931535b4711fe5befc3c91bd4971e9 Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Thu, 3 Dec 2020 13:36:10 -0800 Subject: [PATCH 13/21] another test --- tests/test_user_context.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_user_context.py b/tests/test_user_context.py index fd3aff00..a627d3b8 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -15,6 +15,7 @@ import mock from optimizely.decision.decide_option import DecideOption +from optimizely.event.event_factory import EventFactory from optimizely.helpers import enums from . import base from optimizely import logger, optimizely, decision_service @@ -102,3 +103,31 @@ def test_decide_all(self): self.assertEqual(decisions['test_feature_in_experiment_and_rollout'].flag_key, 'test_feature_in_experiment_and_rollout') + def test_decide_all_enabled_only(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)) + + user_context = opt_obj.create_user_context('test_user') + decisions = opt_obj.decide_all(user_context, [DecideOption.ENABLED_FLAGS_ONLY]) + self.assertTrue(len(decisions) == 0) + + def test_track(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)) + + with mock.patch('time.time', return_value=42), mock.patch( + 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' + ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process: + user_context = opt_obj.create_user_context('test_user') + user_context.track_event('test_event') + + log_event = EventFactory.create_log_event(mock_process.call_args[0][0], opt_obj.logger) + self.assertEqual(log_event.params['visitors'][0]['visitor_id'], 'test_user') + self.assertEqual(log_event.params['visitors'][0]['snapshots'][0]['events'][0]['timestamp'], 42000) + self.assertEqual(log_event.params['visitors'][0]['snapshots'][0]['events'][0]['uuid'], + 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c') + self.assertEqual(log_event.params['visitors'][0]['snapshots'][0]['events'][0]['key'], 'test_event') From 0d4ec50853af557a0d4b444435c628318c5d73a4 Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Thu, 3 Dec 2020 14:54:53 -0800 Subject: [PATCH 14/21] try and fix travis pypy tests --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index ce7e0e51..e911f495 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,9 @@ python: # - "3.8" is handled in 'Test' job using xenial as Python 3.8 is not available for trusty. - "pypy" - "pypy3" +before_install: + - apt-get --auto-remove --yes remove python-openssl + - pip install pyOpenSSL install: "pip install -r requirements/core.txt;pip install -r requirements/test.txt" script: "pytest --cov=optimizely" after_success: From 8b5d56dceedd36d5ab3f810f0a9a4d510c93723c Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Thu, 3 Dec 2020 14:58:28 -0800 Subject: [PATCH 15/21] attempt to fix pypy on travis --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index e911f495..b67a4134 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,7 +9,7 @@ python: - "pypy" - "pypy3" before_install: - - apt-get --auto-remove --yes remove python-openssl + - sudo apt-get --auto-remove --yes remove python-openssl - pip install pyOpenSSL install: "pip install -r requirements/core.txt;pip install -r requirements/test.txt" script: "pytest --cov=optimizely" From a1cbc68dd3c8fc5eec4bf812b2a3a9615ddba1a7 Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Thu, 3 Dec 2020 15:22:37 -0800 Subject: [PATCH 16/21] fix travis pypy --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index b67a4134..d1d42066 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,7 +10,7 @@ python: - "pypy3" before_install: - sudo apt-get --auto-remove --yes remove python-openssl - - pip install pyOpenSSL + - sudo pip install pyOpenSSL install: "pip install -r requirements/core.txt;pip install -r requirements/test.txt" script: "pytest --cov=optimizely" after_success: From 67fc4f2e5b78714144780436c4f4f46d9c9e012c Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Tue, 8 Dec 2020 14:56:54 -0800 Subject: [PATCH 17/21] add more tests --- optimizely/decision_service.py | 8 +- optimizely/optimizely.py | 19 ++-- tests/test_user_context.py | 154 +++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 8 deletions(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index 56764d7b..1b767e11 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -301,6 +301,7 @@ def get_variation_for_rollout(self, project_config, rollout, user_id, attributes rollout: Rollout for which we are getting the variation. user_id: ID for user. attributes: Dict representing user attributes. + ignore_user_profile: True if we should bypass the user profile service Returns: Decision namedtuple consisting of experiment and variation for the user. @@ -390,7 +391,7 @@ def get_experiment_in_group(self, project_config, group, bucketing_id): return None - def get_variation_for_feature(self, project_config, feature, user_id, attributes=None): + def get_variation_for_feature(self, project_config, feature, user_id, attributes=None, ignore_user_profile=False): """ Returns the experiment/variation the user is bucketed in for the given feature. Args: @@ -398,6 +399,7 @@ def get_variation_for_feature(self, project_config, feature, user_id, attributes feature: Feature for which we are determining if it is enabled or not for the given user. user_id: ID for user. attributes: Dict representing user attributes. + ignore_user_profile: True if you want to bypass the user profile service Returns: Decision namedtuple consisting of experiment and variation for the user. @@ -411,7 +413,7 @@ def get_variation_for_feature(self, project_config, feature, user_id, attributes if group: experiment = self.get_experiment_in_group(project_config, group, bucketing_id) if experiment and experiment.id in feature.experimentIds: - variation = self.get_variation(project_config, experiment, user_id, attributes) + variation = self.get_variation(project_config, experiment, user_id, attributes, ignore_user_profile) if variation: return Decision(experiment, variation, enums.DecisionSources.FEATURE_TEST) @@ -423,7 +425,7 @@ def get_variation_for_feature(self, project_config, feature, user_id, attributes # If an experiment is not in a group, then the feature can only be associated with one experiment experiment = project_config.get_experiment_from_id(feature.experimentIds[0]) if experiment: - variation = self.get_variation(project_config, experiment, user_id, attributes) + variation = self.get_variation(project_config, experiment, user_id, attributes, ignore_user_profile) if variation: return Decision(experiment, variation, enums.DecisionSources.FEATURE_TEST) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 014f1fc8..637f18a3 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -201,6 +201,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, + ignore_user_profile=False ): """ Helper method to determine value for a certain variable attached to a feature flag based on type of variable. @@ -211,6 +212,7 @@ def _get_feature_variable_for_type( variable_type: Type of variable which could be one of boolean/double/integer/string. user_id: ID for user. attributes: Dict representing user attributes. + ignore_user_profile: if true don't use the user profile service Returns: Value of the variable. None if: @@ -253,7 +255,8 @@ def _get_feature_variable_for_type( feature_enabled = False source_info = {} variable_value = variable.defaultValue - decision = self.decision_service.get_variation_for_feature(project_config, feature_flag, user_id, attributes) + decision = self.decision_service.get_variation_for_feature(project_config, feature_flag, user_id, + attributes, ignore_user_profile) if decision.variation: feature_enabled = decision.variation.featureEnabled @@ -1009,7 +1012,10 @@ def decide(self, user_context, key, decide_options=None): source_info = {} decision = self.decision_service.get_variation_for_feature(config, feature_flag, user_context.user_id, - user_context.user_attributes) + user_context.user_attributes, + DecideOption.IGNORE_USER_PROFILE_SERVICE in + decide_options) + # Fill in experiment and variation if returned (rollouts can have featureEnabled variables as well.) if decision.experiment is not None: experiment = decision.experiment @@ -1033,10 +1039,13 @@ def decide(self, user_context, key, decide_options=None): # Generate all variables map if decide options doesn't include excludeVariables if DecideOption.EXCLUDE_VARIABLES not in decide_options: project_config = self.config_manager.get_config() - for key in feature_flag.variables: - v = feature_flag.variables[key] + for v_key in feature_flag.variables: + v = feature_flag.variables[v_key] all_variables[v.key] = self._get_feature_variable_for_type(project_config, feature_flag.key, - v.key, v.type, user_id, attributes) + v.key, v.type, user_id, attributes, + DecideOption.IGNORE_USER_PROFILE_SERVICE in + decide_options + ) # Send notification self.notification_center.send_notifications( diff --git a/tests/test_user_context.py b/tests/test_user_context.py index a627d3b8..650208be 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -17,6 +17,7 @@ from optimizely.decision.decide_option import DecideOption from optimizely.event.event_factory import EventFactory from optimizely.helpers import enums +from optimizely.user_profile import UserProfileService, UserProfile from . import base from optimizely import logger, optimizely, decision_service from optimizely.user_context import UserContext @@ -131,3 +132,156 @@ def test_track(self): self.assertEqual(log_event.params['visitors'][0]['snapshots'][0]['events'][0]['uuid'], 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c') self.assertEqual(log_event.params['visitors'][0]['snapshots'][0]['events'][0]['key'], 'test_event') + + def test_decide_sendEvent(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') + + # Assert that featureEnabled property is True + self.assertTrue(mock_variation.featureEnabled) + + 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( + 'optimizely.event.event_processor.ForwardingEventProcessor.process' + ) as mock_process, mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' + ), mock.patch( + 'time.time', return_value=42 + ): + context = opt_obj.create_user_context('test_user') + decision = context.decide('test_feature_in_experiment') + self.assertTrue(decision.enabled) + + mock_broadcast_decision.assert_called_with( + enums.NotificationTypes.DECISION, + 'feature', + 'test_user', + {}, + { + 'feature_key': 'test_feature_in_experiment', + 'feature_enabled': True, + 'source': 'rollout', + 'source_info': { + 'experiment': mock_experiment, + 'variation': mock_variation, + }, + }, + ) + + # Check that impression event is sent for rollout and send_flag_decisions = True + self.assertEqual(1, mock_process.call_count) + + def test_decide_doNotSendEvent_withOption(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') + + # Assert that featureEnabled property is True + self.assertTrue(mock_variation.featureEnabled) + + 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( + 'optimizely.event.event_processor.ForwardingEventProcessor.process' + ) as mock_process, mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' + ), mock.patch( + 'time.time', return_value=42 + ): + context = opt_obj.create_user_context('test_user') + decision = context.decide('test_feature_in_experiment', [DecideOption.DISABLE_DECISION_EVENT]) + self.assertTrue(decision.enabled) + + mock_broadcast_decision.assert_called_with( + enums.NotificationTypes.DECISION, + 'feature', + 'test_user', + {}, + { + 'feature_key': 'test_feature_in_experiment', + 'feature_enabled': True, + 'source': 'rollout', + 'source_info': { + 'experiment': mock_experiment, + 'variation': mock_variation, + }, + }, + ) + + # Check that impression event is NOT sent for rollout and send_flag_decisions = True + # with disable decision event decision option + self.assertEqual(0, mock_process.call_count) + + def test_decide_options_bypass_UPS(self): + user_id = 'test_user' + experiment_bucket_map = {'111127': {'variation_id': '111128'}} + + profile = UserProfile(user_id, experiment_bucket_map=experiment_bucket_map) + + class Ups(UserProfileService): + + def lookup(self, user_id): + return profile + + def save(self, user_profile): + super(Ups, self).save(user_profile) + + ups = Ups() + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features), user_profile_service=ups) + 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') + + # Assert that featureEnabled property is True + self.assertTrue(mock_variation.featureEnabled) + + with mock.patch( + 'optimizely.bucketer.Bucketer.bucket', + return_value=mock_variation, + ), mock.patch( + 'optimizely.event.event_processor.ForwardingEventProcessor.process' + ) as mock_process, mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' + ), mock.patch( + 'time.time', return_value=42 + ): + context = opt_obj.create_user_context(user_id) + decision = context.decide('test_feature_in_experiment', [DecideOption.DISABLE_DECISION_EVENT, + DecideOption.IGNORE_USER_PROFILE_SERVICE, + DecideOption.EXCLUDE_VARIABLES]) + self.assertTrue(decision.enabled) + + mock_broadcast_decision.assert_called_with( + enums.NotificationTypes.DECISION, + 'feature', + 'test_user', + {}, + { + 'feature_key': 'test_feature_in_experiment', + 'feature_enabled': True, + 'source': 'feature-test', + 'source_info': { + 'experiment': mock_experiment, + 'variation': mock_variation, + }, + }, + ) + + # Check that impression event is NOT sent for rollout and send_flag_decisions = True + # with disable decision event decision option + self.assertEqual(0, mock_process.call_count) From f3e3854327df163214ffde65c34583343d290c7e Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Tue, 8 Dec 2020 18:21:18 -0800 Subject: [PATCH 18/21] finish python with reasons --- optimizely/optimizely.py | 34 ++++++++++++++++++ tests/test_user_context.py | 71 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 637f18a3..f82a180b 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -10,6 +10,9 @@ # 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 logging +import threading + from six import string_types from . import decision_service @@ -998,6 +1001,33 @@ def decide(self, user_context, key, decide_options=None): self.logger.debug('Provided decide options is not an array. Using default decide options.') decide_options = self.default_decisions + class ReasonLogHandler(logging.StreamHandler): + def __init__(self): + super(ReasonLogHandler, self).__init__() + self._name = "ReasonLogHandler" + self.reasons = {threading.current_thread().ident: []} + self.level = logging.INFO + formatter = logging.Formatter('%(levelname)-8s %(asctime)s %(filename)s:%(lineno)s:%(message)s') + self.setFormatter(formatter) + self.createLock() + + def handle(self, record): + msg = self.format(record) + self.reasons[threading.current_thread().ident].append(msg) + + def emit(self, record): + pass + + def get_reasons(self): + return self.reasons[threading.current_thread().ident] + + handler = None + + if DecideOption.INCLUDE_REASONS in decide_options: + handler = ReasonLogHandler() + self.decision_service.logger.addHandler(handler) + config.logger.addHandler(handler) + # Create Optimizely Decision Result. user_id = user_context.user_id attributes = user_context.user_attributes @@ -1063,7 +1093,11 @@ def decide(self, user_context, key, decide_options=None): include_reasons = [] if DecideOption.INCLUDE_REASONS in decide_options: + handler.flush() include_reasons = reasons + include_reasons += handler.get_reasons() + self.decision_service.logger.removeHandler(handler) + config.logger.removeHandler(handler) return Decision(variation_key=variation_key, enabled=feature_enabled, variables=all_variables, rule_key=rule_key, diff --git a/tests/test_user_context.py b/tests/test_user_context.py index 650208be..c881c23c 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -11,6 +11,7 @@ # See the License for the specific language governing permissions and # limitations under the License. import json +import logging import mock @@ -285,3 +286,73 @@ def save(self, user_profile): # Check that impression event is NOT sent for rollout and send_flag_decisions = True # with disable decision event decision option self.assertEqual(0, mock_process.call_count) + + def test_decide_options_reasons(self): + user_id = 'test_user' + experiment_bucket_map = {'111127': {'variation_id': '111128'}} + + profile = UserProfile(user_id, experiment_bucket_map=experiment_bucket_map) + + class Ups(UserProfileService): + + def lookup(self, user_id): + return profile + + def save(self, user_profile): + super(Ups, self).save(user_profile) + + ups = Ups() + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features), + logger=logger.SimpleLogger(min_level=logging.INFO), + user_profile_service=ups) + 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') + + # Assert that featureEnabled property is True + self.assertTrue(mock_variation.featureEnabled) + + with mock.patch( + 'optimizely.bucketer.Bucketer.bucket', + return_value=mock_variation, + ), mock.patch( + 'optimizely.event.event_processor.ForwardingEventProcessor.process' + ) as mock_process, mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' + ), mock.patch( + 'time.time', return_value=42 + ): + context = opt_obj.create_user_context(user_id) + decision = context.decide('test_feature_in_experiment', [DecideOption.DISABLE_DECISION_EVENT, + DecideOption.IGNORE_USER_PROFILE_SERVICE, + DecideOption.EXCLUDE_VARIABLES, + DecideOption.INCLUDE_REASONS]) + self.assertTrue(decision.enabled) + + mock_broadcast_decision.assert_called_with( + enums.NotificationTypes.DECISION, + 'feature', + 'test_user', + {}, + { + 'feature_key': 'test_feature_in_experiment', + 'feature_enabled': True, + 'source': 'feature-test', + 'source_info': { + 'experiment': mock_experiment, + 'variation': mock_variation, + }, + }, + ) + + self.assertIsNotNone(decision.reasons) + self.assertTrue(decision.reasons[0].find( + 'Audiences for experiment "test_experiment" collectively evaluated to TRUE.') is not -1) + self.assertTrue(decision.reasons[1].find( + 'User "test_user" is in variation "variation" of experiment test_experiment.') is not -1) + # Check that impression event is NOT sent for rollout and send_flag_decisions = True + # with disable decision event decision option + self.assertEqual(0, mock_process.call_count) From c38dbfc2a30d1b93e5942a776e1dff4e53f0fa54 Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Wed, 9 Dec 2020 10:19:21 -0800 Subject: [PATCH 19/21] make sure we are at the lowest level DEBUG --- optimizely/optimizely.py | 2 +- tests/test_user_context.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index f82a180b..e92aa942 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -1006,7 +1006,7 @@ def __init__(self): super(ReasonLogHandler, self).__init__() self._name = "ReasonLogHandler" self.reasons = {threading.current_thread().ident: []} - self.level = logging.INFO + self.level = logging.DEBUG formatter = logging.Formatter('%(levelname)-8s %(asctime)s %(filename)s:%(lineno)s:%(message)s') self.setFormatter(formatter) self.createLock() diff --git a/tests/test_user_context.py b/tests/test_user_context.py index c881c23c..55d2812c 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -303,7 +303,7 @@ def save(self, user_profile): ups = Ups() opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features), - logger=logger.SimpleLogger(min_level=logging.INFO), + logger=logger.SimpleLogger(min_level=logging.DEBUG), user_profile_service=ups) project_config = opt_obj.config_manager.get_config() @@ -349,9 +349,9 @@ def save(self, user_profile): ) self.assertIsNotNone(decision.reasons) - self.assertTrue(decision.reasons[0].find( + self.assertTrue(decision.reasons[2].find( 'Audiences for experiment "test_experiment" collectively evaluated to TRUE.') is not -1) - self.assertTrue(decision.reasons[1].find( + self.assertTrue(decision.reasons[3].find( 'User "test_user" is in variation "variation" of experiment test_experiment.') is not -1) # Check that impression event is NOT sent for rollout and send_flag_decisions = True # with disable decision event decision option From 2c1f8b08a6fcd1954ecc9fc2898957855ccab06a Mon Sep 17 00:00:00 2001 From: Tom Zurkan Date: Thu, 10 Dec 2020 10:17:36 -0800 Subject: [PATCH 20/21] only capture info messages and up --- optimizely/optimizely.py | 3 ++- tests/test_user_context.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index e92aa942..6193fc2b 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -1006,7 +1006,8 @@ def __init__(self): super(ReasonLogHandler, self).__init__() self._name = "ReasonLogHandler" self.reasons = {threading.current_thread().ident: []} - self.level = logging.DEBUG + # setting to info level since we don't put debug in reasons. + self.level = logging.INFO formatter = logging.Formatter('%(levelname)-8s %(asctime)s %(filename)s:%(lineno)s:%(message)s') self.setFormatter(formatter) self.createLock() diff --git a/tests/test_user_context.py b/tests/test_user_context.py index 55d2812c..4312b1cb 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -349,9 +349,9 @@ def save(self, user_profile): ) self.assertIsNotNone(decision.reasons) - self.assertTrue(decision.reasons[2].find( + self.assertTrue(decision.reasons[0].find( 'Audiences for experiment "test_experiment" collectively evaluated to TRUE.') is not -1) - self.assertTrue(decision.reasons[3].find( + self.assertTrue(decision.reasons[1].find( 'User "test_user" is in variation "variation" of experiment test_experiment.') is not -1) # Check that impression event is NOT sent for rollout and send_flag_decisions = True # with disable decision event decision option From 95174421e531ba2f5bcccbd33ecb1842b03fadd7 Mon Sep 17 00:00:00 2001 From: Owais Akbani Date: Mon, 1 Feb 2021 22:58:01 +0500 Subject: [PATCH 21/21] refact: Decide API (#314) * WIP * WIP * fix: Passes All FSC * fix: unit tests and cleanup * refact: rename decide classes * fix: merge default decide options in decide for keys * prefix decide methods with _ * fix: decide option import * mutex locks * Apply suggestions from code review Co-authored-by: Jae Kim <45045038+jaeopt@users.noreply.github.com> * tests: user context tests * tests: WIP * feat: reasons work * tests: refact * tests: Add unit tests * remove reasons from find_bucket * address comments * tests: decide * fix: import * tests: Add reasons tests Co-authored-by: Jae Kim <45045038+jaeopt@users.noreply.github.com> --- .travis.yml | 3 - optimizely/bucketer.py | 48 +- optimizely/decision/__init__.py | 2 +- ..._option.py => optimizely_decide_option.py} | 4 +- .../{decision.py => optimizely_decision.py} | 15 +- ...sage.py => optimizely_decision_message.py} | 8 +- optimizely/decision_service.py | 258 ++-- optimizely/entities.py | 8 +- optimizely/helpers/audience.py | 29 +- optimizely/helpers/enums.py | 5 +- optimizely/optimizely.py | 215 ++- ..._context.py => optimizely_user_context.py} | 53 +- tests/base.py | 2 +- tests/helpers_tests/test_audience.py | 109 +- tests/test_bucketing.py | 241 +-- tests/test_decision_service.py | 464 +++--- tests/test_optimizely.py | 260 ++-- tests/test_user_context.py | 1319 ++++++++++++++--- 18 files changed, 2085 insertions(+), 958 deletions(-) rename optimizely/decision/{decide_option.py => optimizely_decide_option.py} (92%) rename optimizely/decision/{decision.py => optimizely_decision.py} (70%) rename optimizely/decision/{decision_message.py => optimizely_decision_message.py} (76%) rename optimizely/{user_context.py => optimizely_user_context.py} (64%) diff --git a/.travis.yml b/.travis.yml index d1d42066..ce7e0e51 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,9 +8,6 @@ python: # - "3.8" is handled in 'Test' job using xenial as Python 3.8 is not available for trusty. - "pypy" - "pypy3" -before_install: - - sudo apt-get --auto-remove --yes remove python-openssl - - sudo pip install pyOpenSSL install: "pip install -r requirements/core.txt;pip install -r requirements/test.txt" script: "pytest --cov=optimizely" after_success: diff --git a/optimizely/bucketer.py b/optimizely/bucketer.py index 940a9549..ca5e0f28 100644 --- a/optimizely/bucketer.py +++ b/optimizely/bucketer.py @@ -1,4 +1,4 @@ -# Copyright 2016-2017, 2019-2020 Optimizely +# Copyright 2016-2017, 2019-2021 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 @@ -71,13 +71,13 @@ def find_bucket(self, project_config, bucketing_id, parent_id, traffic_allocatio traffic_allocations: Traffic allocations representing traffic allotted to experiments or variations. Returns: - Entity ID which may represent experiment or variation. + Entity ID which may represent experiment or variation and """ - bucketing_key = BUCKETING_ID_TEMPLATE.format(bucketing_id=bucketing_id, parent_id=parent_id) bucketing_number = self._generate_bucket_value(bucketing_key) + message = 'Assigned bucket %s to user with bucketing ID "%s".' % (bucketing_number, bucketing_id) project_config.logger.debug( - 'Assigned bucket %s to user with bucketing ID "%s".' % (bucketing_number, bucketing_id) + message ) for traffic_allocation in traffic_allocations: @@ -97,11 +97,13 @@ def bucket(self, project_config, experiment, user_id, bucketing_id): bucketing_id: ID to be used for bucketing the user. Returns: - Variation in which user with ID user_id will be put in. None if no variation. + Variation in which user with ID user_id will be put in. None if no variation + and array of log messages representing decision making. + */. """ - + decide_reasons = [] if not experiment: - return None + return None, decide_reasons # Determine if experiment is in a mutually exclusive group. # This will not affect evaluation of rollout rules. @@ -109,29 +111,43 @@ def bucket(self, project_config, experiment, user_id, bucketing_id): group = project_config.get_group(experiment.groupId) if not group: - return None + return None, decide_reasons user_experiment_id = self.find_bucket( project_config, bucketing_id, experiment.groupId, group.trafficAllocation, ) + if not user_experiment_id: - project_config.logger.info('User "%s" is in no experiment.' % user_id) - return None + message = 'User "%s" is in no experiment.' % user_id + project_config.logger.info(message) + decide_reasons.append(message) + return None, decide_reasons if user_experiment_id != experiment.id: + message = 'User "%s" is not in experiment "%s" of group %s.' \ + % (user_id, experiment.key, experiment.groupId) project_config.logger.info( - 'User "%s" is not in experiment "%s" of group %s.' % (user_id, experiment.key, experiment.groupId) + message ) - return None + decide_reasons.append(message) + return None, decide_reasons + message = 'User "%s" is in experiment %s of group %s.' % (user_id, experiment.key, experiment.groupId) project_config.logger.info( - 'User "%s" is in experiment %s of group %s.' % (user_id, experiment.key, experiment.groupId) + message ) + decide_reasons.append(message) # Bucket user if not in white-list and in group (if any) - variation_id = self.find_bucket(project_config, bucketing_id, experiment.id, experiment.trafficAllocation) + variation_id = self.find_bucket(project_config, bucketing_id, + experiment.id, experiment.trafficAllocation) if variation_id: variation = project_config.get_variation_from_id(experiment.key, variation_id) - return variation + return variation, decide_reasons - return None + else: + message = 'Bucketed into an empty traffic range. Returning nil.' + project_config.logger.info(message) + decide_reasons.append(message) + + return None, decide_reasons diff --git a/optimizely/decision/__init__.py b/optimizely/decision/__init__.py index 8f0a0bce..016c35cd 100644 --- a/optimizely/decision/__init__.py +++ b/optimizely/decision/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2020, Optimizely +# Copyright 2021, 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 diff --git a/optimizely/decision/decide_option.py b/optimizely/decision/optimizely_decide_option.py similarity index 92% rename from optimizely/decision/decide_option.py rename to optimizely/decision/optimizely_decide_option.py index adc1eb26..4eb8e7e5 100644 --- a/optimizely/decision/decide_option.py +++ b/optimizely/decision/optimizely_decide_option.py @@ -1,4 +1,4 @@ -# Copyright 2020, Optimizely +# Copyright 2021, 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 @@ -12,7 +12,7 @@ # limitations under the License. -class DecideOption(object): +class OptimizelyDecideOption(object): DISABLE_DECISION_EVENT = 'DISABLE_DECISION_EVENT' ENABLED_FLAGS_ONLY = 'ENABLED_FLAGS_ONLY' IGNORE_USER_PROFILE_SERVICE = 'IGNORE_USER_PROFILE_SERVICE' diff --git a/optimizely/decision/decision.py b/optimizely/decision/optimizely_decision.py similarity index 70% rename from optimizely/decision/decision.py rename to optimizely/decision/optimizely_decision.py index 19ecb7b0..781ab2bb 100644 --- a/optimizely/decision/decision.py +++ b/optimizely/decision/optimizely_decision.py @@ -1,4 +1,4 @@ -# Copyright 2020, Optimizely +# Copyright 2021, 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 @@ -12,7 +12,7 @@ # limitations under the License. -class Decision(object): +class OptimizelyDecision(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 @@ -22,3 +22,14 @@ def __init__(self, variation_key=None, enabled=None, self.flag_key = flag_key self.user_context = user_context self.reasons = reasons or [] + + def as_json(self): + return { + 'variation_key': self.variation_key, + 'enabled': self.enabled, + 'variables': self.variables, + 'rule_key': self.rule_key, + 'flag_key': self.flag_key, + 'user_context': self.user_context.as_json(), + 'reasons': self.reasons + } diff --git a/optimizely/decision/decision_message.py b/optimizely/decision/optimizely_decision_message.py similarity index 76% rename from optimizely/decision/decision_message.py rename to optimizely/decision/optimizely_decision_message.py index ea3c48d3..5b1ab417 100644 --- a/optimizely/decision/decision_message.py +++ b/optimizely/decision/optimizely_decision_message.py @@ -1,4 +1,4 @@ -# Copyright 2020, Optimizely +# Copyright 2021, 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 @@ -12,7 +12,7 @@ # limitations under the License. -class DecisionMessage(object): +class OptimizelyDecisionMessage(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.' + FLAG_KEY_INVALID = 'No flag was found for key "{}".' + VARIABLE_VALUE_INVALID = 'Variable value for key "{}" is invalid or wrong type.' diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index 1b767e11..52e9d02b 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -1,4 +1,4 @@ -# Copyright 2017-2020, Optimizely +# Copyright 2017-2021, 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 @@ -47,19 +47,21 @@ def _get_bucketing_id(self, user_id, attributes): attributes: Dict representing user attributes. May consist of bucketing ID to be used. Returns: - String representing bucketing ID if it is a String type in attributes else return user ID. + String representing bucketing ID if it is a String type in attributes else return user ID + array of log messages representing decision making. """ - + decide_reasons = [] attributes = attributes or {} bucketing_id = attributes.get(enums.ControlAttributes.BUCKETING_ID) if bucketing_id is not None: if isinstance(bucketing_id, string_types): - return bucketing_id - - self.logger.warning('Bucketing ID attribute is not a string. Defaulted to user_id.') + return bucketing_id, decide_reasons + message = 'Bucketing ID attribute is not a string. Defaulted to user_id.' + self.logger.warning(message) + decide_reasons.append(message) - return user_id + return user_id, decide_reasons def set_forced_variation(self, project_config, experiment_key, user_id, variation_key): """ Sets users to a map of experiments to forced variations. @@ -128,38 +130,43 @@ def get_forced_variation(self, project_config, experiment_key, user_id): user_id: The user ID. Returns: - The variation which the given user and experiment should be forced into. + The variation which the given user and experiment should be forced into and + array of log messages representing decision making. """ - + decide_reasons = [] if user_id not in self.forced_variation_map: - self.logger.debug('User "%s" is not in the forced variation map.' % user_id) - return None + message = 'User "%s" is not in the forced variation map.' % user_id + self.logger.debug(message) + return None, decide_reasons experiment = project_config.get_experiment_from_key(experiment_key) if not experiment: # The invalid experiment key will be logged inside this call. - return None + return None, decide_reasons experiment_to_variation_map = self.forced_variation_map.get(user_id) if not experiment_to_variation_map: + message = 'No experiment "%s" mapped to user "%s" in the forced variation map.' % (experiment_key, user_id) self.logger.debug( - 'No experiment "%s" mapped to user "%s" in the forced variation map.' % (experiment_key, user_id) + message ) - return None + return None, decide_reasons variation_id = experiment_to_variation_map.get(experiment.id) if variation_id is None: - self.logger.debug('No variation mapped to experiment "%s" in the forced variation map.' % experiment_key) - return None + message = 'No variation mapped to experiment "%s" in the forced variation map.' % experiment_key + self.logger.debug(message) + return None, decide_reasons variation = project_config.get_variation_from_id(experiment_key, variation_id) - + message = 'Variation "%s" is mapped to experiment "%s" and user "%s" in the forced variation map' \ + % (variation.key, experiment_key, user_id) self.logger.debug( - 'Variation "%s" is mapped to experiment "%s" and user "%s" in the forced variation map' - % (variation.key, experiment_key, user_id) + message ) - return variation + decide_reasons.append(message) + return variation, decide_reasons def get_whitelisted_variation(self, project_config, experiment, user_id): """ Determine if a user is forced into a variation (through whitelisting) @@ -171,18 +178,21 @@ def get_whitelisted_variation(self, project_config, experiment, user_id): user_id: ID for the user. Returns: - Variation in which the user with ID user_id is forced into. None if no variation. + Variation in which the user with ID user_id is forced into. None if no variation and + array of log messages representing decision making. """ - + decide_reasons = [] forced_variations = experiment.forcedVariations if forced_variations and user_id in forced_variations: variation_key = forced_variations.get(user_id) variation = project_config.get_variation_from_key(experiment.key, variation_key) if variation: - self.logger.info('User "%s" is forced in variation "%s".' % (user_id, variation_key)) - return variation + message = 'User "%s" is forced in variation "%s".' % (user_id, variation_key) + self.logger.info(message) + decide_reasons.append(message) + return variation, decide_reasons - return None + return None, decide_reasons def get_stored_variation(self, project_config, experiment, user_profile): """ Determine if the user has a stored variation available for the given experiment and return that. @@ -195,22 +205,24 @@ def get_stored_variation(self, project_config, experiment, user_profile): Returns: Variation if available. None otherwise. """ - user_id = user_profile.user_id variation_id = user_profile.get_variation_for_experiment(experiment.id) if variation_id: variation = project_config.get_variation_from_id(experiment.key, variation_id) if variation: + message = 'Found a stored decision. User "%s" is in variation "%s" of experiment "%s".'\ + % (user_id, variation.key, experiment.key) self.logger.info( - 'Found a stored decision. User "%s" is in variation "%s" of experiment "%s".' - % (user_id, variation.key, experiment.key) + message ) return variation return None - def get_variation(self, project_config, experiment, user_id, attributes, ignore_user_profile=False): + def get_variation( + self, project_config, experiment, user_id, attributes, ignore_user_profile=False + ): """ Top-level function to help determine variation user should be put in. First, check if experiment is running. @@ -227,23 +239,28 @@ def get_variation(self, project_config, experiment, user_id, attributes, ignore_ ignore_user_profile: True to ignore the user profile lookup. Defaults to False. Returns: - Variation user should see. None if user is not in experiment or experiment is not running. + Variation user should see. None if user is not in experiment or experiment is not running + And an array of log messages representing decision making. """ - + decide_reasons = [] # Check if experiment is running if not experiment_helper.is_experiment_running(experiment): - self.logger.info('Experiment "%s" is not running.' % experiment.key) - return None + message = 'Experiment "%s" is not running.' % experiment.key + self.logger.info(message) + decide_reasons.append(message) + return None, decide_reasons # Check if the user is forced into a variation - variation = self.get_forced_variation(project_config, experiment.key, user_id) + variation, reasons_received = self.get_forced_variation(project_config, experiment.key, user_id) + decide_reasons += reasons_received if variation: - return variation + return variation, decide_reasons # Check to see if user is white-listed for a certain variation - variation = self.get_whitelisted_variation(project_config, experiment, user_id) + variation, reasons_received = self.get_whitelisted_variation(project_config, experiment, user_id) + decide_reasons += reasons_received if variation: - return variation + return variation, decide_reasons # Check to see if user has a decision available for the given experiment user_profile = UserProfile(user_id) @@ -258,28 +275,41 @@ def get_variation(self, project_config, experiment, user_id, attributes, ignore_ user_profile = UserProfile(**retrieved_profile) variation = self.get_stored_variation(project_config, experiment, user_profile) if variation: - return variation + message = 'Returning previously activated variation ID "{}" of experiment ' \ + '"{}" for user "{}" from user profile.'.format(variation, experiment, user_id) + self.logger.info(message) + decide_reasons.append(message) + return variation, decide_reasons else: self.logger.warning('User profile has invalid format.') # Bucket user and store the new decision audience_conditions = experiment.get_audience_conditions_or_ids() - if not audience_helper.does_user_meet_audience_conditions(project_config, audience_conditions, - enums.ExperimentAudienceEvaluationLogs, - experiment.key, - attributes, self.logger): + user_meets_audience_conditions, reasons_received = audience_helper.does_user_meet_audience_conditions( + project_config, audience_conditions, + enums.ExperimentAudienceEvaluationLogs, + experiment.key, + attributes, self.logger) + decide_reasons += reasons_received + if not user_meets_audience_conditions: + message = 'User "{}" does not meet conditions to be in experiment "{}".'.format(user_id, experiment.key) self.logger.info( - 'User "{}" does not meet conditions to be in experiment "{}".'.format(user_id, experiment.key)) - return None + message + ) + decide_reasons.append(message) + return None, decide_reasons # Determine bucketing ID to be used - bucketing_id = self._get_bucketing_id(user_id, attributes) - variation = self.bucketer.bucket(project_config, experiment, user_id, bucketing_id) - + bucketing_id, bucketing_id_reasons = self._get_bucketing_id(user_id, attributes) + decide_reasons += bucketing_id_reasons + variation, bucket_reasons = self.bucketer.bucket(project_config, experiment, user_id, bucketing_id) + decide_reasons += bucket_reasons if variation: + message = 'User "%s" is in variation "%s" of experiment %s.' % (user_id, variation.key, experiment.key) self.logger.info( - 'User "%s" is in variation "%s" of experiment %s.' % (user_id, variation.key, experiment.key) + message ) + decide_reasons.append(message) # Store this new decision and return the variation for the user if not ignore_user_profile and self.user_profile_service: try: @@ -287,26 +317,27 @@ def get_variation(self, project_config, experiment, user_id, attributes, ignore_ self.user_profile_service.save(user_profile.__dict__) except: self.logger.exception('Unable to save user profile for user "{}".'.format(user_id)) - return variation - - self.logger.info('User "%s" is in no variation.' % user_id) - return None + return variation, decide_reasons + message = 'User "%s" is in no variation.' % user_id + self.logger.info(message) + decide_reasons.append(message) + return None, decide_reasons def get_variation_for_rollout(self, project_config, rollout, user_id, attributes=None): """ Determine which experiment/variation the user is in for a given rollout. - Returns the variation of the first experiment the user qualifies for. + Returns the variation of the first experiment the user qualifies for. Args: project_config: Instance of ProjectConfig. rollout: Rollout for which we are getting the variation. user_id: ID for user. attributes: Dict representing user attributes. - ignore_user_profile: True if we should bypass the user profile service Returns: - Decision namedtuple consisting of experiment and variation for the user. + Decision namedtuple consisting of experiment and variation for the user and + array of log messages representing decision making. """ - + decide_reasons = [] # Go through each experiment in order and try to get the variation for the user if rollout and len(rollout.experiments) > 0: for idx in range(len(rollout.experiments) - 1): @@ -315,53 +346,72 @@ def get_variation_for_rollout(self, project_config, rollout, user_id, attributes # Check if user meets audience conditions for targeting rule audience_conditions = rollout_rule.get_audience_conditions_or_ids() - if not audience_helper.does_user_meet_audience_conditions(project_config, - audience_conditions, - enums.RolloutRuleAudienceEvaluationLogs, - logging_key, - attributes, - self.logger): + user_meets_audience_conditions, reasons_received = audience_helper.does_user_meet_audience_conditions( + project_config, + audience_conditions, + enums.RolloutRuleAudienceEvaluationLogs, + logging_key, + attributes, + self.logger) + decide_reasons += reasons_received + if not user_meets_audience_conditions: + message = 'User "{}" does not meet conditions for targeting rule {}.'.format(user_id, logging_key) self.logger.debug( - 'User "{}" does not meet conditions for targeting rule {}.'.format(user_id, logging_key)) + message + ) + decide_reasons.append(message) continue - - self.logger.debug( - 'User "{}" meets audience conditions for targeting rule {}.'.format(user_id, idx + 1)) + message = 'User "{}" meets audience conditions for targeting rule {}.'.format(user_id, idx + 1) + self.logger.debug(message) + decide_reasons.append(message) # Determine bucketing ID to be used - bucketing_id = self._get_bucketing_id(user_id, attributes) - variation = self.bucketer.bucket(project_config, rollout_rule, user_id, bucketing_id) + bucketing_id, bucket_reasons = self._get_bucketing_id(user_id, attributes) + decide_reasons += bucket_reasons + variation, reasons = self.bucketer.bucket(project_config, rollout_rule, user_id, bucketing_id) + decide_reasons += reasons if variation: + message = 'User "{}" is in the traffic group of targeting rule {}.'.format(user_id, logging_key) self.logger.debug( - 'User "{}" is in the traffic group of targeting rule {}.'.format(user_id, logging_key) + message ) - return Decision(rollout_rule, variation, enums.DecisionSources.ROLLOUT) + decide_reasons.append(message) + return Decision(rollout_rule, variation, enums.DecisionSources.ROLLOUT), decide_reasons else: + message = 'User "{}" is not in the traffic group for targeting rule {}. ' \ + 'Checking "Everyone Else" rule now.'.format(user_id, logging_key) # Evaluate no further rules self.logger.debug( - 'User "{}" is not in the traffic group for targeting rule {}. ' - 'Checking "Everyone Else" rule now.'.format(user_id, logging_key) + message ) + decide_reasons.append(message) break # Evaluate last rule i.e. "Everyone Else" rule everyone_else_rule = project_config.get_experiment_from_key(rollout.experiments[-1].get('key')) audience_conditions = everyone_else_rule.get_audience_conditions_or_ids() - if audience_helper.does_user_meet_audience_conditions( + audience_eval, audience_reasons = audience_helper.does_user_meet_audience_conditions( project_config, audience_conditions, enums.RolloutRuleAudienceEvaluationLogs, 'Everyone Else', attributes, self.logger - ): + ) + decide_reasons += audience_reasons + if audience_eval: # Determine bucketing ID to be used - bucketing_id = self._get_bucketing_id(user_id, attributes) - variation = self.bucketer.bucket(project_config, everyone_else_rule, user_id, bucketing_id) + bucketing_id, bucket_id_reasons = self._get_bucketing_id(user_id, attributes) + decide_reasons += bucket_id_reasons + variation, bucket_reasons = self.bucketer.bucket( + project_config, everyone_else_rule, user_id, bucketing_id) + decide_reasons += bucket_reasons if variation: - self.logger.debug('User "{}" meets conditions for targeting rule "Everyone Else".'.format(user_id)) - return Decision(everyone_else_rule, variation, enums.DecisionSources.ROLLOUT,) + message = 'User "{}" meets conditions for targeting rule "Everyone Else".'.format(user_id) + self.logger.debug(message) + decide_reasons.append(message) + return Decision(everyone_else_rule, variation, enums.DecisionSources.ROLLOUT,), decide_reasons - return Decision(None, None, enums.DecisionSources.ROLLOUT) + return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons def get_experiment_in_group(self, project_config, group, bucketing_id): """ Determine which experiment in the group the user is bucketed into. @@ -372,24 +422,29 @@ def get_experiment_in_group(self, project_config, group, bucketing_id): bucketing_id: ID to be used for bucketing the user. Returns: - Experiment if the user is bucketed into an experiment in the specified group. None otherwise. + Experiment if the user is bucketed into an experiment in the specified group. None otherwise + and array of log messages representing decision making. """ - - experiment_id = self.bucketer.find_bucket(project_config, bucketing_id, group.id, group.trafficAllocation) + decide_reasons = [] + experiment_id = self.bucketer.find_bucket( + project_config, bucketing_id, group.id, group.trafficAllocation) if experiment_id: experiment = project_config.get_experiment_from_id(experiment_id) if experiment: + message = 'User with bucketing ID "%s" is in experiment %s of group %s.' % \ + (bucketing_id, experiment.key, group.id) self.logger.info( - 'User with bucketing ID "%s" is in experiment %s of group %s.' - % (bucketing_id, experiment.key, group.id) + message ) - return experiment - + decide_reasons.append(message) + return experiment, decide_reasons + message = 'User with bucketing ID "%s" is not in any experiments of group %s.' % (bucketing_id, group.id) self.logger.info( - 'User with bucketing ID "%s" is not in any experiments of group %s.' % (bucketing_id, group.id) + message ) + decide_reasons.append(message) - return None + return None, decide_reasons def get_variation_for_feature(self, project_config, feature, user_id, attributes=None, ignore_user_profile=False): """ Returns the experiment/variation the user is bucketed in for the given feature. @@ -399,24 +454,26 @@ def get_variation_for_feature(self, project_config, feature, user_id, attributes feature: Feature for which we are determining if it is enabled or not for the given user. user_id: ID for user. attributes: Dict representing user attributes. - ignore_user_profile: True if you want to bypass the user profile service + ignore_user_profile: True if we should bypass the user profile service Returns: Decision namedtuple consisting of experiment and variation for the user. """ - - bucketing_id = self._get_bucketing_id(user_id, attributes) - + decide_reasons = [] + bucketing_id, reasons = self._get_bucketing_id(user_id, attributes) + decide_reasons += reasons # First check if the feature is in a mutex group if feature.groupId: group = project_config.get_group(feature.groupId) if group: - experiment = self.get_experiment_in_group(project_config, group, bucketing_id) + experiment, reasons = self.get_experiment_in_group(project_config, group, bucketing_id) + decide_reasons += reasons if experiment and experiment.id in feature.experimentIds: - variation = self.get_variation(project_config, experiment, user_id, attributes, ignore_user_profile) - + variation, variation_reasons = self.get_variation( + project_config, experiment, user_id, attributes, ignore_user_profile) + decide_reasons += variation_reasons if variation: - return Decision(experiment, variation, enums.DecisionSources.FEATURE_TEST) + return Decision(experiment, variation, enums.DecisionSources.FEATURE_TEST), decide_reasons else: self.logger.error(enums.Errors.INVALID_GROUP_ID.format('_get_variation_for_feature')) @@ -425,14 +482,15 @@ def get_variation_for_feature(self, project_config, feature, user_id, attributes # If an experiment is not in a group, then the feature can only be associated with one experiment experiment = project_config.get_experiment_from_id(feature.experimentIds[0]) if experiment: - variation = self.get_variation(project_config, experiment, user_id, attributes, ignore_user_profile) - + variation, variation_reasons = self.get_variation( + project_config, experiment, user_id, attributes, ignore_user_profile) + decide_reasons += variation_reasons if variation: - return Decision(experiment, variation, enums.DecisionSources.FEATURE_TEST) + return Decision(experiment, variation, enums.DecisionSources.FEATURE_TEST), decide_reasons # Next check if user is part of a rollout if feature.rolloutId: rollout = project_config.get_rollout_from_id(feature.rolloutId) return self.get_variation_for_rollout(project_config, rollout, user_id, attributes) else: - return Decision(None, None, enums.DecisionSources.ROLLOUT) + return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons diff --git a/optimizely/entities.py b/optimizely/entities.py index c182c4da..88cd49c4 100644 --- a/optimizely/entities.py +++ b/optimizely/entities.py @@ -1,4 +1,4 @@ -# Copyright 2016-2020, Optimizely +# Copyright 2016-2021, 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 @@ -71,6 +71,9 @@ def get_audience_conditions_or_ids(self): """ Returns audienceConditions if present, otherwise audienceIds. """ return self.audienceConditions if self.audienceConditions is not None else self.audienceIds + def __str__(self): + return self.key + class FeatureFlag(BaseEntity): def __init__(self, id, key, experimentIds, rolloutId, variables, groupId=None, **kwargs): @@ -122,3 +125,6 @@ def __init__(self, id, key, featureEnabled=False, variables=None, **kwargs): self.key = key self.featureEnabled = featureEnabled self.variables = variables or [] + + def __str__(self): + return self.key diff --git a/optimizely/helpers/audience.py b/optimizely/helpers/audience.py index 857d20ef..e9914c66 100644 --- a/optimizely/helpers/audience.py +++ b/optimizely/helpers/audience.py @@ -1,4 +1,4 @@ -# Copyright 2016, 2018-2020, Optimizely +# Copyright 2016, 2018-2021, 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 @@ -35,15 +35,21 @@ def does_user_meet_audience_conditions(config, logger: Provides a logger to send log messages to. Returns: - Boolean representing if user satisfies audience conditions for any of the audiences or not. + Boolean representing if user satisfies audience conditions for any of the audiences or not + And an array of log messages representing decision making. """ - logger.debug(audience_logs.EVALUATING_AUDIENCES_COMBINED.format(logging_key, json.dumps(audience_conditions))) + decide_reasons = [] + message = audience_logs.EVALUATING_AUDIENCES_COMBINED.format(logging_key, json.dumps(audience_conditions)) + logger.debug(message) + decide_reasons.append(message) # Return True in case there are no audiences if audience_conditions is None or audience_conditions == []: - logger.info(audience_logs.AUDIENCE_EVALUATION_RESULT_COMBINED.format(logging_key, 'TRUE')) + message = audience_logs.AUDIENCE_EVALUATION_RESULT_COMBINED.format(logging_key, 'TRUE') + logger.info(message) + decide_reasons.append(message) - return True + return True, decide_reasons if attributes is None: attributes = {} @@ -61,19 +67,22 @@ def evaluate_audience(audience_id): if audience is None: return None - - logger.debug(audience_logs.EVALUATING_AUDIENCE.format(audience_id, audience.conditions)) + _message = audience_logs.EVALUATING_AUDIENCE.format(audience_id, audience.conditions) + logger.debug(_message) result = condition_tree_evaluator.evaluate( audience.conditionStructure, lambda index: evaluate_custom_attr(audience_id, index), ) result_str = str(result).upper() if result is not None else 'UNKNOWN' - logger.debug(audience_logs.AUDIENCE_EVALUATION_RESULT.format(audience_id, result_str)) + _message = audience_logs.AUDIENCE_EVALUATION_RESULT.format(audience_id, result_str) + logger.debug(_message) return result eval_result = condition_tree_evaluator.evaluate(audience_conditions, evaluate_audience) eval_result = eval_result or False - logger.info(audience_logs.AUDIENCE_EVALUATION_RESULT_COMBINED.format(logging_key, str(eval_result).upper())) - return eval_result + message = audience_logs.AUDIENCE_EVALUATION_RESULT_COMBINED.format(logging_key, str(eval_result).upper()) + logger.info(message) + decide_reasons.append(message) + return eval_result, decide_reasons diff --git a/optimizely/helpers/enums.py b/optimizely/helpers/enums.py index 5685f9c8..8339eee6 100644 --- a/optimizely/helpers/enums.py +++ b/optimizely/helpers/enums.py @@ -1,4 +1,4 @@ -# Copyright 2016-2020, Optimizely +# Copyright 2016-2021, 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 @@ -82,10 +82,11 @@ class DatafileVersions(object): class DecisionNotificationTypes(object): AB_TEST = 'ab-test' + ALL_FEATURE_VARIABLES = 'all-feature-variables' FEATURE = 'feature' FEATURE_TEST = 'feature-test' FEATURE_VARIABLE = 'feature-variable' - ALL_FEATURE_VARIABLES = 'all-feature-variables' + FLAG = 'flag' class DecisionSources(object): diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 6193fc2b..1383674a 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -1,4 +1,4 @@ -# Copyright 2016-2020, Optimizely +# Copyright 2016-2021, 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 @@ -10,8 +10,6 @@ # 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 logging -import threading from six import string_types @@ -23,9 +21,9 @@ 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 .decision.optimizely_decide_option import OptimizelyDecideOption +from .decision.optimizely_decision import OptimizelyDecision +from .decision.optimizely_decision_message import OptimizelyDecisionMessage from .error_handler import NoOpErrorHandler as noop_error_handler from .event import event_factory, user_event_factory from .event.event_processor import ForwardingEventProcessor @@ -34,7 +32,7 @@ from .helpers.enums import DecisionSources from .notification_center import NotificationCenter from .optimizely_config import OptimizelyConfigService -from .user_context import UserContext +from .optimizely_user_context import OptimizelyUserContext class Optimizely(object): @@ -53,7 +51,7 @@ def __init__( notification_center=None, event_processor=None, datafile_access_token=None, - default_decisions=None + default_decide_options=None ): """ Optimizely init method for managing Custom projects. @@ -77,7 +75,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. + default_decide_options: Optional list of decide options used with the decide APIs. """ self.logger_name = '.'.join([__name__, self.__class__.__name__]) self.is_valid = True @@ -89,7 +87,17 @@ 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 [] + + if default_decide_options is None: + self.default_decide_options = [] + else: + self.default_decide_options = default_decide_options + + if isinstance(self.default_decide_options, list): + self.default_decide_options = self.default_decide_options[:] + else: + self.logger.debug('Provided default decide options is not a list.') + self.default_decide_options = [] try: self._validate_instantiation_options() @@ -203,8 +211,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, - ignore_user_profile=False + 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. @@ -215,7 +222,6 @@ def _get_feature_variable_for_type( variable_type: Type of variable which could be one of boolean/double/integer/string. user_id: ID for user. attributes: Dict representing user attributes. - ignore_user_profile: if true don't use the user profile service Returns: Value of the variable. None if: @@ -258,8 +264,7 @@ def _get_feature_variable_for_type( feature_enabled = False source_info = {} variable_value = variable.defaultValue - decision = self.decision_service.get_variation_for_feature(project_config, feature_flag, user_id, - attributes, ignore_user_profile) + decision, _ = self.decision_service.get_variation_for_feature(project_config, feature_flag, user_id, attributes) if decision.variation: feature_enabled = decision.variation.featureEnabled @@ -310,7 +315,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. @@ -342,7 +347,8 @@ def _get_all_feature_variables_for_type( feature_enabled = False source_info = {} - decision = self.decision_service.get_variation_for_feature(project_config, feature_flag, user_id, attributes) + decision, _ = self.decision_service.get_variation_for_feature( + project_config, feature_flag, user_id, attributes) if decision.variation: feature_enabled = decision.variation.featureEnabled @@ -534,7 +540,7 @@ def get_variation(self, experiment_key, user_id, attributes=None): if not self._validate_user_inputs(attributes): return None - variation = self.decision_service.get_variation(project_config, experiment, user_id, attributes) + variation, _ = self.decision_service.get_variation(project_config, experiment, user_id, attributes) if variation: variation_key = variation.key @@ -591,7 +597,7 @@ def is_feature_enabled(self, feature_key, user_id, attributes=None): feature_enabled = False source_info = {} - decision = self.decision_service.get_variation_for_feature(project_config, feature, user_id, attributes) + decision, _ = self.decision_service.get_variation_for_feature(project_config, feature, user_id, attributes) is_source_experiment = decision.source == enums.DecisionSources.FEATURE_TEST is_source_rollout = decision.source == enums.DecisionSources.ROLLOUT @@ -903,7 +909,7 @@ def get_forced_variation(self, experiment_key, user_id): self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('get_forced_variation')) return None - forced_variation = self.decision_service.get_forced_variation(project_config, 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): @@ -948,90 +954,61 @@ def create_user_context(self, user_id, attributes=None): self.logger.error(enums.Errors.INVALID_INPUT.format('attributes')) return None - user_context = UserContext(self, user_id, attributes) - return user_context + return OptimizelyUserContext(self, user_id, attributes) - def decide(self, user_context, key, decide_options=None): + 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 + decide_options: list of OptimizelyDecideOption Returns: Decision object """ # raising on user context as it is internal and not provided directly by the user. - if not isinstance(user_context, UserContext): + if not isinstance(user_context, OptimizelyUserContext): 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) + self.logger.error(enums.Errors.INVALID_OPTIMIZELY.format('decide')) + reasons.append(OptimizelyDecisionMessage.SDK_NOT_READY) + return OptimizelyDecision(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) + reasons.append(OptimizelyDecisionMessage.FLAG_KEY_INVALID.format(key)) + return OptimizelyDecision(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) + reasons.append(OptimizelyDecisionMessage.SDK_NOT_READY) + return OptimizelyDecision(flag_key=key, user_context=user_context, reasons=reasons) feature_flag = config.get_feature_from_key(key) if feature_flag is None: self.logger.error("No feature flag was found for key '#{key}'.") - reasons.append(DecisionMessage.FLAG_KEY_INVALID.format(key)) - return Decision(flag_key=key, user_context=user_context, reasons=reasons) + reasons.append(OptimizelyDecisionMessage.FLAG_KEY_INVALID.format(key)) + return OptimizelyDecision(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 + decide_options += self.default_decide_options else: self.logger.debug('Provided decide options is not an array. Using default decide options.') - decide_options = self.default_decisions - - class ReasonLogHandler(logging.StreamHandler): - def __init__(self): - super(ReasonLogHandler, self).__init__() - self._name = "ReasonLogHandler" - self.reasons = {threading.current_thread().ident: []} - # setting to info level since we don't put debug in reasons. - self.level = logging.INFO - formatter = logging.Formatter('%(levelname)-8s %(asctime)s %(filename)s:%(lineno)s:%(message)s') - self.setFormatter(formatter) - self.createLock() - - def handle(self, record): - msg = self.format(record) - self.reasons[threading.current_thread().ident].append(msg) - - def emit(self, record): - pass - - def get_reasons(self): - return self.reasons[threading.current_thread().ident] - - handler = None - - if DecideOption.INCLUDE_REASONS in decide_options: - handler = ReasonLogHandler() - self.decision_service.logger.addHandler(handler) - config.logger.addHandler(handler) + decide_options = self.default_decide_options # Create Optimizely Decision Result. user_id = user_context.user_id - attributes = user_context.user_attributes + attributes = user_context.get_user_attributes() variation_key = None variation = None feature_enabled = False @@ -1041,11 +1018,13 @@ def get_reasons(self): experiment = None decision_source = DecisionSources.ROLLOUT source_info = {} + decision_event_dispatched = False + ignore_ups = OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE in decide_options - decision = self.decision_service.get_variation_for_feature(config, feature_flag, user_context.user_id, - user_context.user_attributes, - DecideOption.IGNORE_USER_PROFILE_SERVICE in - decide_options) + decision, decision_reasons = self.decision_service.get_variation_for_feature(config, feature_flag, user_id, + attributes, ignore_ups) + + reasons += decision_reasons # Fill in experiment and variation if returned (rollouts can have featureEnabled variables as well.) if decision.experiment is not None: @@ -1061,50 +1040,59 @@ def get_reasons(self): # 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 OptimizelyDecideOption.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, + decision_source, feature_enabled, user_id, attributes) + decision_event_dispatched = True # Generate all variables map if decide options doesn't include excludeVariables - if DecideOption.EXCLUDE_VARIABLES not in decide_options: - project_config = self.config_manager.get_config() - for v_key in feature_flag.variables: - v = feature_flag.variables[v_key] - all_variables[v.key] = self._get_feature_variable_for_type(project_config, feature_flag.key, - v.key, v.type, user_id, attributes, - DecideOption.IGNORE_USER_PROFILE_SERVICE in - decide_options - ) + if OptimizelyDecideOption.EXCLUDE_VARIABLES not in decide_options: + for variable_key in feature_flag.variables: + variable = config.get_variable_for_feature(flag_key, variable_key) + variable_value = variable.defaultValue + if feature_enabled: + variable_value = 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, flag_key) + ) + + try: + actual_value = 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 + + should_include_reasons = OptimizelyDecideOption.INCLUDE_REASONS in decide_options # Send notification self.notification_center.send_notifications( enums.NotificationTypes.DECISION, - enums.DecisionNotificationTypes.FEATURE, + enums.DecisionNotificationTypes.FLAG, user_id, attributes or {}, { - 'feature_key': key, - 'feature_enabled': feature_enabled, - 'source': decision.source, - 'source_info': source_info, + 'flag_key': flag_key, + 'enabled': feature_enabled, + 'variables': all_variables, + 'variation_key': variation_key, + 'rule_key': rule_key, + 'reasons': reasons if should_include_reasons else [], + 'decision_event_dispatched': decision_event_dispatched + }, ) - include_reasons = [] - if DecideOption.INCLUDE_REASONS in decide_options: - handler.flush() - include_reasons = reasons - include_reasons += handler.get_reasons() - self.decision_service.logger.removeHandler(handler) - config.logger.removeHandler(handler) - - 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) + return OptimizelyDecision(variation_key=variation_key, enabled=feature_enabled, variables=all_variables, + rule_key=rule_key, flag_key=flag_key, + user_context=user_context, reasons=reasons if should_include_reasons else [] + ) - def decide_all(self, user_context, decide_options=None): + def _decide_all(self, user_context, decide_options=None): """ decide_all will return a decision for every feature key in the current config Args: @@ -1115,28 +1103,25 @@ def decide_all(self, user_context, decide_options=None): 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): + if not isinstance(user_context, OptimizelyUserContext): 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')) + self.logger.error(enums.Errors.INVALID_OPTIMIZELY.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) + return {} keys = [] for f in config.feature_flags: keys.append(f['key']) + return self._decide_for_keys(user_context, keys, decide_options) - return self.decide_for_keys(user_context, keys, decide_options) - - def decide_for_keys(self, user_context, keys, decide_options=None): + def _decide_for_keys(self, user_context, keys, decide_options=None): """ Args: @@ -1148,23 +1133,29 @@ def decide_for_keys(self, user_context, keys, decide_options=None): 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): + if not isinstance(user_context, OptimizelyUserContext): 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')) + self.logger.error(enums.Errors.INVALID_OPTIMIZELY.format('decide_for_keys')) return {} - enabled_flags_only = False - if decide_options is not None: - enabled_flags_only = DecideOption.ENABLED_FLAGS_ONLY in decide_options + # merge decide_options and default_decide_options + merged_decide_options = [] + if isinstance(decide_options, list): + merged_decide_options = decide_options[:] + merged_decide_options += self.default_decide_options + else: + self.logger.debug('Provided decide options is not an array. Using default decide options.') + merged_decide_options = self.default_decide_options + + enabled_flags_only = OptimizelyDecideOption.ENABLED_FLAGS_ONLY in merged_decide_options decisions = {} for key in keys: - decision = self.decide(user_context, key, decide_options) + 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/optimizely_user_context.py similarity index 64% rename from optimizely/user_context.py rename to optimizely/optimizely_user_context.py index 98b40a27..9416f65d 100644 --- a/optimizely/user_context.py +++ b/optimizely/optimizely_user_context.py @@ -1,4 +1,4 @@ -# Copyright 2020, Optimizely and contributors +# Copyright 2021, 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. @@ -12,10 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. # -from . import logger as _logging +import threading -class UserContext(object): + +class OptimizelyUserContext(object): """ Representation of an Optimizely User Context using which APIs are to be called. """ @@ -34,11 +35,19 @@ def __init__(self, optimizely_client, user_id, user_attributes=None): 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__]) + if not isinstance(user_attributes, dict): + user_attributes = {} + + self._user_attributes = user_attributes.copy() if user_attributes else {} + self.lock = threading.Lock() - self.logger = _logging.reset_logger(self.logger_name) + def _clone(self): + return OptimizelyUserContext(self.client, self.user_id, self.get_user_attributes()) + + def get_user_attributes(self): + with self.lock: + return self._user_attributes.copy() def set_attribute(self, attribute_key, attribute_value): """ @@ -50,7 +59,8 @@ def set_attribute(self, attribute_key, attribute_value): Returns: None """ - self.user_attributes[attribute_key] = attribute_value + with self.lock: + self._user_attributes[attribute_key] = attribute_value def decide(self, key, options=None): """ @@ -62,11 +72,10 @@ def decide(self, key, options=None): Returns: Decision object """ - if not self.client: - self.logger.error("Optimizely Client invalid") - return None + if isinstance(options, list): + options = options[:] - return self.client.decide(self, key, options) + return self.client._decide(self._clone(), key, options) def decide_for_keys(self, keys, options=None): """ @@ -78,11 +87,10 @@ def decide_for_keys(self, keys, options=None): Returns: Dictionary with feature_key keys and Decision object values """ - if not self.client: - self.logger.error("Optimizely Client invalid") - return None + if isinstance(options, list): + options = options[:] - self.client.decide_for_keys(self, keys, options) + return self.client._decide_for_keys(self._clone(), keys, options) def decide_all(self, options=None): """ @@ -93,11 +101,16 @@ def decide_all(self, options=None): Returns: Dictionary with feature_key keys and Decision object values """ - if not self.client: - self.logger.error("Optimizely Client invalid") - return None + if isinstance(options, list): + options = options[:] - self.client.decide_all(self, options) + return self.client._decide_all(self._clone(), options) def track_event(self, event_key, event_tags=None): - self.client.track(event_key, self.user_id, self.user_attributes, event_tags) + return self.client.track(event_key, self.user_id, self.get_user_attributes(), event_tags) + + def as_json(self): + return { + 'user_id': self.user_id, + 'attributes': self.get_user_attributes(), + } diff --git a/tests/base.py b/tests/base.py index 88d5b73f..254be7c5 100644 --- a/tests/base.py +++ b/tests/base.py @@ -135,7 +135,7 @@ def setUp(self, config_dict='config_dict'): { 'key': 'test_experiment', 'status': 'Running', - 'forcedVariations': {}, + 'forcedVariations': {'user_1': 'control'}, 'layerId': '111182', 'audienceIds': [], 'trafficAllocation': [ diff --git a/tests/helpers_tests/test_audience.py b/tests/helpers_tests/test_audience.py index 95311887..719705d6 100644 --- a/tests/helpers_tests/test_audience.py +++ b/tests/helpers_tests/test_audience.py @@ -1,4 +1,4 @@ -# Copyright 2016-2020, Optimizely +# Copyright 2016-2021, 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 @@ -34,47 +34,48 @@ def test_does_user_meet_audience_conditions__no_audience(self): experiment = self.project_config.get_experiment_from_key('test_experiment') experiment.audienceIds = [] experiment.audienceConditions = [] + user_meets_audience_conditions, _ = audience.does_user_meet_audience_conditions( + self.project_config, + experiment.get_audience_conditions_or_ids(), + enums.ExperimentAudienceEvaluationLogs, + 'test_experiment', + user_attributes, + self.mock_client_logger + ) self.assertStrictTrue( - audience.does_user_meet_audience_conditions( - self.project_config, - experiment.get_audience_conditions_or_ids(), - enums.ExperimentAudienceEvaluationLogs, - 'test_experiment', - user_attributes, - self.mock_client_logger - ) + user_meets_audience_conditions ) # Audience Ids exist but Audience Conditions is Empty experiment = self.project_config.get_experiment_from_key('test_experiment') experiment.audienceIds = ['11154'] experiment.audienceConditions = [] + user_meets_audience_conditions, _ = audience.does_user_meet_audience_conditions( + self.project_config, + experiment.get_audience_conditions_or_ids(), + enums.ExperimentAudienceEvaluationLogs, + 'test_experiment', + user_attributes, + self.mock_client_logger + ) self.assertStrictTrue( - audience.does_user_meet_audience_conditions( - self.project_config, - experiment.get_audience_conditions_or_ids(), - enums.ExperimentAudienceEvaluationLogs, - 'test_experiment', - user_attributes, - self.mock_client_logger - ) - + user_meets_audience_conditions ) # Audience Ids is Empty and Audience Conditions is None experiment = self.project_config.get_experiment_from_key('test_experiment') experiment.audienceIds = [] experiment.audienceConditions = None + user_meets_audience_conditions, _ = audience.does_user_meet_audience_conditions( + self.project_config, + experiment.get_audience_conditions_or_ids(), + enums.ExperimentAudienceEvaluationLogs, + 'test_experiment', + user_attributes, + self.mock_client_logger + ) self.assertStrictTrue( - audience.does_user_meet_audience_conditions( - self.project_config, - experiment.get_audience_conditions_or_ids(), - enums.ExperimentAudienceEvaluationLogs, - 'test_experiment', - user_attributes, - self.mock_client_logger - ) - + user_meets_audience_conditions ) def test_does_user_meet_audience_conditions__with_audience(self): @@ -160,16 +161,16 @@ def test_does_user_meet_audience_conditions__returns_true__when_condition_tree_e user_attributes = {'test_attribute': 'test_value_1'} experiment = self.project_config.get_experiment_from_key('test_experiment') with mock.patch('optimizely.helpers.condition_tree_evaluator.evaluate', return_value=True): - + user_meets_audience_conditions, _ = audience.does_user_meet_audience_conditions( + self.project_config, + experiment.get_audience_conditions_or_ids(), + enums.ExperimentAudienceEvaluationLogs, + 'test_experiment', + user_attributes, + self.mock_client_logger + ) self.assertStrictTrue( - audience.does_user_meet_audience_conditions( - self.project_config, - experiment.get_audience_conditions_or_ids(), - enums.ExperimentAudienceEvaluationLogs, - 'test_experiment', - user_attributes, - self.mock_client_logger - ) + user_meets_audience_conditions ) def test_does_user_meet_audience_conditions_returns_false_when_condition_tree_evaluator_returns_none_or_false(self): @@ -179,29 +180,29 @@ def test_does_user_meet_audience_conditions_returns_false_when_condition_tree_ev user_attributes = {'test_attribute': 'test_value_1'} experiment = self.project_config.get_experiment_from_key('test_experiment') with mock.patch('optimizely.helpers.condition_tree_evaluator.evaluate', return_value=None): - + user_meets_audience_conditions, _ = audience.does_user_meet_audience_conditions( + self.project_config, + experiment.get_audience_conditions_or_ids(), + enums.ExperimentAudienceEvaluationLogs, + 'test_experiment', + user_attributes, + self.mock_client_logger + ) self.assertStrictFalse( - audience.does_user_meet_audience_conditions( - self.project_config, - experiment.get_audience_conditions_or_ids(), - enums.ExperimentAudienceEvaluationLogs, - 'test_experiment', - user_attributes, - self.mock_client_logger - ) + user_meets_audience_conditions ) with mock.patch('optimizely.helpers.condition_tree_evaluator.evaluate', return_value=False): - + user_meets_audience_conditions, _ = audience.does_user_meet_audience_conditions( + self.project_config, + experiment.get_audience_conditions_or_ids(), + enums.ExperimentAudienceEvaluationLogs, + 'test_experiment', + user_attributes, + self.mock_client_logger + ) self.assertStrictFalse( - audience.does_user_meet_audience_conditions( - self.project_config, - experiment.get_audience_conditions_or_ids(), - enums.ExperimentAudienceEvaluationLogs, - 'test_experiment', - user_attributes, - self.mock_client_logger - ) + user_meets_audience_conditions ) def test_does_user_meet_audience_conditions__evaluates_audience_ids(self): diff --git a/tests/test_bucketing.py b/tests/test_bucketing.py index f0268b66..fb71ba13 100644 --- a/tests/test_bucketing.py +++ b/tests/test_bucketing.py @@ -1,4 +1,4 @@ -# Copyright 2016-2020, Optimizely +# Copyright 2016-2021, 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 @@ -37,14 +37,15 @@ def test_bucket(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=42 ) as mock_generate_bucket_value: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', + 'test_user', + ) self.assertEqual( entities.Variation('111128', 'control'), - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('test_experiment'), - 'test_user', - 'test_user', - ), + variation, ) mock_generate_bucket_value.assert_called_once_with('test_user111127') @@ -52,13 +53,14 @@ def test_bucket(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=4242 ) as mock_generate_bucket_value: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('test_experiment'), - 'test_user', - 'test_user', - ) + variation ) mock_generate_bucket_value.assert_called_once_with('test_user111127') @@ -66,14 +68,15 @@ def test_bucket(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=5042 ) as mock_generate_bucket_value: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', + 'test_user', + ) self.assertEqual( entities.Variation('111129', 'variation'), - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('test_experiment'), - 'test_user', - 'test_user', - ), + variation, ) mock_generate_bucket_value.assert_called_once_with('test_user111127') @@ -81,26 +84,27 @@ def test_bucket(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=424242 ) as mock_generate_bucket_value: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('test_experiment'), - 'test_user', - 'test_user', - ) + variation ) mock_generate_bucket_value.assert_called_once_with('test_user111127') def test_bucket__invalid_experiment(self): """ Test that bucket returns None for unknown experiment. """ - + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('invalid_experiment'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('invalid_experiment'), - 'test_user', - 'test_user', - ) + variation ) def test_bucket__invalid_group(self): @@ -110,8 +114,8 @@ def test_bucket__invalid_group(self): experiment = project_config.get_experiment_from_key('group_exp_1') # Set invalid group ID for the experiment experiment.groupId = 'invalid_group_id' - - self.assertIsNone(self.bucketer.bucket(self.project_config, experiment, 'test_user', 'test_user')) + variation, _ = self.bucketer.bucket(self.project_config, experiment, 'test_user', 'test_user') + self.assertIsNone(variation) def test_bucket__experiment_in_group(self): """ Test that for provided bucket values correct variation ID is returned. """ @@ -120,14 +124,15 @@ def test_bucket__experiment_in_group(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', side_effect=[42, 4242], ) as mock_generate_bucket_value: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('group_exp_1'), + 'test_user', + 'test_user', + ) self.assertEqual( entities.Variation('28902', 'group_exp_1_variation'), - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('group_exp_1'), - 'test_user', - 'test_user', - ), + variation, ) self.assertEqual( @@ -138,13 +143,14 @@ def test_bucket__experiment_in_group(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', side_effect=[42, 9500], ) as mock_generate_bucket_value: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('group_exp_1'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('group_exp_1'), - 'test_user', - 'test_user', - ) + variation ) self.assertEqual( [mock.call('test_user19228'), mock.call('test_user32222')], mock_generate_bucket_value.call_args_list, @@ -154,13 +160,14 @@ def test_bucket__experiment_in_group(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', side_effect=[42, 4242], ) as mock_generate_bucket_value: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('group_exp_2'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('group_exp_2'), - 'test_user', - 'test_user', - ) + variation ) mock_generate_bucket_value.assert_called_once_with('test_user19228') @@ -168,13 +175,14 @@ def test_bucket__experiment_in_group(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', side_effect=[42, 424242], ) as mock_generate_bucket_value: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('group_exp_1'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('group_exp_1'), - 'test_user', - 'test_user', - ) + variation ) self.assertEqual( [mock.call('test_user19228'), mock.call('test_user32222')], mock_generate_bucket_value.call_args_list, @@ -223,14 +231,15 @@ def test_bucket(self): with mock.patch('optimizely.bucketer.Bucketer._generate_bucket_value', return_value=42), mock.patch.object( self.project_config, 'logger' ) as mock_config_logging: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', + 'test_user', + ) self.assertEqual( entities.Variation('111128', 'control'), - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('test_experiment'), - 'test_user', - 'test_user', - ), + variation, ) mock_config_logging.debug.assert_called_once_with('Assigned bucket 42 to user with bucketing ID "test_user".') @@ -239,13 +248,14 @@ def test_bucket(self): with mock.patch('optimizely.bucketer.Bucketer._generate_bucket_value', return_value=4242), mock.patch.object( self.project_config, 'logger' ) as mock_config_logging: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('test_experiment'), - 'test_user', - 'test_user', - ) + variation ) mock_config_logging.debug.assert_called_once_with('Assigned bucket 4242 to user with bucketing ID "test_user".') @@ -254,14 +264,15 @@ def test_bucket(self): with mock.patch('optimizely.bucketer.Bucketer._generate_bucket_value', return_value=5042), mock.patch.object( self.project_config, 'logger' ) as mock_config_logging: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', + 'test_user', + ) self.assertEqual( entities.Variation('111129', 'variation'), - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('test_experiment'), - 'test_user', - 'test_user', - ), + variation, ) mock_config_logging.debug.assert_called_once_with('Assigned bucket 5042 to user with bucketing ID "test_user".') @@ -270,13 +281,14 @@ def test_bucket(self): with mock.patch('optimizely.bucketer.Bucketer._generate_bucket_value', return_value=424242), mock.patch.object( self.project_config, 'logger' ) as mock_config_logging: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('test_experiment'), - 'test_user', - 'test_user', - ) + variation ) mock_config_logging.debug.assert_called_once_with( @@ -290,14 +302,15 @@ def test_bucket__experiment_in_group(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', side_effect=[42, 4242], ), mock.patch.object(self.project_config, 'logger') as mock_config_logging: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('group_exp_1'), + 'test_user', + 'test_user', + ) self.assertEqual( entities.Variation('28902', 'group_exp_1_variation'), - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('group_exp_1'), - 'test_user', - 'test_user', - ), + variation, ) mock_config_logging.debug.assert_has_calls( [ @@ -315,13 +328,14 @@ def test_bucket__experiment_in_group(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', side_effect=[8400, 9500], ), mock.patch.object(self.project_config, 'logger') as mock_config_logging: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('group_exp_1'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('group_exp_1'), - 'test_user', - 'test_user', - ) + variation ) mock_config_logging.debug.assert_called_once_with('Assigned bucket 8400 to user with bucketing ID "test_user".') mock_config_logging.info.assert_called_once_with('User "test_user" is in no experiment.') @@ -330,13 +344,14 @@ def test_bucket__experiment_in_group(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', side_effect=[42, 9500], ), mock.patch.object(self.project_config, 'logger') as mock_config_logging: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('group_exp_1'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('group_exp_1'), - 'test_user', - 'test_user', - ) + variation ) mock_config_logging.debug.assert_has_calls( [ @@ -354,13 +369,14 @@ def test_bucket__experiment_in_group(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', side_effect=[42, 4242], ), mock.patch.object(self.project_config, 'logger') as mock_config_logging: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('group_exp_2'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('group_exp_2'), - 'test_user', - 'test_user', - ) + variation ) mock_config_logging.debug.assert_called_once_with('Assigned bucket 42 to user with bucketing ID "test_user".') mock_config_logging.info.assert_called_once_with( @@ -371,13 +387,14 @@ def test_bucket__experiment_in_group(self): with mock.patch( 'optimizely.bucketer.Bucketer._generate_bucket_value', side_effect=[42, 424242], ), mock.patch.object(self.project_config, 'logger') as mock_config_logging: + variation, _ = self.bucketer.bucket( + self.project_config, + self.project_config.get_experiment_from_key('group_exp_1'), + 'test_user', + 'test_user', + ) self.assertIsNone( - self.bucketer.bucket( - self.project_config, - self.project_config.get_experiment_from_key('group_exp_1'), - 'test_user', - 'test_user', - ) + variation ) mock_config_logging.debug.assert_has_calls( diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py index 6875a1c0..f4023d0a 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -1,4 +1,4 @@ -# Copyright 2017-2020, Optimizely +# Copyright 2017-2021, 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 @@ -33,16 +33,19 @@ def test_get_bucketing_id__no_bucketing_id_attribute(self): """ Test that _get_bucketing_id returns correct bucketing ID when there is no bucketing ID attribute. """ # No attributes + bucketing_id, _ = self.decision_service._get_bucketing_id("test_user", None) self.assertEqual( - "test_user", self.decision_service._get_bucketing_id("test_user", None) + "test_user", + bucketing_id ) # With attributes, but no bucketing ID + bucketing_id, _ = self.decision_service._get_bucketing_id( + "test_user", {"random_key": "random_value"} + ) self.assertEqual( "test_user", - self.decision_service._get_bucketing_id( - "test_user", {"random_key": "random_value"} - ), + bucketing_id, ) def test_get_bucketing_id__bucketing_id_attribute(self): @@ -50,11 +53,12 @@ def test_get_bucketing_id__bucketing_id_attribute(self): with mock.patch.object( self.decision_service, "logger" ) as mock_decision_service_logging: + bucketing_id, _ = self.decision_service._get_bucketing_id( + "test_user", {"$opt_bucketing_id": "user_bucket_value"} + ) self.assertEqual( "user_bucket_value", - self.decision_service._get_bucketing_id( - "test_user", {"$opt_bucketing_id": "user_bucket_value"} - ), + bucketing_id, ) mock_decision_service_logging.debug.assert_not_called() @@ -63,33 +67,35 @@ def test_get_bucketing_id__bucketing_id_attribute_not_a_string(self): with mock.patch.object( self.decision_service, "logger" ) as mock_decision_service_logging: + bucketing_id, _ = self.decision_service._get_bucketing_id( + "test_user", {"$opt_bucketing_id": True} + ) self.assertEqual( "test_user", - self.decision_service._get_bucketing_id( - "test_user", {"$opt_bucketing_id": True} - ), + bucketing_id, ) mock_decision_service_logging.warning.assert_called_once_with( "Bucketing ID attribute is not a string. Defaulted to user_id." ) mock_decision_service_logging.reset_mock() + bucketing_id, _ = self.decision_service._get_bucketing_id( + "test_user", {"$opt_bucketing_id": 5.9} + ) self.assertEqual( "test_user", - self.decision_service._get_bucketing_id( - "test_user", {"$opt_bucketing_id": 5.9} - ), + bucketing_id, ) mock_decision_service_logging.warning.assert_called_once_with( "Bucketing ID attribute is not a string. Defaulted to user_id." ) mock_decision_service_logging.reset_mock() - + bucketing_id, _ = self.decision_service._get_bucketing_id( + "test_user", {"$opt_bucketing_id": 5} + ) self.assertEqual( "test_user", - self.decision_service._get_bucketing_id( - "test_user", {"$opt_bucketing_id": 5} - ), + bucketing_id, ) mock_decision_service_logging.warning.assert_called_once_with( "Bucketing ID attribute is not a string. Defaulted to user_id." @@ -154,10 +160,11 @@ def test_set_forced_variation__multiple_sets(self): self.project_config, "test_experiment", "test_user_1", "variation" ) ) + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "test_experiment", "test_user_1" + ) self.assertEqual( - self.decision_service.get_forced_variation( - self.project_config, "test_experiment", "test_user_1" - ).key, + variation.key, "variation", ) # same user, same experiment, different variation @@ -166,10 +173,11 @@ def test_set_forced_variation__multiple_sets(self): self.project_config, "test_experiment", "test_user_1", "control" ) ) + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "test_experiment", "test_user_1" + ) self.assertEqual( - self.decision_service.get_forced_variation( - self.project_config, "test_experiment", "test_user_1" - ).key, + variation.key, "control", ) # same user, different experiment @@ -178,10 +186,11 @@ def test_set_forced_variation__multiple_sets(self): self.project_config, "group_exp_1", "test_user_1", "group_exp_1_control" ) ) + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "group_exp_1", "test_user_1" + ) self.assertEqual( - self.decision_service.get_forced_variation( - self.project_config, "group_exp_1", "test_user_1" - ).key, + variation.key, "group_exp_1_control", ) @@ -191,10 +200,11 @@ def test_set_forced_variation__multiple_sets(self): self.project_config, "test_experiment", "test_user_2", "variation" ) ) + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "test_experiment", "test_user_2" + ) self.assertEqual( - self.decision_service.get_forced_variation( - self.project_config, "test_experiment", "test_user_2" - ).key, + variation.key, "variation", ) # different user, different experiment @@ -203,24 +213,27 @@ def test_set_forced_variation__multiple_sets(self): self.project_config, "group_exp_1", "test_user_2", "group_exp_1_control" ) ) + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "group_exp_1", "test_user_2" + ) self.assertEqual( - self.decision_service.get_forced_variation( - self.project_config, "group_exp_1", "test_user_2" - ).key, + variation.key, "group_exp_1_control", ) # make sure the first user forced variations are still valid + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "test_experiment", "test_user_1" + ) self.assertEqual( - self.decision_service.get_forced_variation( - self.project_config, "test_experiment", "test_user_1" - ).key, + variation.key, "control", ) + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "group_exp_1", "test_user_1" + ) self.assertEqual( - self.decision_service.get_forced_variation( - self.project_config, "group_exp_1", "test_user_1" - ).key, + variation.key, "group_exp_1_control", ) @@ -269,15 +282,17 @@ def test_get_forced_variation__invalid_user_id(self): "test_experiment" ] = "test_variation" + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "test_experiment", None + ) self.assertIsNone( - self.decision_service.get_forced_variation( - self.project_config, "test_experiment", None - ) + variation + ) + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "test_experiment", "" ) self.assertIsNone( - self.decision_service.get_forced_variation( - self.project_config, "test_experiment", "" - ) + variation ) def test_get_forced_variation__invalid_experiment_key(self): @@ -286,21 +301,23 @@ def test_get_forced_variation__invalid_experiment_key(self): self.decision_service.forced_variation_map["test_user"][ "test_experiment" ] = "test_variation" - + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "test_experiment_not_in_datafile", "test_user" + ) self.assertIsNone( - self.decision_service.get_forced_variation( - self.project_config, "test_experiment_not_in_datafile", "test_user" - ) + variation + ) + variation, _ = self.decision_service.get_forced_variation( + self.project_config, None, "test_user" ) self.assertIsNone( - self.decision_service.get_forced_variation( - self.project_config, None, "test_user" - ) + variation + ) + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "", "test_user" ) self.assertIsNone( - self.decision_service.get_forced_variation( - self.project_config, "", "test_user" - ) + variation ) def test_get_forced_variation_with_none_set_for_user(self): @@ -311,10 +328,11 @@ def test_get_forced_variation_with_none_set_for_user(self): with mock.patch.object( self.decision_service, "logger" ) as mock_decision_service_logging: + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "test_experiment", "test_user" + ) self.assertIsNone( - self.decision_service.get_forced_variation( - self.project_config, "test_experiment", "test_user" - ) + variation ) mock_decision_service_logging.debug.assert_called_once_with( 'No experiment "test_experiment" mapped to user "test_user" in the forced variation map.' @@ -331,10 +349,11 @@ def test_get_forced_variation_missing_variation_mapped_to_experiment(self): with mock.patch.object( self.decision_service, "logger" ) as mock_decision_service_logging: + variation, _ = self.decision_service.get_forced_variation( + self.project_config, "test_experiment", "test_user" + ) self.assertIsNone( - self.decision_service.get_forced_variation( - self.project_config, "test_experiment", "test_user" - ) + variation ) mock_decision_service_logging.debug.assert_called_once_with( @@ -348,11 +367,12 @@ def test_get_whitelisted_variation__user_in_forced_variation(self): with mock.patch.object( self.decision_service, "logger" ) as mock_decision_service_logging: + variation, _ = self.decision_service.get_whitelisted_variation( + self.project_config, experiment, "user_1" + ) self.assertEqual( entities.Variation("111128", "control"), - self.decision_service.get_whitelisted_variation( - self.project_config, experiment, "user_1" - ), + variation, ) mock_decision_service_logging.info.assert_called_once_with( @@ -367,10 +387,11 @@ def test_get_whitelisted_variation__user_in_invalid_variation(self): "optimizely.project_config.ProjectConfig.get_variation_from_key", return_value=None, ) as mock_get_variation_id: + variation, _ = self.decision_service.get_whitelisted_variation( + self.project_config, experiment, "user_1" + ) self.assertIsNone( - self.decision_service.get_whitelisted_variation( - self.project_config, experiment, "user_1" - ) + variation ) mock_get_variation_id.assert_called_once_with("test_experiment", "control") @@ -385,11 +406,12 @@ def test_get_stored_variation__stored_decision_available(self): with mock.patch.object( self.decision_service, "logger" ) as mock_decision_service_logging: + variation = self.decision_service.get_stored_variation( + self.project_config, experiment, profile + ) self.assertEqual( entities.Variation("111128", "control"), - self.decision_service.get_stored_variation( - self.project_config, experiment, profile - ), + variation, ) mock_decision_service_logging.info.assert_called_once_with( @@ -401,10 +423,11 @@ def test_get_stored_variation__no_stored_decision_available(self): experiment = self.project_config.get_experiment_from_key("test_experiment") profile = user_profile.UserProfile("test_user") + variation = self.decision_service.get_stored_variation( + self.project_config, experiment, profile + ) self.assertIsNone( - self.decision_service.get_stored_variation( - self.project_config, experiment, profile - ) + variation ) def test_get_variation__experiment_not_running(self): @@ -428,10 +451,11 @@ def test_get_variation__experiment_not_running(self): ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: + variation, _ = self.decision_service.get_variation( + self.project_config, experiment, "test_user", None + ) self.assertIsNone( - self.decision_service.get_variation( - self.project_config, experiment, "test_user", None - ) + variation ) mock_decision_service_logging.info.assert_called_once_with( @@ -451,16 +475,17 @@ def test_get_variation__bucketing_id_provided(self): experiment = self.project_config.get_experiment_from_key("test_experiment") with mock.patch( "optimizely.decision_service.DecisionService.get_forced_variation", - return_value=None, + return_value=[None, []], ), mock.patch( "optimizely.decision_service.DecisionService.get_stored_variation", return_value=None, ), mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=True + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[True, []] ), mock.patch( - "optimizely.bucketer.Bucketer.bucket" + "optimizely.bucketer.Bucketer.bucket", + return_value=[self.project_config.get_variation_from_id("211127", "211129"), []], ) as mock_bucket: - self.decision_service.get_variation( + variation, _ = self.decision_service.get_variation( self.project_config, experiment, "test_user", @@ -481,7 +506,7 @@ def test_get_variation__user_whitelisted_for_variation(self): experiment = self.project_config.get_experiment_from_key("test_experiment") with mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", - return_value=entities.Variation("111128", "control"), + return_value=[entities.Variation("111128", "control"), []], ) as mock_get_whitelisted_variation, mock.patch( "optimizely.decision_service.DecisionService.get_stored_variation" ) as mock_get_stored_variation, mock.patch( @@ -493,11 +518,12 @@ def test_get_variation__user_whitelisted_for_variation(self): ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: + variation, _ = self.decision_service.get_variation( + self.project_config, experiment, "test_user", None + ) self.assertEqual( entities.Variation("111128", "control"), - self.decision_service.get_variation( - self.project_config, experiment, "test_user", None - ), + variation, ) # Assert that forced variation is returned and stored decision or bucketing service are not involved @@ -516,7 +542,7 @@ def test_get_variation__user_has_stored_decision(self): experiment = self.project_config.get_experiment_from_key("test_experiment") with mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", - return_value=None, + return_value=[None, []], ) as mock_get_whitelisted_variation, mock.patch( "optimizely.decision_service.DecisionService.get_stored_variation", return_value=entities.Variation("111128", "control"), @@ -533,11 +559,12 @@ def test_get_variation__user_has_stored_decision(self): ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: + variation, _ = self.decision_service.get_variation( + self.project_config, experiment, "test_user", None + ) self.assertEqual( entities.Variation("111128", "control"), - self.decision_service.get_variation( - self.project_config, experiment, "test_user", None - ), + variation, ) # Assert that stored variation is returned and bucketing service is not involved @@ -567,26 +594,27 @@ def test_get_variation__user_bucketed_for_new_experiment__user_profile_service_a self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", - return_value=None, + return_value=[None, []], ) as mock_get_whitelisted_variation, mock.patch( "optimizely.decision_service.DecisionService.get_stored_variation", return_value=None, ) as mock_get_stored_variation, mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=True + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[True, []] ) as mock_audience_check, mock.patch( "optimizely.bucketer.Bucketer.bucket", - return_value=entities.Variation("111129", "variation"), + return_value=[entities.Variation("111129", "variation"), []], ) as mock_bucket, mock.patch( "optimizely.user_profile.UserProfileService.lookup", return_value={"user_id": "test_user", "experiment_bucket_map": {}}, ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: + variation, _ = self.decision_service.get_variation( + self.project_config, experiment, "test_user", None + ) self.assertEqual( entities.Variation("111129", "variation"), - self.decision_service.get_variation( - self.project_config, experiment, "test_user", None - ), + variation, ) # Assert that user is bucketed and new decision is stored @@ -627,24 +655,25 @@ def test_get_variation__user_bucketed_for_new_experiment__user_profile_service_n self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", - return_value=None, + return_value=[None, []], ) as mock_get_whitelisted_variation, mock.patch( "optimizely.decision_service.DecisionService.get_stored_variation" ) as mock_get_stored_variation, mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=True + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[True, []] ) as mock_audience_check, mock.patch( "optimizely.bucketer.Bucketer.bucket", - return_value=entities.Variation("111129", "variation"), + return_value=[entities.Variation("111129", "variation"), []], ) as mock_bucket, mock.patch( "optimizely.user_profile.UserProfileService.lookup" ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: + variation, _ = self.decision_service.get_variation( + self.project_config, experiment, "test_user", None + ) self.assertEqual( entities.Variation("111129", "variation"), - self.decision_service.get_variation( - self.project_config, experiment, "test_user", None - ), + variation, ) # Assert that user is bucketed and new decision is not stored as user profile service is not available @@ -674,12 +703,12 @@ def test_get_variation__user_does_not_meet_audience_conditions(self): self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", - return_value=None, + return_value=[None, []], ) as mock_get_whitelisted_variation, mock.patch( "optimizely.decision_service.DecisionService.get_stored_variation", return_value=None, ) as mock_get_stored_variation, mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=False + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[False, []] ) as mock_audience_check, mock.patch( "optimizely.bucketer.Bucketer.bucket" ) as mock_bucket, mock.patch( @@ -688,10 +717,11 @@ def test_get_variation__user_does_not_meet_audience_conditions(self): ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: + variation, _ = self.decision_service.get_variation( + self.project_config, experiment, "test_user", None + ) self.assertIsNone( - self.decision_service.get_variation( - self.project_config, experiment, "test_user", None - ) + variation ) # Assert that user is bucketed and new decision is stored @@ -721,25 +751,26 @@ def test_get_variation__user_profile_in_invalid_format(self): self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", - return_value=None, + return_value=[None, []], ) as mock_get_whitelisted_variation, mock.patch( "optimizely.decision_service.DecisionService.get_stored_variation" ) as mock_get_stored_variation, mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=True + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[True, []] ) as mock_audience_check, mock.patch( "optimizely.bucketer.Bucketer.bucket", - return_value=entities.Variation("111129", "variation"), + return_value=[entities.Variation("111129", "variation"), []], ) as mock_bucket, mock.patch( "optimizely.user_profile.UserProfileService.lookup", return_value="invalid_profile", ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: + variation, _ = self.decision_service.get_variation( + self.project_config, experiment, "test_user", None + ) self.assertEqual( entities.Variation("111129", "variation"), - self.decision_service.get_variation( - self.project_config, experiment, "test_user", None - ), + variation, ) # Assert that user is bucketed and new decision is stored @@ -778,25 +809,26 @@ def test_get_variation__user_profile_lookup_fails(self): self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", - return_value=None, + return_value=[None, []], ) as mock_get_whitelisted_variation, mock.patch( "optimizely.decision_service.DecisionService.get_stored_variation" ) as mock_get_stored_variation, mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=True + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[True, []] ) as mock_audience_check, mock.patch( "optimizely.bucketer.Bucketer.bucket", - return_value=entities.Variation("111129", "variation"), + return_value=[entities.Variation("111129", "variation"), []], ) as mock_bucket, mock.patch( "optimizely.user_profile.UserProfileService.lookup", side_effect=Exception("major problem"), ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: + variation, _ = self.decision_service.get_variation( + self.project_config, experiment, "test_user", None + ) self.assertEqual( entities.Variation("111129", "variation"), - self.decision_service.get_variation( - self.project_config, experiment, "test_user", None - ), + variation, ) # Assert that user is bucketed and new decision is stored @@ -835,25 +867,26 @@ def test_get_variation__user_profile_save_fails(self): self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", - return_value=None, + return_value=[None, []], ) as mock_get_whitelisted_variation, mock.patch( "optimizely.decision_service.DecisionService.get_stored_variation" ) as mock_get_stored_variation, mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=True + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[True, []] ) as mock_audience_check, mock.patch( "optimizely.bucketer.Bucketer.bucket", - return_value=entities.Variation("111129", "variation"), + return_value=[entities.Variation("111129", "variation"), []], ) as mock_bucket, mock.patch( "optimizely.user_profile.UserProfileService.lookup", return_value=None ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save", side_effect=Exception("major problem"), ) as mock_save: + variation, _ = self.decision_service.get_variation( + self.project_config, experiment, "test_user", None + ) self.assertEqual( entities.Variation("111129", "variation"), - self.decision_service.get_variation( - self.project_config, experiment, "test_user", None - ), + variation, ) # Assert that user is bucketed and new decision is stored @@ -891,26 +924,27 @@ def test_get_variation__ignore_user_profile_when_specified(self): self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", - return_value=None, + return_value=[None, []], ) as mock_get_whitelisted_variation, mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=True + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[True, []] ) as mock_audience_check, mock.patch( "optimizely.bucketer.Bucketer.bucket", - return_value=entities.Variation("111129", "variation"), + return_value=[entities.Variation("111129", "variation"), []], ) as mock_bucket, mock.patch( "optimizely.user_profile.UserProfileService.lookup" ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: + variation, _ = self.decision_service.get_variation( + self.project_config, + experiment, + "test_user", + None, + ignore_user_profile=True, + ) self.assertEqual( entities.Variation("111129", "variation"), - self.decision_service.get_variation( - self.project_config, - experiment, - "test_user", - None, - ignore_user_profile=True, - ), + variation, ) # Assert that user is bucketed and new decision is NOT stored @@ -946,11 +980,12 @@ def test_get_variation_for_rollout__returns_none_if_no_experiments(self): with self.mock_config_logger as mock_logging: no_experiment_rollout = self.project_config.get_rollout_from_id("201111") + variation_received, _ = self.decision_service.get_variation_for_rollout( + self.project_config, no_experiment_rollout, "test_user" + ) self.assertEqual( decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), - self.decision_service.get_variation_for_rollout( - self.project_config, no_experiment_rollout, "test_user" - ), + variation_received, ) # Assert no log messages were generated @@ -963,20 +998,21 @@ def test_get_variation_for_rollout__returns_decision_if_user_in_rollout(self): rollout = self.project_config.get_rollout_from_id("211111") with mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=True + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[True, []] ), self.mock_decision_logger as mock_decision_service_logging, mock.patch( "optimizely.bucketer.Bucketer.bucket", - return_value=self.project_config.get_variation_from_id("211127", "211129"), + return_value=[self.project_config.get_variation_from_id("211127", "211129"), []], ) as mock_bucket: + variation_received, _ = self.decision_service.get_variation_for_rollout( + self.project_config, rollout, "test_user" + ) self.assertEqual( decision_service.Decision( self.project_config.get_experiment_from_id("211127"), self.project_config.get_variation_from_id("211127", "211129"), enums.DecisionSources.ROLLOUT, ), - self.decision_service.get_variation_for_rollout( - self.project_config, rollout, "test_user" - ), + variation_received, ) # Check all log messages @@ -998,23 +1034,24 @@ def test_get_variation_for_rollout__calls_bucket_with_bucketing_id(self): rollout = self.project_config.get_rollout_from_id("211111") with mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=True + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[True, []] ), self.mock_decision_logger as mock_decision_service_logging, mock.patch( "optimizely.bucketer.Bucketer.bucket", - return_value=self.project_config.get_variation_from_id("211127", "211129"), + return_value=[self.project_config.get_variation_from_id("211127", "211129"), []], ) as mock_bucket: + variation_received, _ = self.decision_service.get_variation_for_rollout( + self.project_config, + rollout, + "test_user", + {"$opt_bucketing_id": "user_bucket_value"}, + ) self.assertEqual( decision_service.Decision( self.project_config.get_experiment_from_id("211127"), self.project_config.get_variation_from_id("211127", "211129"), enums.DecisionSources.ROLLOUT, ), - self.decision_service.get_variation_for_rollout( - self.project_config, - rollout, - "test_user", - {"$opt_bucketing_id": "user_bucket_value"}, - ), + variation_received, ) # Check all log messages @@ -1040,17 +1077,18 @@ def test_get_variation_for_rollout__skips_to_everyone_else_rule(self): ) with mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=True + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[True, []] ) as mock_audience_check, self.mock_decision_logger as mock_decision_service_logging, mock.patch( - "optimizely.bucketer.Bucketer.bucket", side_effect=[None, variation_to_mock] + "optimizely.bucketer.Bucketer.bucket", side_effect=[[None, []], [variation_to_mock, []]] ): + variation_received, _ = self.decision_service.get_variation_for_rollout( + self.project_config, rollout, "test_user" + ) self.assertEqual( decision_service.Decision( everyone_else_exp, variation_to_mock, enums.DecisionSources.ROLLOUT ), - self.decision_service.get_variation_for_rollout( - self.project_config, rollout, "test_user" - ), + variation_received, ) # Check that after first experiment, it skips to the last experiment to check @@ -1096,13 +1134,14 @@ def test_get_variation_for_rollout__returns_none_for_user_not_in_rollout(self): rollout = self.project_config.get_rollout_from_id("211111") with mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=False + "optimizely.helpers.audience.does_user_meet_audience_conditions", return_value=[False, []] ) as mock_audience_check, self.mock_decision_logger as mock_decision_service_logging: + variation_received, _ = self.decision_service.get_variation_for_rollout( + self.project_config, rollout, "test_user" + ) self.assertEqual( decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), - self.decision_service.get_variation_for_rollout( - self.project_config, rollout, "test_user" - ), + variation_received, ) # Check that all experiments in rollout layer were checked @@ -1164,18 +1203,19 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_experiment( ) decision_patch = mock.patch( "optimizely.decision_service.DecisionService.get_variation", - return_value=expected_variation, + return_value=[expected_variation, []], ) with decision_patch as mock_decision, self.mock_decision_logger: + variation_received, _ = self.decision_service.get_variation_for_feature( + self.project_config, feature, "test_user" + ) self.assertEqual( decision_service.Decision( expected_experiment, expected_variation, enums.DecisionSources.FEATURE_TEST, ), - self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user" - ), + variation_received, ) mock_decision.assert_called_once_with( @@ -1183,11 +1223,12 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_experiment( self.project_config.get_experiment_from_key("test_experiment"), "test_user", None, + False ) def test_get_variation_for_feature__returns_variation_for_feature_in_rollout(self): """ Test that get_variation_for_feature returns the variation of - the experiment in the rollout that the user is bucketed into. """ + the experiment in the rollout that the user is bucketed into. """ feature = self.project_config.get_feature_from_key("test_feature_in_rollout") @@ -1196,16 +1237,16 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_rollout(sel ) get_variation_for_rollout_patch = mock.patch( "optimizely.decision_service.DecisionService.get_variation_for_rollout", - return_value=expected_variation, + return_value=[expected_variation, None], ) - with \ - get_variation_for_rollout_patch as mock_get_variation_for_rollout, \ + with get_variation_for_rollout_patch as mock_get_variation_for_rollout, \ self.mock_decision_logger as mock_decision_service_logging: + variation_received, _ = self.decision_service.get_variation_for_feature( + self.project_config, feature, "test_user" + ) self.assertEqual( expected_variation, - self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user" - ), + variation_received, ) expected_rollout = self.project_config.get_rollout_from_id("211111") @@ -1221,7 +1262,7 @@ def test_get_variation_for_feature__returns_variation_if_user_not_in_experiment_ self, ): """ Test that get_variation_for_feature returns the variation of the experiment in the - feature's rollout even if the user is not bucketed into the feature's experiment. """ + feature's rollout even if the user is not bucketed into the feature's experiment. """ feature = self.project_config.get_feature_from_key( "test_feature_in_experiment_and_rollout" @@ -1233,19 +1274,20 @@ def test_get_variation_for_feature__returns_variation_if_user_not_in_experiment_ ) with mock.patch( "optimizely.helpers.audience.does_user_meet_audience_conditions", - side_effect=[False, True], + side_effect=[[False, []], [True, []]], ) as mock_audience_check, self.mock_decision_logger as mock_decision_service_logging, mock.patch( - "optimizely.bucketer.Bucketer.bucket", return_value=expected_variation - ): + "optimizely.bucketer.Bucketer.bucket", return_value=[expected_variation, []]): + + decision, _ = self.decision_service.get_variation_for_feature( + self.project_config, feature, "test_user" + ) self.assertEqual( decision_service.Decision( expected_experiment, expected_variation, enums.DecisionSources.ROLLOUT, ), - self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user" - ), + decision, ) self.assertEqual(2, mock_audience_check.call_count) @@ -1257,6 +1299,7 @@ def test_get_variation_for_feature__returns_variation_if_user_not_in_experiment_ None, mock_decision_service_logging, ) + mock_audience_check.assert_any_call( self.project_config, self.project_config.get_experiment_from_key("211127").get_audience_conditions_or_ids(), @@ -1278,30 +1321,32 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_group(self) ) with mock.patch( "optimizely.decision_service.DecisionService.get_experiment_in_group", - return_value=self.project_config.get_experiment_from_key("group_exp_1"), + return_value=(self.project_config.get_experiment_from_key("group_exp_1"), []), ) as mock_get_experiment_in_group, mock.patch( "optimizely.decision_service.DecisionService.get_variation", - return_value=expected_variation, + return_value=(expected_variation, []), ) as mock_decision: + variation_received, _ = self.decision_service.get_variation_for_feature( + self.project_config, feature, "test_user" + ) self.assertEqual( decision_service.Decision( expected_experiment, expected_variation, enums.DecisionSources.FEATURE_TEST, ), - self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user" - ), + variation_received, ) mock_get_experiment_in_group.assert_called_once_with( - self.project_config, self.project_config.get_group("19228"), "test_user" - ) + self.project_config, self.project_config.get_group("19228"), 'test_user') + mock_decision.assert_called_once_with( self.project_config, self.project_config.get_experiment_from_key("group_exp_1"), "test_user", None, + False ) def test_get_variation_for_feature__returns_none_for_user_not_in_group(self): @@ -1312,20 +1357,21 @@ def test_get_variation_for_feature__returns_none_for_user_not_in_group(self): with mock.patch( "optimizely.decision_service.DecisionService.get_experiment_in_group", - return_value=None, + return_value=[None, []], ) as mock_get_experiment_in_group, mock.patch( "optimizely.decision_service.DecisionService.get_variation" ) as mock_decision: + variation_received, _ = self.decision_service.get_variation_for_feature( + self.project_config, feature, "test_user" + ) self.assertEqual( decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), - self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user" - ), + variation_received, ) mock_get_experiment_in_group.assert_called_once_with( - self.project_config, self.project_config.get_group("19228"), "test_user" - ) + self.project_config, self.project_config.get_group("19228"), "test_user") + self.assertFalse(mock_decision.called) def test_get_variation_for_feature__returns_none_for_user_not_in_experiment(self): @@ -1335,13 +1381,14 @@ def test_get_variation_for_feature__returns_none_for_user_not_in_experiment(self with mock.patch( "optimizely.decision_service.DecisionService.get_variation", - return_value=None, + return_value=[None, []], ) as mock_decision: + variation_received, _ = self.decision_service.get_variation_for_feature( + self.project_config, feature, "test_user" + ) self.assertEqual( decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), - self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user" - ), + variation_received, ) mock_decision.assert_called_once_with( @@ -1349,6 +1396,7 @@ def test_get_variation_for_feature__returns_none_for_user_not_in_experiment(self self.project_config.get_experiment_from_key("test_experiment"), "test_user", None, + False ) def test_get_variation_for_feature__returns_none_for_invalid_group_id(self): @@ -1358,11 +1406,12 @@ def test_get_variation_for_feature__returns_none_for_invalid_group_id(self): feature.groupId = "aabbccdd" with self.mock_decision_logger as mock_decision_service_logging: + variation_received, _ = self.decision_service.get_variation_for_feature( + self.project_config, feature, "test_user" + ) self.assertEqual( decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), - self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user" - ), + variation_received, ) mock_decision_service_logging.error.assert_called_once_with( enums.Errors.INVALID_GROUP_ID.format("_get_variation_for_feature") @@ -1378,13 +1427,14 @@ def test_get_variation_for_feature__returns_none_for_user_in_group_experiment_no with mock.patch( "optimizely.decision_service.DecisionService.get_experiment_in_group", - return_value=self.project_config.get_experiment_from_key("group_exp_2"), + return_value=[self.project_config.get_experiment_from_key("group_exp_2"), []], ) as mock_decision: + variation_received, _ = self.decision_service.get_variation_for_feature( + self.project_config, feature, "test_user" + ) self.assertEqual( decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), - self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user" - ), + variation_received, ) mock_decision.assert_called_once_with( @@ -1399,11 +1449,12 @@ def test_get_experiment_in_group(self): with mock.patch( "optimizely.bucketer.Bucketer.find_bucket", return_value="32222" ), self.mock_decision_logger as mock_decision_service_logging: + variation_received, _ = self.decision_service.get_experiment_in_group( + self.project_config, group, "test_user" + ) self.assertEqual( experiment, - self.decision_service.get_experiment_in_group( - self.project_config, group, "test_user" - ), + variation_received, ) mock_decision_service_logging.info.assert_called_once_with( @@ -1417,10 +1468,11 @@ def test_get_experiment_in_group__returns_none_if_user_not_in_group(self): with mock.patch( "optimizely.bucketer.Bucketer.find_bucket", return_value=None ), self.mock_decision_logger as mock_decision_service_logging: + variation_received, _ = self.decision_service.get_experiment_in_group( + self.project_config, group, "test_user" + ) self.assertIsNone( - self.decision_service.get_experiment_in_group( - self.project_config, group, "test_user" - ) + variation_received ) mock_decision_service_logging.info.assert_called_once_with( diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index e88c308c..1c21dc6a 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -1,4 +1,4 @@ -# Copyright 2016-2020, Optimizely +# Copyright 2016-2021, 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 @@ -26,7 +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.decision.optimizely_decide_option import OptimizelyDecideOption as DecideOption from optimizely.event.event_factory import EventFactory from optimizely.helpers import enums from . import base @@ -304,7 +304,7 @@ def test_activate(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ) as mock_decision, mock.patch('time.time', return_value=42), mock.patch( 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch( @@ -383,7 +383,7 @@ def on_activate(experiment, user_id, attributes, variation, event): ) with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process'): self.assertEqual('variation', self.optimizely.activate('test_experiment', 'test_user')) @@ -416,7 +416,7 @@ def on_track(event_key, user_id, attributes, event_tags, event): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process'): self.optimizely.track('test_event', 'test_user') @@ -444,7 +444,7 @@ def on_activate(event_key, user_id, attributes, event_tags, event): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast: @@ -484,7 +484,7 @@ def on_activate(event_key, user_id, attributes, event_tags, event): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast: @@ -520,7 +520,8 @@ def test_decision_listener__user_not_in_experiment(self): """ Test that activate calls broadcast decision with variation_key 'None' \ when user not in experiment. """ - with mock.patch('optimizely.decision_service.DecisionService.get_variation', return_value=None,), mock.patch( + with mock.patch('optimizely.decision_service.DecisionService.get_variation', + return_value=(None, []),), mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' @@ -545,7 +546,7 @@ def on_track(event_key, user_id, attributes, event_tags, event): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111128'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111128'), []), ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_event_tracked: @@ -567,7 +568,7 @@ def on_track(event_key, user_id, attributes, event_tags, event): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111128'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111128'), []), ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_event_tracked: @@ -594,7 +595,7 @@ def on_track(event_key, user_id, attributes, event_tags, event): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111128'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111128'), []), ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_event_tracked: @@ -636,7 +637,8 @@ def on_activate(experiment, user_id, attributes, variation, event): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=( + decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), []), ) as mock_decision, mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process'): self.assertTrue(opt_obj.is_feature_enabled('test_feature_in_experiment', 'test_user')) @@ -662,7 +664,8 @@ def on_activate(experiment, user_id, attributes, variation, event): 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.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process: @@ -685,10 +688,11 @@ def test_decide_experiment(self): 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), + 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]) + decision = user_context.decide('test_feature_in_experiment', [DecideOption.DISABLE_DECISION_EVENT]) self.assertTrue(decision.enabled, "decision should be enabled") def test_activate__with_attributes__audience_match(self): @@ -697,7 +701,7 @@ def test_activate__with_attributes__audience_match(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ) as mock_get_variation, mock.patch('time.time', return_value=42), mock.patch( 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch( @@ -768,7 +772,7 @@ def test_activate__with_attributes_of_different_types(self): with mock.patch( 'optimizely.bucketer.Bucketer.bucket', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ) as mock_bucket, mock.patch('time.time', return_value=42), mock.patch( 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch( @@ -1042,7 +1046,7 @@ def test_activate__with_attributes__audience_match__bucketing_id_provided(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ) as mock_get_variation, mock.patch('time.time', return_value=42), mock.patch( 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch( @@ -1122,7 +1126,7 @@ def test_activate__with_attributes__no_audience_match(self): """ Test that activate returns None when audience conditions do not match. """ with mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions', - return_value=False) as mock_audience_check: + return_value=(False, [])) as mock_audience_check: self.assertIsNone( self.optimizely.activate('test_experiment', 'test_user', attributes={'test_attribute': 'test_value'},) ) @@ -1189,9 +1193,9 @@ def test_activate__bucketer_returns_none(self): with mock.patch( 'optimizely.helpers.audience.does_user_meet_audience_conditions', - return_value=True), mock.patch( + return_value=(True, [])), mock.patch( 'optimizely.bucketer.Bucketer.bucket', - return_value=None) as mock_bucket, mock.patch( + return_value=(None, [])) as mock_bucket, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process: self.assertIsNone( @@ -1780,7 +1784,7 @@ def test_get_variation(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ), mock.patch('optimizely.notification_center.NotificationCenter.send_notifications') as mock_broadcast: self.assertEqual( 'variation', self.optimizely.get_variation('test_experiment', 'test_user'), @@ -1805,7 +1809,7 @@ def test_get_variation_with_experiment_in_feature(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(project_config.get_variation_from_id('test_experiment', '111129'), []), ), mock.patch('optimizely.notification_center.NotificationCenter.send_notifications') as mock_broadcast: self.assertEqual('variation', opt_obj.get_variation('test_experiment', 'test_user')) @@ -1822,7 +1826,8 @@ def test_get_variation_with_experiment_in_feature(self): def test_get_variation__returns_none(self): """ Test that get_variation returns no variation and broadcasts decision with proper parameters. """ - with mock.patch('optimizely.decision_service.DecisionService.get_variation', return_value=None,), mock.patch( + with mock.patch('optimizely.decision_service.DecisionService.get_variation', + return_value=(None, []),), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast: self.assertEqual( @@ -1980,7 +1985,8 @@ def test_is_feature_enabled__returns_true_for_feature_experiment_if_feature_enab with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process, mock.patch( @@ -2078,7 +2084,8 @@ def test_is_feature_enabled__returns_false_for_feature_experiment_if_feature_dis with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process, mock.patch( @@ -2176,7 +2183,8 @@ def test_is_feature_enabled__returns_true_for_feature_rollout_if_feature_enabled with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process, mock.patch( @@ -2224,7 +2232,8 @@ def test_is_feature_enabled__returns_true_for_feature_rollout_if_feature_enabled with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process, mock.patch( @@ -2324,7 +2333,8 @@ def test_is_feature_enabled__returns_false_for_feature_rollout_if_feature_disabl with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process, mock.patch( @@ -2364,7 +2374,7 @@ def test_is_feature_enabled__returns_false_when_user_is_not_bucketed_into_any_va feature = project_config.get_feature_from_key('test_feature_in_experiment') with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process, mock.patch( @@ -2406,7 +2416,7 @@ def test_is_feature_enabled__returns_false_when_variation_is_nil(self,): feature = project_config.get_feature_from_key('test_feature_in_experiment_and_rollout') with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process, mock.patch( @@ -2509,14 +2519,19 @@ def test_get_enabled_features__broadcasts_decision_for_each_feature(self): def side_effect(*args, **kwargs): feature = args[1] + response = None if feature.key == 'test_feature_in_experiment': - return decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST) + response = decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST) elif feature.key == 'test_feature_in_rollout': - return decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT) + response = decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT) elif feature.key == 'test_feature_in_experiment_and_rollout': - return decision_service.Decision(mock_experiment, mock_variation_2, enums.DecisionSources.FEATURE_TEST,) + response = decision_service.Decision( + mock_experiment, mock_variation_2, enums.DecisionSources.FEATURE_TEST,) else: - return decision_service.Decision(mock_experiment, mock_variation_2, enums.DecisionSources.ROLLOUT) + response = decision_service.Decision(mock_experiment, mock_variation_2, enums.DecisionSources.ROLLOUT) + + return (response, []) with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', side_effect=side_effect, @@ -2640,7 +2655,8 @@ def test_get_feature_variable_boolean(self): 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), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2677,7 +2693,8 @@ def test_get_feature_variable_double(self): 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), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2714,7 +2731,8 @@ def test_get_feature_variable_integer(self): 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), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2751,7 +2769,8 @@ def test_get_feature_variable_string(self): 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), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2789,7 +2808,8 @@ def test_get_feature_variable_json(self): 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), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2835,7 +2855,8 @@ def test_get_all_feature_variables(self): '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), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2891,7 +2912,8 @@ def test_get_feature_variable(self): # Boolean with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2919,7 +2941,8 @@ def test_get_feature_variable(self): # Double with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2949,7 +2972,8 @@ def test_get_feature_variable(self): # Integer with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2979,7 +3003,8 @@ def test_get_feature_variable(self): # String with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3009,7 +3034,8 @@ def test_get_feature_variable(self): # 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), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3048,7 +3074,8 @@ def test_get_feature_variable_boolean_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3089,7 +3116,8 @@ def test_get_feature_variable_double_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3130,7 +3158,8 @@ def test_get_feature_variable_integer_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3171,7 +3200,8 @@ def test_get_feature_variable_string_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3212,7 +3242,8 @@ def test_get_feature_variable_json_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3253,7 +3284,8 @@ def test_get_all_feature_variables_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3306,7 +3338,8 @@ def test_get_feature_variable_for_feature_in_rollout(self): # Boolean with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3338,7 +3371,8 @@ def test_get_feature_variable_for_feature_in_rollout(self): # Double with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3370,7 +3404,8 @@ def test_get_feature_variable_for_feature_in_rollout(self): # Integer with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3402,7 +3437,8 @@ def test_get_feature_variable_for_feature_in_rollout(self): # String with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3435,7 +3471,8 @@ 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), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3478,7 +3515,8 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va # Boolean with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ): self.assertTrue( opt_obj.get_feature_variable_boolean('test_feature_in_experiment', 'is_working', 'test_user') @@ -3487,7 +3525,8 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va # Double with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ): self.assertEqual( 10.99, opt_obj.get_feature_variable_double('test_feature_in_experiment', 'cost', 'test_user'), @@ -3496,7 +3535,8 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va # Integer with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ): self.assertEqual( 999, opt_obj.get_feature_variable_integer('test_feature_in_experiment', 'count', 'test_user'), @@ -3505,7 +3545,8 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va # String with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ): self.assertEqual( 'devel', opt_obj.get_feature_variable_string('test_feature_in_experiment', 'environment', 'test_user'), @@ -3514,7 +3555,8 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va # 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), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ): self.assertEqual( {"test": 12}, opt_obj.get_feature_variable_json('test_feature_in_experiment', 'object', 'test_user'), @@ -3523,13 +3565,15 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va # Non-typed with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ): self.assertTrue(opt_obj.get_feature_variable('test_feature_in_experiment', 'is_working', 'test_user')) with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ): self.assertEqual( 10.99, opt_obj.get_feature_variable('test_feature_in_experiment', 'cost', 'test_user'), @@ -3537,7 +3581,8 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ): self.assertEqual( 999, opt_obj.get_feature_variable('test_feature_in_experiment', 'count', 'test_user'), @@ -3545,7 +3590,8 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ): self.assertEqual( 'devel', opt_obj.get_feature_variable('test_feature_in_experiment', 'environment', 'test_user'), @@ -3560,7 +3606,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # Boolean with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + 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: @@ -3594,7 +3640,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # Double with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + 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: @@ -3628,7 +3674,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # Integer with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + 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: @@ -3662,7 +3708,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # String with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + 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: @@ -3696,7 +3742,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # JSON with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + 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: @@ -3730,7 +3776,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # Non-typed with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + 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: @@ -3761,7 +3807,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + 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: @@ -3794,7 +3840,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + 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: @@ -3827,7 +3873,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + 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: @@ -4140,7 +4186,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self # Boolean with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertTrue( @@ -4155,7 +4202,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self # Double with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + 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( 10.99, opt_obj.get_feature_variable_double('test_feature_in_experiment', 'cost', 'test_user'), @@ -4169,7 +4217,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self # Integer with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + 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( 999, opt_obj.get_feature_variable_integer('test_feature_in_experiment', 'count', 'test_user'), @@ -4183,7 +4232,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self # String with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + 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( 'devel', opt_obj.get_feature_variable_string('test_feature_in_experiment', 'environment', 'test_user'), @@ -4197,7 +4247,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self # 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), + 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'), @@ -4211,7 +4262,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self # Non-typed with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertTrue(opt_obj.get_feature_variable('test_feature_in_experiment', 'is_working', 'test_user')) @@ -4223,7 +4275,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + 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( 10.99, opt_obj.get_feature_variable('test_feature_in_experiment', 'cost', 'test_user'), @@ -4236,7 +4289,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + 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( 999, opt_obj.get_feature_variable('test_feature_in_experiment', 'count', 'test_user'), @@ -4249,7 +4303,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), + 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( 'devel', opt_obj.get_feature_variable('test_feature_in_experiment', 'environment', 'test_user'), @@ -4270,7 +4325,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r # Boolean with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertFalse(opt_obj.get_feature_variable_boolean('test_feature_in_rollout', 'is_running', 'test_user')) @@ -4282,7 +4338,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r # Double with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 99.99, opt_obj.get_feature_variable_double('test_feature_in_rollout', 'price', 'test_user'), @@ -4296,7 +4353,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r # Integer with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 999, opt_obj.get_feature_variable_integer('test_feature_in_rollout', 'count', 'test_user'), @@ -4310,7 +4368,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r # String with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 'Hello', opt_obj.get_feature_variable_string('test_feature_in_rollout', 'message', 'test_user'), @@ -4323,7 +4382,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r # JSON with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + 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'), @@ -4336,7 +4396,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r # Non-typed with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertFalse(opt_obj.get_feature_variable('test_feature_in_rollout', 'is_running', 'test_user')) @@ -4347,7 +4408,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 99.99, opt_obj.get_feature_variable('test_feature_in_rollout', 'price', 'test_user'), @@ -4360,7 +4422,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 999, opt_obj.get_feature_variable('test_feature_in_rollout', 'count', 'test_user'), @@ -4373,7 +4436,8 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 'Hello', opt_obj.get_feature_variable('test_feature_in_rollout', 'message', 'test_user'), @@ -4391,7 +4455,8 @@ def test_get_feature_variable__returns_none_if_type_mismatch(self): 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), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: # "is_working" is boolean variable and we are using double method on it. self.assertIsNone( @@ -4411,7 +4476,8 @@ def test_get_feature_variable__returns_none_if_unable_to_cast(self): 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), + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch( 'optimizely.project_config.ProjectConfig.get_typecast_value', side_effect=ValueError(), ), mock.patch.object( @@ -4628,7 +4694,7 @@ def test_activate(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ), mock.patch('time.time', return_value=42), mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ), mock.patch.object( @@ -4769,7 +4835,7 @@ def test_activate__empty_user_id(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129'), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ), mock.patch('time.time', return_value=42), mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ), mock.patch.object( diff --git a/tests/test_user_context.py b/tests/test_user_context.py index 4312b1cb..abc18a87 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -1,4 +1,4 @@ -# Copyright 2020, Optimizely +# Copyright 2021, 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 @@ -11,348 +11,1237 @@ # See the License for the specific language governing permissions and # limitations under the License. import json -import logging import mock -from optimizely.decision.decide_option import DecideOption -from optimizely.event.event_factory import EventFactory +from optimizely.decision.optimizely_decision import OptimizelyDecision from optimizely.helpers import enums -from optimizely.user_profile import UserProfileService, UserProfile from . import base -from optimizely import logger, optimizely, decision_service -from optimizely.user_context import UserContext +from optimizely import optimizely, decision_service +from optimizely.optimizely_user_context import OptimizelyUserContext +from optimizely.user_profile import UserProfileService -class UserContextTests(base.BaseTest): +class UserContextTest(base.BaseTest): def setUp(self): base.BaseTest.setUp(self, 'config_dict_with_multiple_experiments') - self.logger = logger.NoOpLogger() + + def compare_opt_decisions(self, expected, actual): + self.assertEqual(expected.variation_key, actual.variation_key) + self.assertEqual(expected.enabled, actual.enabled) + self.assertEqual(expected.rule_key, actual.rule_key) + self.assertEqual(expected.flag_key, actual.flag_key) + self.assertEqual(expected.variables, actual.variables) + self.assertEqual(expected.user_context.user_id, actual.user_context.user_id) + self.assertEqual(expected.user_context.get_user_attributes(), actual.user_context.get_user_attributes()) def test_user_context(self): """ - tests user context creating and attributes + tests user context creating and setting 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): + uc = OptimizelyUserContext(self.optimizely, "test_user") + # user attribute should be empty dict + self.assertEqual({}, uc.get_user_attributes()) + + # user id should be as provided in constructor + self.assertEqual("test_user", uc.user_id) + + # set attribute + uc.set_attribute("browser", "chrome") + self.assertEqual("chrome", uc.get_user_attributes()["browser"], ) + + # set another attribute + uc.set_attribute("color", "red") + self.assertEqual("chrome", uc.get_user_attributes()["browser"]) + self.assertEqual("red", uc.get_user_attributes()["color"]) + + # override existing attribute + uc.set_attribute("browser", "firefox") + self.assertEqual("firefox", uc.get_user_attributes()["browser"]) + self.assertEqual("red", uc.get_user_attributes()["color"]) + + def test_attributes_are_cloned_when_passed_to_user_context(self): + user_id = 'test_user' + attributes = {"browser": "chrome"} + uc = OptimizelyUserContext(self.optimizely, user_id, attributes) + self.assertEqual(attributes, uc.get_user_attributes()) + attributes['new_key'] = 'test_value' + self.assertNotEqual(attributes, uc.get_user_attributes()) + + def test_attributes_default_to_dict_when_passes_as_non_dict(self): + uc = OptimizelyUserContext(self.optimizely, "test_user", True) + # user attribute should be empty dict + self.assertEqual({}, uc.get_user_attributes()) + + uc = OptimizelyUserContext(self.optimizely, "test_user", 10) + # user attribute should be empty dict + self.assertEqual({}, uc.get_user_attributes()) + + uc = OptimizelyUserContext(self.optimizely, "test_user", 'helloworld') + # user attribute should be empty dict + self.assertEqual({}, uc.get_user_attributes()) + + uc = OptimizelyUserContext(self.optimizely, "test_user", []) + # user attribute should be empty dict + self.assertEqual({}, uc.get_user_attributes()) + + def test_user_context_is_cloned_when_passed_to_optimizely_APIs(self): + """ Test that the user context in decide response is not the same object on which + the decide was called """ + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + user_context = opt_obj.create_user_context('test_user') + + # decide + decision = user_context.decide('test_feature_in_rollout') + self.assertNotEqual(user_context, decision.user_context) + + # decide_all + decisions = user_context.decide_all() + self.assertNotEqual(user_context, decisions['test_feature_in_rollout'].user_context) + + # decide_for_keys + decisions = user_context.decide_for_keys(['test_feature_in_rollout']) + self.assertNotEqual(user_context, decisions['test_feature_in_rollout'].user_context) + + def test_decide__SDK_not_ready(self): + opt_obj = optimizely.Optimizely("") + user_context = opt_obj.create_user_context('test_user') + + expected = OptimizelyDecision( + variation_key=None, + rule_key=None, + enabled=False, + variables={}, + flag_key='test_feature', + user_context=user_context + ) + + actual = user_context.decide('test_feature') + + self.compare_opt_decisions(expected, actual) + + self.assertIn( + 'Optimizely SDK not configured properly yet.', + actual.reasons + ) + + def test_decide__invalid_flag_key(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + user_context = opt_obj.create_user_context('test_user', {'some-key': 'some-value'}) + + expected = OptimizelyDecision( + variation_key=None, + rule_key=None, + enabled=False, + variables={}, + flag_key=123, + user_context=user_context + ) + + actual = user_context.decide(123) + + self.compare_opt_decisions(expected, actual) + + self.assertIn( + 'No flag was found for key "123".', + actual.reasons + ) + + def test_decide__unknown_flag_key(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + user_context = opt_obj.create_user_context('test_user') + + expected = OptimizelyDecision( + variation_key=None, + rule_key=None, + enabled=False, + variables={}, + flag_key='unknown_flag_key', + user_context=user_context + ) + + actual = user_context.decide('unknown_flag_key') + + self.compare_opt_decisions(expected, actual) + + self.assertIn( + 'No flag was found for key "unknown_flag_key".', + actual.reasons + ) + + 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") + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST), []), + ), mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: + + user_context = opt_obj.create_user_context('test_user', {'browser': 'chrome'}) + actual = user_context.decide('test_feature_in_experiment') + + expected_variables = { + 'is_working': True, + 'environment': 'staging', + 'cost': 10.02, + 'count': 4243, + 'variable_without_usage': 45, + 'object': {"test": 123}, + 'true_object': {"true_test": 1.4} + } - 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. """ + expected = OptimizelyDecision( + variation_key='variation', + rule_key='test_experiment', + enabled=True, + variables=expected_variables, + flag_key='test_feature_in_experiment', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) + # assert notification + mock_broadcast_decision.assert_called_with( + enums.NotificationTypes.DECISION, + 'flag', + 'test_user', + {'browser': 'chrome'}, + { + 'flag_key': expected.flag_key, + 'enabled': expected.enabled, + 'variation_key': expected.variation_key, + 'rule_key': expected.rule_key, + 'reasons': expected.reasons, + 'decision_event_dispatched': True, + 'variables': expected.variables, + }, + ) + + # assert event count + self.assertEqual(1, mock_send_event.call_count) + + # assert event payload + mock_send_event.assert_called_with( + project_config, + mock_experiment, + mock_variation, + expected.flag_key, + expected.rule_key, + 'feature-test', + expected.enabled, + 'test_user', + {'browser': 'chrome'} + ) + + def test_decide__feature_test__send_flag_decision_false(self): opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + project_config = opt_obj.config_manager.get_config() + project_config.send_flag_decisions = False - user_context = opt_obj.create_user_context('test_user') - decision = opt_obj.decide(user_context, 'test_feature_in_rollout') - self.assertFalse(decision.enabled) - self.assertEqual(decision.flag_key, 'test_feature_in_rollout') + mock_experiment = project_config.get_experiment_from_key('test_experiment') + mock_variation = project_config.get_variation_from_id('test_experiment', '111129') - def test_decide_for_keys(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. """ + 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( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: + + user_context = opt_obj.create_user_context('test_user') + actual = user_context.decide('test_feature_in_experiment') + + expected_variables = { + 'is_working': True, + 'environment': 'staging', + 'cost': 10.02, + 'count': 4243, + 'variable_without_usage': 45, + 'object': {"test": 123}, + 'true_object': {"true_test": 1.4} + } + + expected = OptimizelyDecision( + variation_key='variation', + rule_key='test_experiment', + enabled=True, + variables=expected_variables, + flag_key='test_feature_in_experiment', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) + + # assert notification count + self.assertEqual(1, mock_broadcast_decision.call_count) + # assert event count + self.assertEqual(1, mock_send_event.call_count) + + def test_decide_feature_rollout(self): opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + project_config = opt_obj.config_manager.get_config() - user_context = opt_obj.create_user_context('test_user') - decisions = opt_obj.decide_for_keys(user_context, ['test_feature_in_rollout', 'test_feature_in_experiment']) - self.assertTrue(len(decisions) == 2) + with mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: - self.assertFalse(decisions['test_feature_in_rollout'].enabled) - self.assertEqual(decisions['test_feature_in_rollout'].flag_key, 'test_feature_in_rollout') + user_attributes = {'test_attribute': 'test_value_1'} + user_context = opt_obj.create_user_context('test_user', user_attributes) + actual = user_context.decide('test_feature_in_rollout') - self.assertFalse(decisions['test_feature_in_experiment'].enabled) - self.assertEqual(decisions['test_feature_in_experiment'].flag_key, 'test_feature_in_experiment') + expected_variables = { + 'is_running': True, + 'message': 'Hello audience', + 'price': 39.99, + 'count': 399, + 'object': {"field": 12} + } - def test_decide_all(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. """ + expected = OptimizelyDecision( + variation_key='211129', + rule_key='211127', + enabled=True, + variables=expected_variables, + flag_key='test_feature_in_rollout', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) + + # assert notification count + self.assertEqual(1, mock_broadcast_decision.call_count) + # assert notification + mock_broadcast_decision.assert_called_with( + enums.NotificationTypes.DECISION, + 'flag', + 'test_user', + user_attributes, + { + 'flag_key': expected.flag_key, + 'enabled': expected.enabled, + 'variation_key': expected.variation_key, + 'rule_key': expected.rule_key, + 'reasons': expected.reasons, + 'decision_event_dispatched': True, + 'variables': expected.variables, + }, + ) + + # assert event count + self.assertEqual(1, mock_send_event.call_count) + + # assert event payload + expected_experiment = project_config.get_experiment_from_key(expected.rule_key) + expected_var = project_config.get_variation_from_key(expected.rule_key, expected.variation_key) + mock_send_event.assert_called_with( + project_config, + expected_experiment, + expected_var, + expected.flag_key, + expected.rule_key, + 'rollout', + expected.enabled, + 'test_user', + user_attributes + ) + + def test_decide_feature_rollout__send_flag_decision_false(self): opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + project_config = opt_obj.config_manager.get_config() + project_config.send_flag_decisions = False - user_context = opt_obj.create_user_context('test_user') - decisions = opt_obj.decide_all(user_context) - self.assertTrue(len(decisions) == 4) + with mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: + + user_attributes = {'test_attribute': 'test_value_1'} + user_context = opt_obj.create_user_context('test_user', user_attributes) + actual = user_context.decide('test_feature_in_rollout') - self.assertFalse(decisions['test_feature_in_rollout'].enabled) - self.assertEqual(decisions['test_feature_in_rollout'].flag_key, 'test_feature_in_rollout') + expected_variables = { + 'is_running': True, + 'message': 'Hello audience', + 'price': 39.99, + 'count': 399, + 'object': {"field": 12} + } - self.assertFalse(decisions['test_feature_in_experiment'].enabled) - self.assertEqual(decisions['test_feature_in_experiment'].flag_key, 'test_feature_in_experiment') + expected = OptimizelyDecision( + variation_key='211129', + rule_key='211127', + enabled=True, + variables=expected_variables, + flag_key='test_feature_in_rollout', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) - self.assertFalse(decisions['test_feature_in_group'].enabled) - self.assertEqual(decisions['test_feature_in_group'].flag_key, 'test_feature_in_group') + # assert notification count + self.assertEqual(1, mock_broadcast_decision.call_count) - self.assertFalse(decisions['test_feature_in_experiment_and_rollout'].enabled) - self.assertEqual(decisions['test_feature_in_experiment_and_rollout'].flag_key, - 'test_feature_in_experiment_and_rollout') + # assert notification + mock_broadcast_decision.assert_called_with( + enums.NotificationTypes.DECISION, + 'flag', + 'test_user', + user_attributes, + { + 'flag_key': expected.flag_key, + 'enabled': expected.enabled, + 'variation_key': expected.variation_key, + 'rule_key': expected.rule_key, + 'reasons': expected.reasons, + 'decision_event_dispatched': False, + 'variables': expected.variables, + }, + ) - def test_decide_all_enabled_only(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. """ + # assert event count + self.assertEqual(0, mock_send_event.call_count) + def test_decide_feature_null_variation(self): opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + project_config = opt_obj.config_manager.get_config() - user_context = opt_obj.create_user_context('test_user') - decisions = opt_obj.decide_all(user_context, [DecideOption.ENABLED_FLAGS_ONLY]) - self.assertTrue(len(decisions) == 0) + mock_experiment = None + mock_variation = None + + 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( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: + + user_context = opt_obj.create_user_context('test_user', {'browser': 'chrome'}) + actual = user_context.decide('test_feature_in_experiment') + + expected_variables = { + 'is_working': True, + 'environment': 'devel', + 'cost': 10.99, + 'count': 999, + 'variable_without_usage': 45, + 'object': {"test": 12}, + 'true_object': {"true_test": 23.54} + } - def test_track(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. """ + expected = OptimizelyDecision( + variation_key=None, + rule_key=None, + enabled=False, + variables=expected_variables, + flag_key='test_feature_in_experiment', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) + # assert notification + mock_broadcast_decision.assert_called_with( + enums.NotificationTypes.DECISION, + 'flag', + 'test_user', + {'browser': 'chrome'}, + { + 'flag_key': expected.flag_key, + 'enabled': expected.enabled, + 'variation_key': expected.variation_key, + 'rule_key': expected.rule_key, + 'reasons': expected.reasons, + 'decision_event_dispatched': True, + 'variables': expected.variables, + }, + ) + + # assert event count + self.assertEqual(1, mock_send_event.call_count) + + # assert event payload + mock_send_event.assert_called_with( + project_config, + mock_experiment, + mock_variation, + expected.flag_key, + '', + 'rollout', + expected.enabled, + 'test_user', + {'browser': 'chrome'} + ) + + def test_decide_feature_null_variation__send_flag_decision_false(self): opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + project_config = opt_obj.config_manager.get_config() + project_config.send_flag_decisions = False - with mock.patch('time.time', return_value=42), mock.patch( - 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' - ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process: - user_context = opt_obj.create_user_context('test_user') - user_context.track_event('test_event') + mock_experiment = None + mock_variation = None + + 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( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: - log_event = EventFactory.create_log_event(mock_process.call_args[0][0], opt_obj.logger) - self.assertEqual(log_event.params['visitors'][0]['visitor_id'], 'test_user') - self.assertEqual(log_event.params['visitors'][0]['snapshots'][0]['events'][0]['timestamp'], 42000) - self.assertEqual(log_event.params['visitors'][0]['snapshots'][0]['events'][0]['uuid'], - 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c') - self.assertEqual(log_event.params['visitors'][0]['snapshots'][0]['events'][0]['key'], 'test_event') + user_context = opt_obj.create_user_context('test_user', {'browser': 'chrome'}) + actual = user_context.decide('test_feature_in_experiment') - def test_decide_sendEvent(self): + expected_variables = { + 'is_working': True, + 'environment': 'devel', + 'cost': 10.99, + 'count': 999, + 'variable_without_usage': 45, + 'object': {"test": 12}, + 'true_object': {"true_test": 23.54} + } + + expected = OptimizelyDecision( + variation_key=None, + rule_key=None, + enabled=False, + variables=expected_variables, + flag_key='test_feature_in_experiment', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) + + # assert notification + mock_broadcast_decision.assert_called_with( + enums.NotificationTypes.DECISION, + 'flag', + 'test_user', + {'browser': 'chrome'}, + { + 'flag_key': expected.flag_key, + 'enabled': expected.enabled, + 'variation_key': expected.variation_key, + 'rule_key': expected.rule_key, + 'reasons': expected.reasons, + 'decision_event_dispatched': False, + 'variables': expected.variables, + }, + ) + + # assert event count + self.assertEqual(0, mock_send_event.call_count) + + def test_decide__option__disable_decision_event(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') - # Assert that featureEnabled property is True - self.assertTrue(mock_variation.featureEnabled) - with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST), []), ), mock.patch( - 'optimizely.event.event_processor.ForwardingEventProcessor.process' - ) as mock_process, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision, mock.patch( - 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: + + user_context = opt_obj.create_user_context('test_user', {'browser': 'chrome'}) + actual = user_context.decide('test_feature_in_experiment', ['DISABLE_DECISION_EVENT']) + + expected_variables = { + 'is_working': True, + 'environment': 'staging', + 'cost': 10.02, + 'count': 4243, + 'variable_without_usage': 45, + 'object': {"test": 123}, + 'true_object': {"true_test": 1.4} + } + + expected = OptimizelyDecision( + variation_key='variation', + rule_key='test_experiment', + enabled=True, + variables=expected_variables, + flag_key='test_feature_in_experiment', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) + + # assert notification + mock_broadcast_decision.assert_called_with( + enums.NotificationTypes.DECISION, + 'flag', + 'test_user', + {'browser': 'chrome'}, + { + 'flag_key': expected.flag_key, + 'enabled': expected.enabled, + 'variation_key': expected.variation_key, + 'rule_key': expected.rule_key, + 'reasons': expected.reasons, + 'decision_event_dispatched': False, + 'variables': expected.variables, + }, + ) + + # assert event count + self.assertEqual(0, mock_send_event.call_count) + + def test_decide__default_option__disable_decision_event(self): + opt_obj = optimizely.Optimizely( + datafile=json.dumps(self.config_dict_with_features), + default_decide_options=['DISABLE_DECISION_EVENT'] + ) + 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), []), ), mock.patch( - 'time.time', return_value=42 - ): - context = opt_obj.create_user_context('test_user') - decision = context.decide('test_feature_in_experiment') - self.assertTrue(decision.enabled) + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: + + user_context = opt_obj.create_user_context('test_user', {'browser': 'chrome'}) + actual = user_context.decide('test_feature_in_experiment') + expected_variables = { + 'is_working': True, + 'environment': 'staging', + 'cost': 10.02, + 'count': 4243, + 'variable_without_usage': 45, + 'object': {"test": 123}, + 'true_object': {"true_test": 1.4} + } + + expected = OptimizelyDecision( + variation_key='variation', + rule_key='test_experiment', + enabled=True, + variables=expected_variables, + flag_key='test_feature_in_experiment', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) + + # assert notification mock_broadcast_decision.assert_called_with( enums.NotificationTypes.DECISION, - 'feature', + 'flag', 'test_user', - {}, + {'browser': 'chrome'}, { - 'feature_key': 'test_feature_in_experiment', - 'feature_enabled': True, - 'source': 'rollout', - 'source_info': { - 'experiment': mock_experiment, - 'variation': mock_variation, - }, + 'flag_key': expected.flag_key, + 'enabled': expected.enabled, + 'variation_key': expected.variation_key, + 'rule_key': expected.rule_key, + 'reasons': expected.reasons, + 'decision_event_dispatched': False, + 'variables': expected.variables, }, ) - # Check that impression event is sent for rollout and send_flag_decisions = True - self.assertEqual(1, mock_process.call_count) + # assert event count + self.assertEqual(0, mock_send_event.call_count) - def test_decide_doNotSendEvent_withOption(self): + def test_decide__option__exclude_variables(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') - # Assert that featureEnabled property is True - self.assertTrue(mock_variation.featureEnabled) - with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST), []), ), mock.patch( - 'optimizely.event.event_processor.ForwardingEventProcessor.process' - ) as mock_process, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision, mock.patch( - 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' - ), mock.patch( - 'time.time', return_value=42 - ): - context = opt_obj.create_user_context('test_user') - decision = context.decide('test_feature_in_experiment', [DecideOption.DISABLE_DECISION_EVENT]) - self.assertTrue(decision.enabled) + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: + + user_context = opt_obj.create_user_context('test_user', {'browser': 'chrome'}) + actual = user_context.decide('test_feature_in_experiment', ['EXCLUDE_VARIABLES']) + + expected_variables = {} + + expected = OptimizelyDecision( + variation_key='variation', + rule_key='test_experiment', + enabled=True, + variables=expected_variables, + flag_key='test_feature_in_experiment', + user_context=user_context + ) + self.compare_opt_decisions(expected, actual) + + # assert notification mock_broadcast_decision.assert_called_with( enums.NotificationTypes.DECISION, - 'feature', + 'flag', 'test_user', - {}, + {'browser': 'chrome'}, { - 'feature_key': 'test_feature_in_experiment', - 'feature_enabled': True, - 'source': 'rollout', - 'source_info': { - 'experiment': mock_experiment, - 'variation': mock_variation, - }, + 'flag_key': expected.flag_key, + 'enabled': expected.enabled, + 'variation_key': expected.variation_key, + 'rule_key': expected.rule_key, + 'reasons': expected.reasons, + 'decision_event_dispatched': True, + 'variables': expected.variables, }, ) - # Check that impression event is NOT sent for rollout and send_flag_decisions = True - # with disable decision event decision option - self.assertEqual(0, mock_process.call_count) + # assert event count + self.assertEqual(1, mock_send_event.call_count) - def test_decide_options_bypass_UPS(self): - user_id = 'test_user' - experiment_bucket_map = {'111127': {'variation_id': '111128'}} + # assert event payload + mock_send_event.assert_called_with( + project_config, + mock_experiment, + mock_variation, + expected.flag_key, + expected.rule_key, + 'feature-test', + expected.enabled, + 'test_user', + {'browser': 'chrome'} + ) - profile = UserProfile(user_id, experiment_bucket_map=experiment_bucket_map) + def test_decide__option__include_reasons__feature_test(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) - class Ups(UserProfileService): + user_context = opt_obj.create_user_context('test_user', {'browser': 'chrome'}) + actual = user_context.decide('test_feature_in_experiment', ['INCLUDE_REASONS']) - def lookup(self, user_id): - return profile + expected_reasons = [ + 'Evaluating audiences for experiment "test_experiment": [].', + 'Audiences for experiment "test_experiment" collectively evaluated to TRUE.', + 'User "test_user" is in variation "control" of experiment test_experiment.' + ] - def save(self, user_profile): - super(Ups, self).save(user_profile) + self.assertEquals(expected_reasons, actual.reasons) - ups = Ups() - opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features), user_profile_service=ups) + def test_decide__option__include_reasons__feature_rollout(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + + user_attributes = {'test_attribute': 'test_value_1'} + user_context = opt_obj.create_user_context('test_user', user_attributes) + actual = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) + + expected_reasons = [ + 'Evaluating audiences for rule 1: ["11154"].', + 'Audiences for rule 1 collectively evaluated to TRUE.', + 'User "test_user" meets audience conditions for targeting rule 1.', + 'User "test_user" is in the traffic group of targeting rule 1.' + ] + + self.assertEquals(expected_reasons, actual.reasons) + + def test_decide__option__enabled_flags_only(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + project_config = opt_obj.config_manager.get_config() + + expected_experiment = project_config.get_experiment_from_key('211127') + expected_var = project_config.get_variation_from_key('211127', '211229') + + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(expected_experiment, expected_var, + enums.DecisionSources.ROLLOUT), []), + ), mock.patch( + 'optimizely.notification_center.NotificationCenter.send_notifications' + ) as mock_broadcast_decision, mock.patch( + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: + + user_attributes = {'test_attribute': 'test_value_1'} + user_context = opt_obj.create_user_context('test_user', user_attributes) + actual = user_context.decide('test_feature_in_rollout', 'ENABLED_FLAGS_ONLY') + + expected_variables = { + 'is_running': False, + 'message': 'Hello', + 'price': 99.99, + 'count': 999, + 'object': {"field": 1} + } + + expected = OptimizelyDecision( + variation_key='211229', + rule_key='211127', + enabled=False, + variables=expected_variables, + flag_key='test_feature_in_rollout', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) + + # assert notification count + self.assertEqual(1, mock_broadcast_decision.call_count) + + # assert notification + mock_broadcast_decision.assert_called_with( + enums.NotificationTypes.DECISION, + 'flag', + 'test_user', + user_attributes, + { + 'flag_key': expected.flag_key, + 'enabled': expected.enabled, + 'variation_key': expected.variation_key, + 'rule_key': expected.rule_key, + 'reasons': expected.reasons, + 'decision_event_dispatched': True, + 'variables': expected.variables, + }, + ) + + # assert event count + self.assertEqual(1, mock_send_event.call_count) + + # assert event payload + mock_send_event.assert_called_with( + project_config, + expected_experiment, + expected_var, + expected.flag_key, + expected.rule_key, + 'rollout', + expected.enabled, + 'test_user', + user_attributes + ) + + def test_decide__default_options__with__options(self): + opt_obj = optimizely.Optimizely( + datafile=json.dumps(self.config_dict_with_features), + default_decide_options=['DISABLE_DECISION_EVENT'] + ) 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') - # Assert that featureEnabled property is True - self.assertTrue(mock_variation.featureEnabled) - with mock.patch( - 'optimizely.bucketer.Bucketer.bucket', - return_value=mock_variation, + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST), []), ), mock.patch( - 'optimizely.event.event_processor.ForwardingEventProcessor.process' - ) as mock_process, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision, mock.patch( - 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' - ), mock.patch( - 'time.time', return_value=42 - ): - context = opt_obj.create_user_context(user_id) - decision = context.decide('test_feature_in_experiment', [DecideOption.DISABLE_DECISION_EVENT, - DecideOption.IGNORE_USER_PROFILE_SERVICE, - DecideOption.EXCLUDE_VARIABLES]) - self.assertTrue(decision.enabled) + 'optimizely.optimizely.Optimizely._send_impression_event' + ) as mock_send_event: + + user_context = opt_obj.create_user_context('test_user', {'browser': 'chrome'}) + actual = user_context.decide('test_feature_in_experiment', ['EXCLUDE_VARIABLES']) + + expected_variables = {} + + expected = OptimizelyDecision( + variation_key='variation', + rule_key='test_experiment', + enabled=True, + variables=expected_variables, + flag_key='test_feature_in_experiment', + user_context=user_context + ) + + self.compare_opt_decisions(expected, actual) + # assert notification mock_broadcast_decision.assert_called_with( enums.NotificationTypes.DECISION, - 'feature', + 'flag', 'test_user', - {}, + {'browser': 'chrome'}, { - 'feature_key': 'test_feature_in_experiment', - 'feature_enabled': True, - 'source': 'feature-test', - 'source_info': { - 'experiment': mock_experiment, - 'variation': mock_variation, - }, + 'flag_key': expected.flag_key, + 'enabled': expected.enabled, + 'variation_key': expected.variation_key, + 'rule_key': expected.rule_key, + 'reasons': expected.reasons, + 'decision_event_dispatched': False, + 'variables': expected.variables, }, ) - # Check that impression event is NOT sent for rollout and send_flag_decisions = True - # with disable decision event decision option - self.assertEqual(0, mock_process.call_count) + # assert event count + self.assertEqual(0, mock_send_event.call_count) + + def test_decide_for_keys(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + + user_context = opt_obj.create_user_context('test_user') + + mocked_decision_1 = OptimizelyDecision(flag_key='test_feature_in_experiment', enabled=True) + mocked_decision_2 = OptimizelyDecision(flag_key='test_feature_in_rollout', enabled=False) + + def side_effect(*args, **kwargs): + flag = args[1] + if flag == 'test_feature_in_experiment': + return mocked_decision_1 + else: + return mocked_decision_2 + + with mock.patch( + 'optimizely.optimizely.Optimizely._decide', side_effect=side_effect + ) as mock_decide, mock.patch( + 'optimizely.optimizely_user_context.OptimizelyUserContext._clone', + return_value=user_context + ): + + flags = ['test_feature_in_rollout', 'test_feature_in_experiment'] + options = [] + decisions = user_context.decide_for_keys(flags, options) + + self.assertEqual(2, len(decisions)) + + mock_decide.assert_any_call( + user_context, + 'test_feature_in_experiment', + options + ) + + mock_decide.assert_any_call( + user_context, + 'test_feature_in_rollout', + options + ) + + self.assertEqual(mocked_decision_1, decisions['test_feature_in_experiment']) + self.assertEqual(mocked_decision_2, decisions['test_feature_in_rollout']) + + def test_decide_for_keys__option__enabled_flags_only(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + + user_context = opt_obj.create_user_context('test_user') + + mocked_decision_1 = OptimizelyDecision(flag_key='test_feature_in_experiment', enabled=True) + mocked_decision_2 = OptimizelyDecision(flag_key='test_feature_in_rollout', enabled=False) + + def side_effect(*args, **kwargs): + flag = args[1] + if flag == 'test_feature_in_experiment': + return mocked_decision_1 + else: + return mocked_decision_2 + + with mock.patch( + 'optimizely.optimizely.Optimizely._decide', side_effect=side_effect + ) as mock_decide, mock.patch( + 'optimizely.optimizely_user_context.OptimizelyUserContext._clone', + return_value=user_context + ): + + flags = ['test_feature_in_rollout', 'test_feature_in_experiment'] + options = ['ENABLED_FLAGS_ONLY'] + decisions = user_context.decide_for_keys(flags, options) + + self.assertEqual(1, len(decisions)) + + mock_decide.assert_any_call( + user_context, + 'test_feature_in_experiment', + options + ) + + mock_decide.assert_any_call( + user_context, + 'test_feature_in_rollout', + options + ) + + self.assertEqual(mocked_decision_1, decisions['test_feature_in_experiment']) + + def test_decide_for_keys__default_options__with__options(self): + opt_obj = optimizely.Optimizely( + datafile=json.dumps(self.config_dict_with_features), + default_decide_options=['ENABLED_FLAGS_ONLY'] + ) + + user_context = opt_obj.create_user_context('test_user') + + with mock.patch( + 'optimizely.optimizely.Optimizely._decide' + ) as mock_decide, mock.patch( + 'optimizely.optimizely_user_context.OptimizelyUserContext._clone', + return_value=user_context + ): + + flags = ['test_feature_in_experiment'] + options = ['EXCLUDE_VARIABLES'] + user_context.decide_for_keys(flags, options) + + mock_decide.assert_called_with( + user_context, + 'test_feature_in_experiment', + ['EXCLUDE_VARIABLES'] + ) + + def test_decide_for_all(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + + user_context = opt_obj.create_user_context('test_user') + + with mock.patch( + 'optimizely.optimizely.Optimizely._decide_for_keys', return_value='response from decide_for_keys' + ) as mock_decide, mock.patch( + 'optimizely.optimizely_user_context.OptimizelyUserContext._clone', + return_value=user_context + ): + + options = ['DISABLE_DECISION_EVENT'] + decisions = user_context.decide_all(options) + + mock_decide.assert_called_with( + user_context, + [ + 'test_feature_in_experiment', + 'test_feature_in_rollout', + 'test_feature_in_group', + 'test_feature_in_experiment_and_rollout' + ], + options + ) + + self.assertEqual('response from decide_for_keys', decisions) - def test_decide_options_reasons(self): + def test_decide_options_bypass_UPS(self): user_id = 'test_user' - experiment_bucket_map = {'111127': {'variation_id': '111128'}} - profile = UserProfile(user_id, experiment_bucket_map=experiment_bucket_map) + lookup_profile = { + 'user_id': user_id, + 'experiment_bucket_map': { + '111127': { + 'variation_id': '111128' + } + } + } + + save_profile = [] class Ups(UserProfileService): def lookup(self, user_id): - return profile + return lookup_profile def save(self, user_profile): - super(Ups, self).save(user_profile) + print(user_profile) + save_profile.append(user_profile) ups = Ups() - opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features), - logger=logger.SimpleLogger(min_level=logging.DEBUG), - user_profile_service=ups) + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features), user_profile_service=ups) 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') - # Assert that featureEnabled property is True - self.assertTrue(mock_variation.featureEnabled) - with mock.patch( - 'optimizely.bucketer.Bucketer.bucket', - return_value=mock_variation, + 'optimizely.bucketer.Bucketer.bucket', + return_value=(mock_variation, []), ), mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' - ) as mock_process, mock.patch( - 'optimizely.notification_center.NotificationCenter.send_notifications' - ) as mock_broadcast_decision, mock.patch( - 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch( - 'time.time', return_value=42 + 'optimizely.notification_center.NotificationCenter.send_notifications' ): - context = opt_obj.create_user_context(user_id) - decision = context.decide('test_feature_in_experiment', [DecideOption.DISABLE_DECISION_EVENT, - DecideOption.IGNORE_USER_PROFILE_SERVICE, - DecideOption.EXCLUDE_VARIABLES, - DecideOption.INCLUDE_REASONS]) - self.assertTrue(decision.enabled) + user_context = opt_obj.create_user_context(user_id) + options = [ + 'IGNORE_USER_PROFILE_SERVICE' + ] - mock_broadcast_decision.assert_called_with( - enums.NotificationTypes.DECISION, - 'feature', - 'test_user', - {}, - { - 'feature_key': 'test_feature_in_experiment', - 'feature_enabled': True, - 'source': 'feature-test', - 'source_info': { - 'experiment': mock_experiment, - 'variation': mock_variation, - }, - }, + actual = user_context.decide('test_feature_in_experiment', options) + + expected_variables = { + 'is_working': True, + 'environment': 'staging', + 'cost': 10.02, + 'count': 4243, + 'variable_without_usage': 45, + 'object': {"test": 123}, + 'true_object': {"true_test": 1.4} + } + + expected = OptimizelyDecision( + variation_key='variation', + rule_key='test_experiment', + enabled=True, + variables=expected_variables, + flag_key='test_feature_in_experiment', + user_context=user_context ) - self.assertIsNotNone(decision.reasons) - self.assertTrue(decision.reasons[0].find( - 'Audiences for experiment "test_experiment" collectively evaluated to TRUE.') is not -1) - self.assertTrue(decision.reasons[1].find( - 'User "test_user" is in variation "variation" of experiment test_experiment.') is not -1) - # Check that impression event is NOT sent for rollout and send_flag_decisions = True - # with disable decision event decision option - self.assertEqual(0, mock_process.call_count) + self.compare_opt_decisions(expected, actual) + + self.assertEqual([], save_profile) + + def test_decide_reasons__hit_everyone_else_rule__fails_bucketing(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + + user_attributes = {} + user_context = opt_obj.create_user_context('test_user', user_attributes) + actual = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) + + expected_reasons = [ + 'Evaluating audiences for rule 1: ["11154"].', + 'Audiences for rule 1 collectively evaluated to FALSE.', + 'User "test_user" does not meet conditions for targeting rule 1.', + 'Evaluating audiences for rule 2: ["11159"].', + 'Audiences for rule 2 collectively evaluated to FALSE.', + 'User "test_user" does not meet conditions for targeting rule 2.', + 'Evaluating audiences for rule Everyone Else: [].', + 'Audiences for rule Everyone Else collectively evaluated to TRUE.', + 'Bucketed into an empty traffic range. Returning nil.' + ] + + self.assertEquals(expected_reasons, actual.reasons) + + def test_decide_reasons__hit_everyone_else_rule(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + + user_attributes = {} + user_context = opt_obj.create_user_context('abcde', user_attributes) + actual = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) + + expected_reasons = [ + 'Evaluating audiences for rule 1: ["11154"].', + 'Audiences for rule 1 collectively evaluated to FALSE.', + 'User "abcde" does not meet conditions for targeting rule 1.', + 'Evaluating audiences for rule 2: ["11159"].', + 'Audiences for rule 2 collectively evaluated to FALSE.', + 'User "abcde" does not meet conditions for targeting rule 2.', + 'Evaluating audiences for rule Everyone Else: [].', + 'Audiences for rule Everyone Else collectively evaluated to TRUE.', + 'User "abcde" meets conditions for targeting rule "Everyone Else".' + ] + + self.assertEquals(expected_reasons, actual.reasons) + + def test_decide_reasons__hit_rule2__fails_bucketing(self): + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + + user_attributes = {'test_attribute': 'test_value_2'} + user_context = opt_obj.create_user_context('test_user', user_attributes) + actual = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) + + expected_reasons = [ + 'Evaluating audiences for rule 1: ["11154"].', + 'Audiences for rule 1 collectively evaluated to FALSE.', + 'User "test_user" does not meet conditions for targeting rule 1.', + 'Evaluating audiences for rule 2: ["11159"].', + 'Audiences for rule 2 collectively evaluated to TRUE.', + 'User "test_user" meets audience conditions for targeting rule 2.', + 'Bucketed into an empty traffic range. Returning nil.', + 'User "test_user" is not in the traffic group for targeting rule 2. Checking "Everyone Else" rule now.', + 'Evaluating audiences for rule Everyone Else: [].', + 'Audiences for rule Everyone Else collectively evaluated to TRUE.', + 'Bucketed into an empty traffic range. Returning nil.' + ] + + self.assertEquals(expected_reasons, actual.reasons) + + def test_decide_reasons__hit_user_profile_service(self): + user_id = 'test_user' + + lookup_profile = { + 'user_id': user_id, + 'experiment_bucket_map': { + '111127': { + 'variation_id': '111128' + } + } + } + + save_profile = [] + + class Ups(UserProfileService): + + def lookup(self, user_id): + return lookup_profile + + def save(self, user_profile): + print(user_profile) + save_profile.append(user_profile) + + ups = Ups() + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features), user_profile_service=ups) + + user_context = opt_obj.create_user_context(user_id) + options = ['INCLUDE_REASONS'] + + actual = user_context.decide('test_feature_in_experiment', options) + + expected_reasons = [('Returning previously activated variation ID "control" of experiment ' + '"test_experiment" for user "test_user" from user profile.')] + + self.assertEquals(expected_reasons, actual.reasons) + + def test_decide_reasons__forced_variation(self): + user_id = 'test_user' + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + + user_context = opt_obj.create_user_context(user_id) + options = ['INCLUDE_REASONS'] + + opt_obj.set_forced_variation('test_experiment', user_id, 'control') + + actual = user_context.decide('test_feature_in_experiment', options) + + expected_reasons = [('Variation "control" is mapped to experiment ' + '"test_experiment" and user "test_user" in the forced variation map')] + + self.assertEquals(expected_reasons, actual.reasons) + + def test_decide_reasons__whitelisted_variation(self): + user_id = 'user_1' + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + + user_context = opt_obj.create_user_context(user_id) + options = ['INCLUDE_REASONS'] + + actual = user_context.decide('test_feature_in_experiment', options) + + expected_reasons = ['User "user_1" is forced in variation "control".'] + + self.assertEquals(expected_reasons, actual.reasons)