From c5d9dae3fdc7c9883c62234ed6e582265648272d Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Thu, 9 Sep 2021 14:40:55 -0700 Subject: [PATCH 01/31] add maps to project config --- optimizely/entities.py | 1 + optimizely/project_config.py | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/optimizely/entities.py b/optimizely/entities.py index 88cd49c4..4960e27e 100644 --- a/optimizely/entities.py +++ b/optimizely/entities.py @@ -94,6 +94,7 @@ def __init__(self, id, policy, experiments, trafficAllocation, **kwargs): class Layer(BaseEntity): + """Layer acts as rollout.""" def __init__(self, id, experiments, **kwargs): self.id = id self.experiments = experiments diff --git a/optimizely/project_config.py b/optimizely/project_config.py index 8a696599..6950c1f4 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -134,11 +134,43 @@ def __init__(self, datafile, logger, error_handler): self.experiment_feature_map = {} for feature in self.feature_key_map.values(): feature.variables = self._generate_key_map(feature.variables, 'key', entities.Variable) - for exp_id in feature.experimentIds: # Add this experiment in experiment-feature map. self.experiment_feature_map[exp_id] = [feature.id] + # TODO - make sure to add a test for multiple flags. My test datafile only has a single flag. Because for loop needs to work across all flags. + # all rules(experiment rules and delivery rules) for each flag + self.flag_rules_map = {} + for flag in self.feature_flags: + + experiments = [self.experiment_id_map[exp_id] for exp_id in flag['experimentIds']] + rollout = self.rollout_id_map[flag['rolloutId']] + + rollout_experiments_id_map = self._generate_key_map(rollout.experiments, 'id', entities.Experiment) # TODO - not happy that _generate_key_map funciton is here. I can move this chunk out like other lookups. But the it's not ideal either + rollout_experiments = [exper for exper in rollout_experiments_id_map.values()] + + if rollout and rollout.experiments: + experiments.extend(rollout_experiments) + + self.flag_rules_map[flag['key']] = experiments + + # All variations for each flag + # Datafile does not contain a separate entity for this. + # We collect variations used in each rule (experiment rules and delivery rules) + self.flag_variations_map = {} + + for flag_key, rules in self.flag_rules_map.items(): + variations = [] + for rule in rules: + # get variations as objects (rule.variations gives list) + variation_objects = self.variation_key_map[rule.key].values() + + for variation in variation_objects: + if variation.id not in [var.id for var in variations]: + variations.append(variation) + + self.flag_variations_map[flag_key] = variations + @staticmethod def _generate_key_map(entity_list, key, entity_class): """ Helper method to generate map from key to entity object for given list of dicts. From 17ad742bd06bf7150f6635d84d158792ada7bf92 Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Tue, 14 Sep 2021 20:18:47 -0700 Subject: [PATCH 02/31] initial code --- optimizely/optimizely.py | 29 ++++- optimizely/optimizely_user_context.py | 174 +++++++++++++++++++++++++- optimizely/project_config.py | 2 + 3 files changed, 203 insertions(+), 2 deletions(-) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 1383674a..2c15f579 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -1021,6 +1021,7 @@ def _decide(self, user_context, key, decide_options=None): decision_event_dispatched = False ignore_ups = OptimizelyDecideOption.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) @@ -1123,7 +1124,6 @@ def _decide_all(self, user_context, decide_options=None): def _decide_for_keys(self, user_context, keys, decide_options=None): """ - Args: user_context: UserContent keys: list of feature keys to run decide on. @@ -1159,3 +1159,30 @@ def _decide_for_keys(self, user_context, keys, decide_options=None): continue decisions[key] = decision return decisions + + # TODO - NEW + def get_flag_variation_by_key(self, flag_key, variation_key): + config = self.config_manager.get_config() + variations = config.flag_variations_map[flag_key] + + print('VARIATIONS ', variations) + + if not config: + return None + + if variations.key == variation_key: + return variations.key + + + + + + + + + + + + + + diff --git a/optimizely/optimizely_user_context.py b/optimizely/optimizely_user_context.py index 9416f65d..14c18583 100644 --- a/optimizely/optimizely_user_context.py +++ b/optimizely/optimizely_user_context.py @@ -14,12 +14,14 @@ # import threading +import copy class OptimizelyUserContext(object): """ Representation of an Optimizely User Context using which APIs are to be called. """ + forced_decisions = [] def __init__(self, optimizely_client, user_id, user_attributes=None): """ Create an instance of the Optimizely User Context. @@ -42,8 +44,39 @@ def __init__(self, optimizely_client, user_id, user_attributes=None): self._user_attributes = user_attributes.copy() if user_attributes else {} self.lock = threading.Lock() + # TODO - ADD FORCED DECISION class + """ + struct ForcedDecision { + let flagKey: String + let ruleKey: String? + var variationKey: String + } + var forcedDecisions = AtomicArray() + """ + class ForcedDecision(object): + def __init__(self, flag_key, rule_key, variation_key): + self.flag_key = flag_key + self.rule_key = rule_key + self.variation_key = variation_key + + + # TODO - NEW def _clone(self): - return OptimizelyUserContext(self.client, self.user_id, self.get_user_attributes()) + if not self.client: + return None + + user_context = OptimizelyUserContext(self.client, self.user_id, self.get_user_attributes()) + + if len(self.forced_decisions) > 0: + # Jae: + # Make sure the assigning is to make a copy. Some langs use ref and other make a copy when assigning array/map. + # Swift makes a copy automatically when assigning value type. Not sure about python. + # So it needs to be pass by value. So the original object is not changed. Change is only in the new object. Here I’ll need to call copy.deepcopy() + # The opposite. We won’t change the contents of the copied one. But the original can be changed any time later. + user_context.forced_decisions = copy.deepcopy(self.forced_decisions) # TODO - IMPORTANT -> CHECK IF WE NEED DEEPCOPY OR NOT - SEE SLACK W JAE + + return user_context + def get_user_attributes(self): with self.lock: @@ -114,3 +147,142 @@ def as_json(self): 'user_id': self.user_id, 'attributes': self.get_user_attributes(), } + + + # TODO - NEW + def set_forced_decision(self, flag_key, rule_key, variation_key): + """ + Sets the forced decision (variation key) for a given flag and an optional rule. + + Args: + flag_key: A flag key. + rule_key: An experiment or delivery rule key (optional). + variation_key: A variation key. + + Returns: + True if the forced decision has been set successfully. + """ + config = self.client.get_optimizely_config() + + if self.client is None or config is None: # TODO - check if to use "is not" or "not ==" + # TODO log error sdk key not ready - whichlogger, to show in console, logger for optimizely_client,loggger for what? where do we want it to log? + + return False + + if rule_key: + print('xxx1 ', self.forced_decisions) + for decision in self.forced_decisions: + if decision.flag_key == flag_key and decision.rule_key == rule_key: + decision.variation_key = variation_key # TODO check if .variation_key needs to be a dict key instead of dot notation object + + self.forced_decisions.append(self.ForcedDecision(flag_key, rule_key, variation_key)) + print('xxx2 ', self.forced_decisions[0].variation_key) + + return True + + + # TODO - NEW + def get_forced_decision(self, flag_key, rule_key): + """ + Sets the forced decision (variation key) for a given flag and an optional rule. + + Args: + flag_key: A flag key. + rule_key: An experiment or delivery rule key (optional). + + Returns: + A variation key or None if forced decisions are not set for the parameters. + """ + config = self.client.get_optimizely_config() + + if self.client is None or config is None: # TODO - check if to use "is not" or "not ==" + # TODO log error sdk key not ready - whichlogger, to sho win console, logger for optimizely_client,loggger for what? where do we want it to log? + + return False + + return self.find_forced_decision(flag_key, rule_key) + + + # TODO - NEW + def remove_forced_decision(self, flag_key, *arg): # making rule_key here optional arg - WHAT ABOUT IF RULE_KEY IS KEYWORD ARG????? <--- CHECK THIS! + """ + Removes the forced decision for a given flag and an optional rule. + + Args: + flag_key: A flag key. + rule_key: An experiment or delivery rule key (optional). + + Returns: + Returns: true if the forced decision has been removed successfully. + """ + config = self.client.get_optimizely_config() + + if self.client is None or config is None: # TODO - check if to use "is not" or "not ==" + # TODO log error sdk key not ready - whichlogger, to sho win console, logger for optimizely_client,loggger for what? where do we want it to log? + + return False + + # remove() built-in function by default removes the first occurrence of the element that meets the condition + for decision in self.forced_decisions: + if decision.flag_key == flag_key and decision.rule_key == arg: + self.forced_decisions.remove(decision) #TODO - check if it needs to only remove the first occurrence and no other!!! Test separately if rmoe removes all occurences! + + return False + + # TODO - NEW + def remove_all_forced_decisions(self): + """ + Removes all forced decisions bound to this user context. + + Returns: + True if forced decisions have been removed successfully. + """ + config = self.client.get_optimizely_config() + + if self.client is None or config is None: # TODO - check if to use "is not" or "not ==" + # TODO log error sdk key not ready - whichlogger, to sho win console, logger for optimizely_client,loggger for what? where do we want it to log? + + return False + + self.forced_decisions.clear() + + return True + + # TODO - NEW + def find_forced_decision(self, flag_key, rule_key): + if len(self.forced_decisions) == 0: + return None + + for decision in self.forced_decisions: + if decision.flag_key == flag_key and decision.rule_key == rule_key: + return decision.variation_key + + + + # TODO - For dding logger see this: https://github.com/optimizely/javascript-sdk/compare/pnguen/forced-decisions#diff-2bb39c11f271344df01b662f4313312870714813ceb8508ce7bdb851f09b5666R182-R192 + # TODO - NEW + def find_validated_forced_decision(self, flag_key, rule_key, options): + reasons = [] # TODO - what to do with reasons?? Jae has reasons. Do we need them? + variation_key = self.find_forced_decision(flag_key, rule_key) + if variation_key: + self.client.get_flag_variation_by_key(flag_key, variation_key) + + + + + + + + + + + + + + + + + + + + diff --git a/optimizely/project_config.py b/optimizely/project_config.py index 6950c1f4..a9da7642 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -118,6 +118,7 @@ def __init__(self, datafile, logger, error_handler): variation.variables, 'id', entities.Variation.VariableUsage ) + # TODO - NEW self.feature_key_map = self._generate_key_map(self.feature_flags, 'key', entities.FeatureFlag) # As we cannot create json variables in datafile directly, here we convert @@ -138,6 +139,7 @@ def __init__(self, datafile, logger, error_handler): # Add this experiment in experiment-feature map. self.experiment_feature_map[exp_id] = [feature.id] + # TODO - NEW # TODO - make sure to add a test for multiple flags. My test datafile only has a single flag. Because for loop needs to work across all flags. # all rules(experiment rules and delivery rules) for each flag self.flag_rules_map = {} From 58977d2f92064c5623ac8e3e53d6ef173f6b96ae Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Thu, 30 Sep 2021 17:13:58 -0700 Subject: [PATCH 03/31] feat: add remaining implementation --- optimizely/decision_service.py | 233 ++++++++---- optimizely/helpers/enums.py | 7 + optimizely/optimizely.py | 527 +++++++++++++++----------- optimizely/optimizely_user_context.py | 149 ++++---- optimizely/project_config.py | 29 +- 5 files changed, 544 insertions(+), 401 deletions(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index 6bc92333..7d71a598 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -12,6 +12,7 @@ # limitations under the License. from collections import namedtuple + from six import string_types from . import bucketer @@ -21,7 +22,6 @@ from .helpers import validator from .user_profile import UserProfile - Decision = namedtuple('Decision', 'experiment variation source') @@ -211,7 +211,7 @@ def get_stored_variation(self, project_config, experiment, user_profile): 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".'\ + message = 'Found a stored decision. User "%s" is in variation "%s" of experiment "%s".' \ % (user_id, variation.key, experiment.key) self.logger.info( message @@ -221,7 +221,7 @@ def get_stored_variation(self, project_config, experiment, user_profile): return None def get_variation( - self, project_config, experiment, user_id, attributes, ignore_user_profile=False + self, project_config, experiment, user_context, ignore_user_profile=False ): """ Top-level function to help determine variation user should be put in. @@ -234,14 +234,17 @@ def get_variation( Args: project_config: Instance of ProjectConfig. experiment: Experiment for which user variation needs to be determined. - user_id: ID for user. - attributes: Dict representing user attributes. + user_context: contains user id and attributes 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 And an array of log messages representing decision making. """ + + user_id = user_context.user_id + attributes = user_context.get_user_attributes() + decide_reasons = [] # Check if experiment is running if not experiment_helper.is_experiment_running(experiment): @@ -323,110 +326,174 @@ def get_variation( decide_reasons.append(message) return None, decide_reasons - def get_variation_for_rollout(self, project_config, rollout, user_id, attributes=None): + def get_variation_for_rollout(self, project_config, rollout, user, options): """ Determine which experiment/variation the user is in for a given rollout. 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. + user: ID and attributes for user. + options: Decide options. Returns: Decision namedtuple consisting of experiment and variation for the user and array of log messages representing decision making. """ + user_id = user.user_id + attributes = user.get_user_attributes() 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): - logging_key = str(idx + 1) - rollout_rule = project_config.get_experiment_from_id(rollout.experiments[idx].get('id')) - - # Check if user meets audience conditions for targeting rule - audience_conditions = rollout_rule.get_audience_conditions_or_ids() - user_meets_audience_conditions, reasons_received = audience_helper.does_user_meet_audience_conditions( - project_config, - audience_conditions, - enums.RolloutRuleAudienceEvaluationLogs, - logging_key, - attributes, - self.logger) + rollout_rules = project_config.get_rollout_experiments_map(rollout) + + if rollout and len(rollout_rules) > 0: + index = 0 + while index < len(rollout_rules): + decision_response, reasons_received = self.get_variation_from_delivery_rule(project_config, + rollout_rules[index].key, + rollout_rules, index, user, + options) 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( - message - ) - decide_reasons.append(message) - continue - message = 'User "{}" meets audience conditions for targeting rule {}.'.format(user_id, idx + 1) + + if decision_response: + variation, skip_to_everyone_else = decision_response + + if variation: + rule = rollout_rules[index] + feature_decision = Decision(experiment=rule, variation=variation, + source=enums.DecisionSources.ROLLOUT) + + return feature_decision, decide_reasons + + # the last rule is special for "Everyone Else" + index = len(rollout_rules) - 1 if skip_to_everyone_else else index + 1 + + return None, decide_reasons + + def get_variation_from_experiment_rule(self, config, flag_key, rule, user, options): + """ Checks for experiment rule if decision is forced and returns it. + Otherwise returns a regular decision. + + Args: + config: Instance of ProjectConfig. + flag_key: Key of the flag. + rule: Experiment rule. + user: ID and attributes for user. + options: Decide options. + + Returns: + Decision namedtuple consisting of experiment and variation for the user and + array of log messages representing decision making. + """ + decide_reasons = [] + + # check forced decision first + forced_decision_variation, reasons_received = user.find_validated_forced_decision(flag_key, rule.key, options) + decide_reasons += reasons_received + + if forced_decision_variation: + return forced_decision_variation, decide_reasons + + # regular decision + decision_variation, variation_reasons = self.get_variation(config, rule, user, options) + decide_reasons += variation_reasons + return decision_variation, decide_reasons + + def get_variation_from_delivery_rule(self, config, flag_key, rules, rule_index, user, options): + """ Checks for delivery rule if decision is forced and returns it. + Otherwise returns a regular decision. + + Args: + config: Instance of ProjectConfig. + flag_key: Key of the flag. + rules: Experiment rule. + user: ID and attributes for user. + options: Decide options. + + Returns: + If forced decision, it returns namedtuple consisting of forced_decision_variation and skip_to_everyone_else + and decision reason log messages. + + If regular decision it returns a tuple of bucketed_variation and skip_to_everyone_else + and decision reason log messages + """ + decide_reasons = [] + skip_to_everyone_else = False + bucketed_variation = None + + # check forced decision first + rule = rules[rule_index] + forced_decision_variation, reasons_received = user.find_validated_forced_decision(flag_key, rule.key, options) + decide_reasons += reasons_received + + if forced_decision_variation: + return (forced_decision_variation, skip_to_everyone_else), decide_reasons + + # regular decision + user_id = user.user_id + attributes = user.get_user_attributes() + bucketing_id = self._get_bucketing_id(user_id, attributes) + + everyone_else = (rule_index == len(rules) - 1) + logging_key = "Everyone Else" if everyone_else else str(rule_index + 1) + + rollout_rule = config.get_experiment_from_id(rule.id) + audience_conditions = rollout_rule.get_audience_conditions_or_ids() + + audience_decision_response, reasons_received_audience = audience_helper.does_user_meet_audience_conditions( + config, audience_conditions, enums.RolloutRuleAudienceEvaluationLogs, logging_key, attributes, self.logger) + # TODO - add regular logger here, and add log to reasons + decide_reasons += reasons_received_audience + + if audience_decision_response: + + message = 'User "{}" meets conditions for targeting rule {}.'.format(user_id, logging_key) + self.logger.debug(message) + decide_reasons.append(message) + + bucketed_variation, bucket_reasons = self.bucketer.bucket(config, rollout_rule, user_id, + bucketing_id) # used this from existing, now old code + decide_reasons.append(bucket_reasons) + + if bucketed_variation: + message = 'User "{}" bucketed into a targeting rule {}.'.format(user_id, logging_key) + self.logger.debug(message) + decide_reasons.append(message) + + elif not everyone_else: + # skip this logging for EveryoneElse since this has a message not for everyone_else + message = 'User "{}" not bucketed into a targeting rule {}.'.format(user_id, + logging_key) self.logger.debug(message) decide_reasons.append(message) - # Determine bucketing ID to be used - 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( - message - ) - 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( - message - ) - decide_reasons.append(message) - break - - # Evaluate last rule i.e. "Everyone Else" rule - everyone_else_rule = project_config.get_experiment_from_id(rollout.experiments[-1].get('id')) - audience_conditions = everyone_else_rule.get_audience_conditions_or_ids() - 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, 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: - 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), decide_reasons + # skip the rest of rollout rules to the everyone-else rule if audience matches but not bucketed. + skip_to_everyone_else = True - def get_variation_for_feature(self, project_config, feature, user_id, attributes=None, ignore_user_profile=False): + else: + message = 'User "{}" does not meet conditions for targeting rule {}.'.format(user_id, logging_key) + self.logger.debug(message) + decide_reasons.append(message) + + return (bucketed_variation, skip_to_everyone_else), decide_reasons + + def get_variation_for_feature(self, project_config, feature, user_context, ignore_user_profile=False): """ Returns the experiment/variation the user is bucketed in for the given feature. Args: project_config: Instance of ProjectConfig. feature: Feature for which we are determining if it is enabled or not for the given user. - user_id: ID for user. + user: user context 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. """ + user_id = user_context.user_id + attributes = user_context.get_user_attributes() + decide_reasons = [] + bucketing_id, reasons = self._get_bucketing_id(user_id, attributes) decide_reasons += reasons @@ -436,8 +503,8 @@ def get_variation_for_feature(self, project_config, feature, user_id, attributes for experiment in feature.experimentIds: experiment = project_config.get_experiment_from_id(experiment) if experiment: - variation, variation_reasons = self.get_variation( - project_config, experiment, user_id, attributes, ignore_user_profile) + variation, variation_reasons = self.get_variation_from_experiment_rule( + project_config, feature.key, experiment, user_context, ignore_user_profile) decide_reasons += variation_reasons if variation: return Decision(experiment, variation, enums.DecisionSources.FEATURE_TEST), decide_reasons @@ -445,6 +512,6 @@ def get_variation_for_feature(self, project_config, feature, user_id, attributes # 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) + return self.get_variation_for_rollout(project_config, rollout, user_context, ignore_user_profile) else: return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons diff --git a/optimizely/helpers/enums.py b/optimizely/helpers/enums.py index 8339eee6..8cfbd00e 100644 --- a/optimizely/helpers/enums.py +++ b/optimizely/helpers/enums.py @@ -115,6 +115,13 @@ class Errors(object): UNSUPPORTED_DATAFILE_VERSION = 'This version of the Python SDK does not support the given datafile version: "{}".' +class ForcedDecisionNotificationTypes(object): + USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED = 'Variation "{}" is mapped to flag "{}", rule "{}" and user "{}" in the forced decision map.' + USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED = 'Variation "{}" is mapped to flag "{}" and user "{}" in the forced decision map.' + USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED_BUT_INVALID = 'Invalid variation is mapped to flag "{}", rule "{}" and user "{}" in the forced decision map.' + USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED_BUT_INVALID = 'Invalid variation is mapped to flag "{}" and user "{}" in the forced decision map.' + + class HTTPHeaders(object): AUTHORIZATION = 'Authorization' IF_MODIFIED_SINCE = 'If-Modified-Since' diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 2c15f579..826239e8 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -11,6 +11,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from warnings import warn + from six import string_types from . import decision_service @@ -24,6 +26,7 @@ from .decision.optimizely_decide_option import OptimizelyDecideOption from .decision.optimizely_decision import OptimizelyDecision from .decision.optimizely_decision_message import OptimizelyDecisionMessage +from .decision_service import Decision from .error_handler import NoOpErrorHandler as noop_error_handler from .event import event_factory, user_event_factory from .event.event_processor import ForwardingEventProcessor @@ -55,28 +58,28 @@ def __init__( ): """ Optimizely init method for managing Custom projects. - Args: - datafile: Optional JSON string representing the project. Must provide at least one of datafile or sdk_key. - event_dispatcher: Provides a dispatch_event method which if given a URL and params sends a request to it. - logger: Optional component which provides a log method to log messages. By default nothing would be logged. - error_handler: Optional component which provides a handle_error method to handle exceptions. - By default all exceptions will be suppressed. - skip_json_validation: Optional boolean param which allows skipping JSON schema validation upon object invocation. - By default JSON schema validation will be performed. - user_profile_service: Optional component which provides methods to store and manage user profiles. - sdk_key: Optional string uniquely identifying the datafile corresponding to project and environment combination. - Must provide at least one of datafile or sdk_key. - config_manager: Optional component which implements optimizely.config_manager.BaseConfigManager. - notification_center: Optional instance of notification_center.NotificationCenter. Useful when providing own - config_manager.BaseConfigManager implementation which can be using the - same NotificationCenter instance. - event_processor: Optional component which processes the given event(s). - By default optimizely.event.event_processor.ForwardingEventProcessor is used - 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_decide_options: Optional list of decide options used with the decide APIs. - """ + Args: + datafile: Optional JSON string representing the project. Must provide at least one of datafile or sdk_key. + event_dispatcher: Provides a dispatch_event method which if given a URL and params sends a request to it. + logger: Optional component which provides a log method to log messages. By default nothing would be logged. + error_handler: Optional component which provides a handle_error method to handle exceptions. + By default all exceptions will be suppressed. + skip_json_validation: Optional boolean param which allows skipping JSON schema validation upon object invocation. + By default JSON schema validation will be performed. + user_profile_service: Optional component which provides methods to store and manage user profiles. + sdk_key: Optional string uniquely identifying the datafile corresponding to project and environment combination. + Must provide at least one of datafile or sdk_key. + config_manager: Optional component which implements optimizely.config_manager.BaseConfigManager. + notification_center: Optional instance of notification_center.NotificationCenter. Useful when providing own + config_manager.BaseConfigManager implementation which can be using the + same NotificationCenter instance. + event_processor: Optional component which processes the given event(s). + By default optimizely.event.event_processor.ForwardingEventProcessor is used + 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_decide_options: Optional list of decide options used with the decide APIs. + """ self.logger_name = '.'.join([__name__, self.__class__.__name__]) self.is_valid = True self.event_dispatcher = event_dispatcher or default_event_dispatcher @@ -134,9 +137,9 @@ def __init__( def _validate_instantiation_options(self): """ Helper method to validate all instantiation parameters. - Raises: - Exception if provided instantiation options are valid. - """ + Raises: + Exception if provided instantiation options are valid. + """ if self.config_manager and not validator.is_config_manager_valid(self.config_manager): raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT.format('config_manager')) @@ -158,14 +161,14 @@ def _validate_instantiation_options(self): def _validate_user_inputs(self, attributes=None, event_tags=None): """ Helper method to validate user inputs. - Args: - attributes: Dict representing user attributes. - event_tags: Dict representing metadata associated with an event. + Args: + attributes: Dict representing user attributes. + event_tags: Dict representing metadata associated with an event. - Returns: - Boolean True if inputs are valid. False otherwise. + Returns: + Boolean True if inputs are valid. False otherwise. - """ + """ if attributes and not validator.are_attributes_valid(attributes): self.logger.error('Provided attributes are in an invalid format.') @@ -183,17 +186,17 @@ def _send_impression_event(self, project_config, experiment, variation, flag_key user_id, attributes): """ Helper method to send impression event. - Args: - project_config: Instance of ProjectConfig. - experiment: Experiment for which impression event is being sent. - variation: Variation picked for user for the given experiment. - flag_key: key for a feature flag. - rule_key: key for an experiment. - rule_type: type for the source. - enabled: boolean representing if feature is enabled - user_id: ID for user. - attributes: Dict representing user attributes and values which need to be recorded. - """ + Args: + project_config: Instance of ProjectConfig. + experiment: Experiment for which impression event is being sent. + variation: Variation picked for user for the given experiment. + flag_key: key for a feature flag. + rule_key: key for an experiment. + rule_type: type for the source. + enabled: boolean representing if feature is enabled + user_id: ID for user. + attributes: Dict representing user attributes and values which need to be recorded. + """ variation_id = variation.id if variation is not None else None user_event = user_event_factory.UserEventFactory.create_impression_event( project_config, experiment, variation_id, flag_key, rule_key, rule_type, enabled, user_id, attributes @@ -215,20 +218,20 @@ def _get_feature_variable_for_type( ): """ Helper method to determine value for a certain variable attached to a feature flag based on type of variable. - Args: - project_config: Instance of ProjectConfig. - feature_key: Key of the feature whose variable's value is being accessed. - variable_key: Key of the variable whose value is to be accessed. - variable_type: Type of variable which could be one of boolean/double/integer/string. - user_id: ID for user. - attributes: Dict representing user attributes. - - Returns: - Value of the variable. None if: - - Feature key is invalid. - - Variable key is invalid. - - Mismatch with type of variable. - """ + Args: + project_config: Instance of ProjectConfig. + feature_key: Key of the feature whose variable's value is being accessed. + variable_key: Key of the variable whose value is to be accessed. + variable_type: Type of variable which could be one of boolean/double/integer/string. + user_id: ID for user. + attributes: Dict representing user attributes. + + Returns: + Value of the variable. None if: + - Feature key is invalid. + - Variable key is invalid. + - Mismatch with type of variable. + """ if not validator.is_non_empty_string(feature_key): self.logger.error(enums.Errors.INVALID_INPUT.format('feature_key')) return None @@ -264,7 +267,10 @@ 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) + + user_context = self.create_user_context(user_id, attributes) + decision, _ = self.decision_service.get_variation_for_feature(project_config, feature_flag, user_context) + if decision.variation: feature_enabled = decision.variation.featureEnabled @@ -315,20 +321,20 @@ 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. - Args: - project_config: Instance of ProjectConfig. - feature_key: Key of the feature whose variable's value is being accessed. - user_id: ID for user. - attributes: Dict representing user attributes. + Args: + project_config: Instance of ProjectConfig. + feature_key: Key of the feature whose variable's value is being accessed. + user_id: ID for user. + attributes: Dict representing user attributes. - Returns: - Dictionary of all variables. None if: - - Feature key is invalid. - """ + Returns: + Dictionary of all variables. None if: + - Feature key is invalid. + """ if not validator.is_non_empty_string(feature_key): self.logger.error(enums.Errors.INVALID_INPUT.format('feature_key')) return None @@ -347,8 +353,9 @@ 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) + user_context = self.create_user_context(user_id, attributes) + decision, _ = self.decision_service.get_variation_for_feature(project_config, feature_flag, user_context) + if decision.variation: feature_enabled = decision.variation.featureEnabled @@ -409,15 +416,15 @@ def _get_all_feature_variables_for_type( def activate(self, experiment_key, user_id, attributes=None): """ Buckets visitor and sends impression event to Optimizely. - Args: - experiment_key: Experiment which needs to be activated. - user_id: ID for user. - attributes: Dict representing user attributes and values which need to be recorded. + Args: + experiment_key: Experiment which needs to be activated. + user_id: ID for user. + attributes: Dict representing user attributes and values which need to be recorded. - Returns: - Variation key representing the variation the user will be bucketed in. - None if user is not in experiment or if experiment is not Running. - """ + Returns: + Variation key representing the variation the user will be bucketed in. + None if user is not in experiment or if experiment is not Running. + """ if not self.is_valid: self.logger.error(enums.Errors.INVALID_OPTIMIZELY.format('activate')) @@ -455,12 +462,12 @@ def activate(self, experiment_key, user_id, attributes=None): def track(self, event_key, user_id, attributes=None, event_tags=None): """ Send conversion event to Optimizely. - Args: - event_key: Event key representing the event which needs to be recorded. - user_id: ID for user. - attributes: Dict representing visitor attributes and values which need to be recorded. - event_tags: Dict representing metadata associated with the event. - """ + Args: + event_key: Event key representing the event which needs to be recorded. + user_id: ID for user. + attributes: Dict representing visitor attributes and values which need to be recorded. + event_tags: Dict representing metadata associated with the event. + """ if not self.is_valid: self.logger.error(enums.Errors.INVALID_OPTIMIZELY.format('track')) @@ -503,15 +510,15 @@ def track(self, event_key, user_id, attributes=None, event_tags=None): def get_variation(self, experiment_key, user_id, attributes=None): """ Gets variation where user will be bucketed. - Args: - experiment_key: Experiment for which user variation needs to be determined. - user_id: ID for user. - attributes: Dict representing user attributes. + Args: + experiment_key: Experiment for which user variation needs to be determined. + user_id: ID for user. + attributes: Dict representing user attributes. - Returns: - Variation key representing the variation the user will be bucketed in. - None if user is not in experiment or if experiment is not Running. - """ + Returns: + Variation key representing the variation the user will be bucketed in. + None if user is not in experiment or if experiment is not Running. + """ if not self.is_valid: self.logger.error(enums.Errors.INVALID_OPTIMIZELY.format('get_variation')) @@ -540,9 +547,8 @@ 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) - if variation: - variation_key = variation.key + user_context = self.create_user_context(user_id, attributes) + variation_key = self.decision_service.get_variation(project_config, experiment, user_context) if project_config.is_feature_experiment(experiment.id): decision_notification_type = enums.DecisionNotificationTypes.FEATURE_TEST @@ -560,16 +566,23 @@ def get_variation(self, experiment_key, user_id, attributes=None): return variation_key def is_feature_enabled(self, feature_key, user_id, attributes=None): - """ Returns true if the feature is enabled for the given user. + """ Warning: This method is deprecated. Use the decide API instead. + Refer to [the migration guide](https://docs.developers.optimizely.com/full-stack/v4.0/docs/migrate-from-older-versions-python). - Args: - feature_key: The key of the 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. + Returns true if the feature is enabled for the given user. - Returns: - True if the feature is enabled for the user. False otherwise. - """ + Args: + feature_key: The key of the 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. + + Returns: + True if the feature is enabled for the user. False otherwise. + """ + warn( + "Use 'decide' methods of 'OptimizelyUserContext' instead.", + DeprecationWarning + ) if not self.is_valid: self.logger.error(enums.Errors.INVALID_OPTIMIZELY.format('is_feature_enabled')) @@ -597,7 +610,8 @@ 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) + user_context = self.create_user_context(user_id, attributes) + decision, _ = self.decision_service.get_variation_for_feature(project_config, feature, user_context) is_source_experiment = decision.source == enums.DecisionSources.FEATURE_TEST is_source_rollout = decision.source == enums.DecisionSources.ROLLOUT @@ -643,15 +657,22 @@ def is_feature_enabled(self, feature_key, user_id, attributes=None): return feature_enabled def get_enabled_features(self, user_id, attributes=None): - """ Returns the list of features that are enabled for the user. + """Warning: This method is deprecated. Use the decide API instead. + Refer to [the migration guide](https://docs.developers.optimizely.com/full-stack/v4.0/docs/migrate-from-older-versions-python). + + Returns the list of features that are enabled for the user. - Args: - user_id: ID for user. - attributes: Dict representing user attributes. + Args: + user_id: ID for user. + attributes: Dict representing user attributes. - Returns: - A list of the keys of the features that are enabled for the user. - """ + Returns: + A list of the keys of the features that are enabled for the user. + """ + warn( + "Use 'decide' methods of 'OptimizelyUserContext' instead.", + DeprecationWarning + ) enabled_features = [] if not self.is_valid: @@ -677,19 +698,22 @@ def get_enabled_features(self, user_id, attributes=None): return enabled_features def get_feature_variable(self, feature_key, variable_key, user_id, attributes=None): - """ Returns value for a variable attached to a feature flag. - - Args: - feature_key: Key of the feature whose variable's value is being accessed. - variable_key: Key of the variable whose value is to be accessed. - user_id: ID for user. - attributes: Dict representing user attributes. - - Returns: - Value of the variable. None if: - - Feature key is invalid. - - Variable key is invalid. - """ + """Warning: This method is deprecated. Use the __decide__ API instead. + Refer to [the migration guide](https://docs.developers.optimizely.com/full-stack/v4.0/docs/migrate-from-older-versions-python). + + Returns value for a variable attached to a feature flag. + + Args: + feature_key: Key of the feature whose variable's value is being accessed. + variable_key: Key of the variable whose value is to be accessed. + user_id: ID for user. + attributes: Dict representing user attributes. + + Returns: + Value of the variable. None if: + - Feature key is invalid. + - Variable key is invalid. + """ project_config = self.config_manager.get_config() if not project_config: self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('get_feature_variable')) @@ -698,20 +722,27 @@ def get_feature_variable(self, feature_key, variable_key, user_id, attributes=No return self._get_feature_variable_for_type(project_config, feature_key, variable_key, None, user_id, attributes) def get_feature_variable_boolean(self, feature_key, variable_key, user_id, attributes=None): - """ Returns value for a certain boolean variable attached to a feature flag. + """Warning: This method is deprecated. Use the __decide__ API instead. + Refer to [the migration guide](https://docs.developers.optimizely.com/full-stack/v4.0/docs/migrate-from-older-versions-python). - Args: - feature_key: Key of the feature whose variable's value is being accessed. - variable_key: Key of the variable whose value is to be accessed. - user_id: ID for user. - attributes: Dict representing user attributes. + Returns value for a certain boolean variable attached to a feature flag. - Returns: - Boolean value of the variable. None if: - - Feature key is invalid. - - Variable key is invalid. - - Mismatch with type of variable. - """ + Args: + feature_key: Key of the feature whose variable's value is being accessed. + variable_key: Key of the variable whose value is to be accessed. + user_id: ID for user. + attributes: Dict representing user attributes. + + Returns: + Boolean value of the variable. None if: + - Feature key is invalid. + - Variable key is invalid. + - Mismatch with type of variable. + """ + warn( + "Use 'decide' methods of 'OptimizelyUserContext' instead.", + DeprecationWarning + ) variable_type = entities.Variable.Type.BOOLEAN project_config = self.config_manager.get_config() @@ -724,20 +755,27 @@ def get_feature_variable_boolean(self, feature_key, variable_key, user_id, attri ) def get_feature_variable_double(self, feature_key, variable_key, user_id, attributes=None): - """ Returns value for a certain double variable attached to a feature flag. + """Warning: This method is deprecated. Use the __decide__ API instead. + Refer to [the migration guide](https://docs.developers.optimizely.com/full-stack/v4.0/docs/migrate-from-older-versions-python). - Args: - feature_key: Key of the feature whose variable's value is being accessed. - variable_key: Key of the variable whose value is to be accessed. - user_id: ID for user. - attributes: Dict representing user attributes. + Returns value for a certain double variable attached to a feature flag. - Returns: - Double value of the variable. None if: - - Feature key is invalid. - - Variable key is invalid. - - Mismatch with type of variable. - """ + Args: + feature_key: Key of the feature whose variable's value is being accessed. + variable_key: Key of the variable whose value is to be accessed. + user_id: ID for user. + attributes: Dict representing user attributes. + + Returns: + Double value of the variable. None if: + - Feature key is invalid. + - Variable key is invalid. + - Mismatch with type of variable. + """ + warn( + "Use 'decide' methods of 'OptimizelyUserContext' instead.", + DeprecationWarning + ) variable_type = entities.Variable.Type.DOUBLE project_config = self.config_manager.get_config() @@ -750,20 +788,27 @@ def get_feature_variable_double(self, feature_key, variable_key, user_id, attrib ) def get_feature_variable_integer(self, feature_key, variable_key, user_id, attributes=None): - """ Returns value for a certain integer variable attached to a feature flag. + """Warning: This method is deprecated. Use the __decide__ API instead. + Refer to [the migration guide](https://docs.developers.optimizely.com/full-stack/v4.0/docs/migrate-from-older-versions-python). - Args: - feature_key: Key of the feature whose variable's value is being accessed. - variable_key: Key of the variable whose value is to be accessed. - user_id: ID for user. - attributes: Dict representing user attributes. + Returns value for a certain integer variable attached to a feature flag. - Returns: - Integer value of the variable. None if: - - Feature key is invalid. - - Variable key is invalid. - - Mismatch with type of variable. - """ + Args: + feature_key: Key of the feature whose variable's value is being accessed. + variable_key: Key of the variable whose value is to be accessed. + user_id: ID for user. + attributes: Dict representing user attributes. + + Returns: + Integer value of the variable. None if: + - Feature key is invalid. + - Variable key is invalid. + - Mismatch with type of variable. + """ + warn( + "Use 'decide' methods of 'OptimizelyUserContext' instead.", + DeprecationWarning + ) variable_type = entities.Variable.Type.INTEGER project_config = self.config_manager.get_config() @@ -776,20 +821,27 @@ def get_feature_variable_integer(self, feature_key, variable_key, user_id, attri ) def get_feature_variable_string(self, feature_key, variable_key, user_id, attributes=None): - """ Returns value for a certain string variable attached to a feature. + """Warning: This method is deprecated. Use the __decide__ API instead. + Refer to [the migration guide](https://docs.developers.optimizely.com/full-stack/v4.0/docs/migrate-from-older-versions-python). + + Returns value for a certain string variable attached to a feature. - Args: - feature_key: Key of the feature whose variable's value is being accessed. - variable_key: Key of the variable whose value is to be accessed. - user_id: ID for user. - attributes: Dict representing user attributes. + Args: + feature_key: Key of the feature whose variable's value is being accessed. + variable_key: Key of the variable whose value is to be accessed. + user_id: ID for user. + attributes: Dict representing user attributes. - Returns: - String value of the variable. None if: - - Feature key is invalid. - - Variable key is invalid. - - Mismatch with type of variable. - """ + Returns: + String value of the variable. None if: + - Feature key is invalid. + - Variable key is invalid. + - Mismatch with type of variable. + """ + warn( + "Use 'decide' methods of 'OptimizelyUserContext' instead.", + DeprecationWarning + ) variable_type = entities.Variable.Type.STRING project_config = self.config_manager.get_config() @@ -802,20 +854,27 @@ def get_feature_variable_string(self, feature_key, variable_key, user_id, attrib ) def get_feature_variable_json(self, feature_key, variable_key, user_id, attributes=None): - """ Returns value for a certain JSON variable attached to a feature. + """Warning: This method is deprecated. Use the __decide__ API instead. + Refer to [the migration guide](https://docs.developers.optimizely.com/full-stack/v4.0/docs/migrate-from-older-versions-python). - Args: - feature_key: Key of the feature whose variable's value is being accessed. - variable_key: Key of the variable whose value is to be accessed. - user_id: ID for user. - attributes: Dict representing user attributes. + Returns value for a certain JSON variable attached to a feature. - Returns: - Dictionary object of the variable. None if: - - Feature key is invalid. - - Variable key is invalid. - - Mismatch with type of variable. - """ + Args: + feature_key: Key of the feature whose variable's value is being accessed. + variable_key: Key of the variable whose value is to be accessed. + user_id: ID for user. + attributes: Dict representing user attributes. + + Returns: + Dictionary object of the variable. None if: + - Feature key is invalid. + - Variable key is invalid. + - Mismatch with type of variable. + """ + warn( + "Use 'decide' methods of 'OptimizelyUserContext' instead.", + DeprecationWarning + ) variable_type = entities.Variable.Type.JSON project_config = self.config_manager.get_config() @@ -827,18 +886,25 @@ def get_feature_variable_json(self, feature_key, variable_key, user_id, attribut project_config, feature_key, variable_key, variable_type, user_id, attributes, ) - def get_all_feature_variables(self, feature_key, user_id, attributes=None): - """ Returns dictionary of all variables and their corresponding values in the context of a feature. + def get_all_feature_variables(self, feature_key, user_id, attributes): + """Warning: This method is deprecated. Use the __decide__ API instead. + Refer to [the migration guide](https://docs.developers.optimizely.com/full-stack/v4.0/docs/migrate-from-older-versions-python). - Args: - feature_key: Key of the feature whose variable's value is being accessed. - user_id: ID for user. - attributes: Dict representing user attributes. + Returns dictionary of all variables and their corresponding values in the context of a feature. - Returns: - Dictionary mapping variable key to variable value. None if: - - Feature key is invalid. - """ + Args: + feature_key: Key of the feature whose variable's value is being accessed. + user_id: ID for user. + attributes: Dict representing user attributes. + + Returns: + Dictionary mapping variable key to variable value. None if: + - Feature key is invalid. + """ + warn( + "Use 'decide' methods of 'OptimizelyUserContext' instead.", + DeprecationWarning + ) project_config = self.config_manager.get_config() if not project_config: @@ -852,15 +918,15 @@ def get_all_feature_variables(self, feature_key, user_id, attributes=None): def set_forced_variation(self, experiment_key, user_id, variation_key): """ Force a user into a variation for a given experiment. - Args: - experiment_key: A string key identifying the experiment. - user_id: The user ID. - variation_key: A string variation key that specifies the variation which the user. - will be forced into. If null, then clear the existing experiment-to-variation mapping. + Args: + experiment_key: A string key identifying the experiment. + user_id: The user ID. + variation_key: A string variation key that specifies the variation which the user. + will be forced into. If null, then clear the existing experiment-to-variation mapping. - Returns: - A boolean value that indicates if the set completed successfully. - """ + Returns: + A boolean value that indicates if the set completed successfully. + """ if not self.is_valid: self.logger.error(enums.Errors.INVALID_OPTIMIZELY.format('set_forced_variation')) @@ -884,13 +950,13 @@ def set_forced_variation(self, experiment_key, user_id, variation_key): def get_forced_variation(self, experiment_key, user_id): """ Gets the forced variation for a given user and experiment. - Args: - experiment_key: A string key identifying the experiment. - user_id: The user ID. + Args: + experiment_key: A string key identifying the experiment. + user_id: The user ID. - Returns: - The forced variation key. None if no forced variation key. - """ + Returns: + The forced variation key. None if no forced variation key. + """ if not self.is_valid: self.logger.error(enums.Errors.INVALID_OPTIMIZELY.format('get_forced_variation')) @@ -1021,11 +1087,22 @@ def _decide(self, user_context, key, decide_options=None): decision_event_dispatched = False ignore_ups = OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE in decide_options + # Check forced decisions first + forced_decision_response = user_context.find_validated_forced_decision(flag_key=key, rule_key=rule_key, + options=decide_options) - decision, decision_reasons = self.decision_service.get_variation_for_feature(config, feature_flag, user_id, - attributes, ignore_ups) + variation, received_response = forced_decision_response + reasons += received_response - reasons += decision_reasons + if variation: + decision = Decision(None, variation, enums.DecisionSources.FEATURE_TEST) + else: + # Regular decision + decision = decision_service.DecisionService.get_variation_for_feature(self.decision_service, config, + feature_flag, + user_context, ignore_ups) + decision, decision_reasons = decision + reasons += decision_reasons # Fill in experiment and variation if returned (rollouts can have featureEnabled variables as well.) if decision.experiment is not None: @@ -1160,29 +1237,21 @@ def _decide_for_keys(self, user_context, keys, decide_options=None): decisions[key] = decision return decisions - # TODO - NEW def get_flag_variation_by_key(self, flag_key, variation_key): + """ + Args: + flag_key: flag key + variation_key: variation key + + Returns: + Variation as a map. + """ config = self.config_manager.get_config() variations = config.flag_variations_map[flag_key] - print('VARIATIONS ', variations) - if not config: return None - if variations.key == variation_key: - return variations.key - - - - - - - - - - - - - - + for variation in variations: + if variation.key == variation_key: + return variation diff --git a/optimizely/optimizely_user_context.py b/optimizely/optimizely_user_context.py index 14c18583..f69f01c6 100644 --- a/optimizely/optimizely_user_context.py +++ b/optimizely/optimizely_user_context.py @@ -13,8 +13,13 @@ # limitations under the License. # -import threading import copy +import threading +from collections import namedtuple + +from optimizely import logger +from .decision.optimizely_decision_message import OptimizelyDecisionMessage +from .helpers import enums class OptimizelyUserContext(object): @@ -44,23 +49,10 @@ def __init__(self, optimizely_client, user_id, user_attributes=None): self._user_attributes = user_attributes.copy() if user_attributes else {} self.lock = threading.Lock() - # TODO - ADD FORCED DECISION class - """ - struct ForcedDecision { - let flagKey: String - let ruleKey: String? - var variationKey: String - } - var forcedDecisions = AtomicArray() - """ - class ForcedDecision(object): - def __init__(self, flag_key, rule_key, variation_key): - self.flag_key = flag_key - self.rule_key = rule_key - self.variation_key = variation_key + log = logger.SimpleLogger(min_level=enums.LogLevels.INFO) + ForcedDecision = namedtuple('ForcedDecision', 'flag_key rule_key variation_key') - # TODO - NEW def _clone(self): if not self.client: return None @@ -68,16 +60,10 @@ def _clone(self): user_context = OptimizelyUserContext(self.client, self.user_id, self.get_user_attributes()) if len(self.forced_decisions) > 0: - # Jae: - # Make sure the assigning is to make a copy. Some langs use ref and other make a copy when assigning array/map. - # Swift makes a copy automatically when assigning value type. Not sure about python. - # So it needs to be pass by value. So the original object is not changed. Change is only in the new object. Here I’ll need to call copy.deepcopy() - # The opposite. We won’t change the contents of the copied one. But the original can be changed any time later. - user_context.forced_decisions = copy.deepcopy(self.forced_decisions) # TODO - IMPORTANT -> CHECK IF WE NEED DEEPCOPY OR NOT - SEE SLACK W JAE + user_context.forced_decisions = copy.deepcopy(self.forced_decisions) return user_context - def get_user_attributes(self): with self.lock: return self._user_attributes.copy() @@ -148,8 +134,6 @@ def as_json(self): 'attributes': self.get_user_attributes(), } - - # TODO - NEW def set_forced_decision(self, flag_key, rule_key, variation_key): """ Sets the forced decision (variation key) for a given flag and an optional rule. @@ -164,24 +148,19 @@ def set_forced_decision(self, flag_key, rule_key, variation_key): """ config = self.client.get_optimizely_config() - if self.client is None or config is None: # TODO - check if to use "is not" or "not ==" - # TODO log error sdk key not ready - whichlogger, to show in console, logger for optimizely_client,loggger for what? where do we want it to log? - + if self.client is None or config is None: + self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return False - if rule_key: - print('xxx1 ', self.forced_decisions) - for decision in self.forced_decisions: - if decision.flag_key == flag_key and decision.rule_key == rule_key: - decision.variation_key = variation_key # TODO check if .variation_key needs to be a dict key instead of dot notation object + for decision in self.forced_decisions: + if decision.flag_key == flag_key and decision.rule_key == rule_key: + self.forced_decisions.variation_key = variation_key + return True self.forced_decisions.append(self.ForcedDecision(flag_key, rule_key, variation_key)) - print('xxx2 ', self.forced_decisions[0].variation_key) return True - - # TODO - NEW def get_forced_decision(self, flag_key, rule_key): """ Sets the forced decision (variation key) for a given flag and an optional rule. @@ -195,16 +174,13 @@ def get_forced_decision(self, flag_key, rule_key): """ config = self.client.get_optimizely_config() - if self.client is None or config is None: # TODO - check if to use "is not" or "not ==" - # TODO log error sdk key not ready - whichlogger, to sho win console, logger for optimizely_client,loggger for what? where do we want it to log? - - return False + if self.client is None or config is None: + self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) + return None return self.find_forced_decision(flag_key, rule_key) - - # TODO - NEW - def remove_forced_decision(self, flag_key, *arg): # making rule_key here optional arg - WHAT ABOUT IF RULE_KEY IS KEYWORD ARG????? <--- CHECK THIS! + def remove_forced_decision(self, flag_key, rule_key): """ Removes the forced decision for a given flag and an optional rule. @@ -217,19 +193,18 @@ def remove_forced_decision(self, flag_key, *arg): # making rule_key here o """ config = self.client.get_optimizely_config() - if self.client is None or config is None: # TODO - check if to use "is not" or "not ==" - # TODO log error sdk key not ready - whichlogger, to sho win console, logger for optimizely_client,loggger for what? where do we want it to log? - + if self.client is None or config is None: + self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return False # remove() built-in function by default removes the first occurrence of the element that meets the condition for decision in self.forced_decisions: - if decision.flag_key == flag_key and decision.rule_key == arg: - self.forced_decisions.remove(decision) #TODO - check if it needs to only remove the first occurrence and no other!!! Test separately if rmoe removes all occurences! + if decision.flag_key == flag_key and decision.rule_key == rule_key: + self.forced_decisions.remove(decision) + return True return False - # TODO - NEW def remove_all_forced_decisions(self): """ Removes all forced decisions bound to this user context. @@ -239,50 +214,66 @@ def remove_all_forced_decisions(self): """ config = self.client.get_optimizely_config() - if self.client is None or config is None: # TODO - check if to use "is not" or "not ==" - # TODO log error sdk key not ready - whichlogger, to sho win console, logger for optimizely_client,loggger for what? where do we want it to log? - + if self.client is None or config is None: + self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return False self.forced_decisions.clear() return True - # TODO - NEW def find_forced_decision(self, flag_key, rule_key): + if len(self.forced_decisions) == 0: return None + variation = "" for decision in self.forced_decisions: if decision.flag_key == flag_key and decision.rule_key == rule_key: - return decision.variation_key - - + variation = decision.variation_key + return variation - # TODO - For dding logger see this: https://github.com/optimizely/javascript-sdk/compare/pnguen/forced-decisions#diff-2bb39c11f271344df01b662f4313312870714813ceb8508ce7bdb851f09b5666R182-R192 - # TODO - NEW def find_validated_forced_decision(self, flag_key, rule_key, options): - reasons = [] # TODO - what to do with reasons?? Jae has reasons. Do we need them? - variation_key = self.find_forced_decision(flag_key, rule_key) - if variation_key: - self.client.get_flag_variation_by_key(flag_key, variation_key) - - - - - - - - - - - - - - - - - + reasons = [] + variation_key = self.find_forced_decision(flag_key, rule_key) + if variation_key: + variation = self.client.get_flag_variation_by_key(flag_key, variation_key) + if rule_key: + user_has_forced_decision = enums.ForcedDecisionNotificationTypes.USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED.format( + variation_key, + flag_key, + rule_key, + self.user_id) + + else: + user_has_forced_decision = enums.ForcedDecisionNotificationTypes.USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED.format( + variation_key, + flag_key, + self.user_id + ) + + reasons.append(user_has_forced_decision) + self.log.logger.info(user_has_forced_decision) + + return variation, reasons + + else: + if rule_key: + user_has_forced_decision_but_invalid = enums.ForcedDecisionNotificationTypes.USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED_BUT_INVALID.format( + flag_key, + rule_key, + self.user_id + ) + else: + user_has_forced_decision_but_invalid = enums.ForcedDecisionNotificationTypes.USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED_BUT_INVALID.format( + flag_key, + self.user_id + ) + + reasons.append(user_has_forced_decision_but_invalid) + self.log.logger.info(user_has_forced_decision_but_invalid) + + return None, reasons diff --git a/optimizely/project_config.py b/optimizely/project_config.py index a9da7642..71571568 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -13,10 +13,10 @@ import json -from .helpers import condition as condition_helper -from .helpers import enums from . import entities from . import exceptions +from .helpers import condition as condition_helper +from .helpers import enums SUPPORTED_VERSIONS = [ enums.DatafileVersions.V2, @@ -118,7 +118,6 @@ def __init__(self, datafile, logger, error_handler): variation.variables, 'id', entities.Variation.VariableUsage ) - # TODO - NEW self.feature_key_map = self._generate_key_map(self.feature_flags, 'key', entities.FeatureFlag) # As we cannot create json variables in datafile directly, here we convert @@ -139,8 +138,6 @@ def __init__(self, datafile, logger, error_handler): # Add this experiment in experiment-feature map. self.experiment_feature_map[exp_id] = [feature.id] - # TODO - NEW - # TODO - make sure to add a test for multiple flags. My test datafile only has a single flag. Because for loop needs to work across all flags. # all rules(experiment rules and delivery rules) for each flag self.flag_rules_map = {} for flag in self.feature_flags: @@ -148,8 +145,7 @@ def __init__(self, datafile, logger, error_handler): experiments = [self.experiment_id_map[exp_id] for exp_id in flag['experimentIds']] rollout = self.rollout_id_map[flag['rolloutId']] - rollout_experiments_id_map = self._generate_key_map(rollout.experiments, 'id', entities.Experiment) # TODO - not happy that _generate_key_map funciton is here. I can move this chunk out like other lookups. But the it's not ideal either - rollout_experiments = [exper for exper in rollout_experiments_id_map.values()] + rollout_experiments = self.get_rollout_experiments_map(rollout) if rollout and rollout.experiments: experiments.extend(rollout_experiments) @@ -209,6 +205,20 @@ def _deserialize_audience(audience_map): return audience_map + def get_rollout_experiments_map(self, rollout): + """ Helper method to get rollout experiments as a map. + + Args: + rollout: rollout + + Returns: + Mapped rollout experiments. + """ + rollout_experiments_id_map = self._generate_key_map(rollout.experiments, 'id', entities.Experiment) + rollout_experiments = [exper for exper in rollout_experiments_id_map.values()] + + return rollout_experiments + def get_typecast_value(self, value, type): """ Helper method to determine actual value based on type of feature variable. @@ -456,8 +466,8 @@ def get_attribute_id(self, attribute_key): if has_reserved_prefix: self.logger.warning( ( - 'Attribute %s unexpectedly has reserved prefix %s; using attribute ID ' - 'instead of reserved attribute name.' % (attribute_key, RESERVED_ATTRIBUTE_PREFIX) + 'Attribute %s unexpectedly has reserved prefix %s; using attribute ID ' + 'instead of reserved attribute name.' % (attribute_key, RESERVED_ATTRIBUTE_PREFIX) ) ) @@ -519,7 +529,6 @@ def get_variable_value_for_variation(self, variable, variation): if not variable or not variation: return None - if variation.id not in self.variation_variable_usage_map: self.logger.error('Variation with ID "%s" is not in the datafile.' % variation.id) return None From 340cbcecc8e8b53b0e48defb3eee6297d74c3b2d Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Tue, 19 Oct 2021 19:33:15 +0200 Subject: [PATCH 04/31] WIP: addressed implementation PR comments and fixed failing unit tests --- optimizely/decision_service.py | 79 +-- optimizely/helpers/enums.py | 14 +- optimizely/optimizely.py | 97 +--- optimizely/optimizely_user_context.py | 93 ++-- optimizely/project_config.py | 53 +- tests/base.py | 2 +- tests/test_config.py | 3 +- tests/test_decision_service.py | 392 ++++++++------- tests/test_optimizely.py | 664 ++++++++++++++------------ tests/test_user_context.py | 85 +++- 10 files changed, 786 insertions(+), 696 deletions(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index 7d71a598..40949466 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -183,14 +183,17 @@ def get_whitelisted_variation(self, project_config, experiment, user_id): """ 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: - message = 'User "%s" is forced in variation "%s".' % (user_id, variation_key) + forced_variation_key = forced_variations.get(user_id) + forced_variation = project_config.get_variation_from_key(experiment.key, forced_variation_key) + + if forced_variation: + message = 'User "%s" is forced in variation "%s".' % (user_id, forced_variation_key) self.logger.info(message) decide_reasons.append(message) - return variation, decide_reasons + + return forced_variation, decide_reasons return None, decide_reasons @@ -241,7 +244,6 @@ def get_variation( 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. """ - user_id = user_context.user_id attributes = user_context.get_user_attributes() @@ -326,12 +328,13 @@ def get_variation( decide_reasons.append(message) return None, decide_reasons - def get_variation_for_rollout(self, project_config, rollout, user, options): + def get_variation_for_rollout(self, project_config, feature, user, options): """ Determine which experiment/variation the user is in for a given rollout. Returns the variation of the first experiment the user qualifies for. Args: project_config: Instance of ProjectConfig. + flagKey: Feature key. rollout: Rollout for which we are getting the variation. user: ID and attributes for user. options: Decide options. @@ -340,18 +343,35 @@ def get_variation_for_rollout(self, project_config, rollout, user, options): Decision namedtuple consisting of experiment and variation for the user and array of log messages representing decision making. """ - user_id = user.user_id - attributes = user.get_user_attributes() decide_reasons = [] + + if not feature: + return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons + + rollout = project_config.get_rollout_from_id(feature.rolloutId) + + if not rollout: + message = 'There is no rollout of feature {}.'.format(feature.key) + self.logger.debug(message) + decide_reasons.append(message) + return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons + rollout_rules = project_config.get_rollout_experiments_map(rollout) + if not rollout_rules: + message = 'Rollout {} has no experiments.'.format(rollout.id) + self.logger.debug(message) + decide_reasons.append(message) + return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons + if rollout and len(rollout_rules) > 0: index = 0 while index < len(rollout_rules): decision_response, reasons_received = self.get_variation_from_delivery_rule(project_config, - rollout_rules[index].key, + feature, rollout_rules, index, user, options) + decide_reasons += reasons_received if decision_response: @@ -367,7 +387,7 @@ def get_variation_for_rollout(self, project_config, rollout, user, options): # the last rule is special for "Everyone Else" index = len(rollout_rules) - 1 if skip_to_everyone_else else index + 1 - return None, decide_reasons + return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons def get_variation_from_experiment_rule(self, config, flag_key, rule, user, options): """ Checks for experiment rule if decision is forced and returns it. @@ -398,7 +418,7 @@ def get_variation_from_experiment_rule(self, config, flag_key, rule, user, optio decide_reasons += variation_reasons return decision_variation, decide_reasons - def get_variation_from_delivery_rule(self, config, flag_key, rules, rule_index, user, options): + def get_variation_from_delivery_rule(self, config, feature, rules, rule_index, user, options): """ Checks for delivery rule if decision is forced and returns it. Otherwise returns a regular decision. @@ -422,7 +442,9 @@ def get_variation_from_delivery_rule(self, config, flag_key, rules, rule_index, # check forced decision first rule = rules[rule_index] - forced_decision_variation, reasons_received = user.find_validated_forced_decision(flag_key, rule.key, options) + forced_decision_variation, reasons_received = user.find_validated_forced_decision(feature.key, + rule.key, + options) decide_reasons += reasons_received if forced_decision_variation: @@ -441,18 +463,18 @@ def get_variation_from_delivery_rule(self, config, flag_key, rules, rule_index, audience_decision_response, reasons_received_audience = audience_helper.does_user_meet_audience_conditions( config, audience_conditions, enums.RolloutRuleAudienceEvaluationLogs, logging_key, attributes, self.logger) - # TODO - add regular logger here, and add log to reasons + decide_reasons += reasons_received_audience if audience_decision_response: - message = 'User "{}" meets conditions for targeting rule {}.'.format(user_id, logging_key) + message = 'User "{}" meets audience conditions for targeting rule {}.'.format(user_id, logging_key) self.logger.debug(message) decide_reasons.append(message) bucketed_variation, bucket_reasons = self.bucketer.bucket(config, rollout_rule, user_id, - bucketing_id) # used this from existing, now old code - decide_reasons.append(bucket_reasons) + bucketing_id) + decide_reasons.extend(bucket_reasons) if bucketed_variation: message = 'User "{}" bucketed into a targeting rule {}.'.format(user_id, logging_key) @@ -461,8 +483,8 @@ def get_variation_from_delivery_rule(self, config, flag_key, rules, rule_index, elif not everyone_else: # skip this logging for EveryoneElse since this has a message not for everyone_else - message = 'User "{}" not bucketed into a targeting rule {}.'.format(user_id, - logging_key) + message = 'User "{}" not bucketed into a targeting rule {}. ' \ + 'Checking "Everyone Else" rule now.'.format(user_id, logging_key) self.logger.debug(message) decide_reasons.append(message) @@ -470,7 +492,7 @@ def get_variation_from_delivery_rule(self, config, flag_key, rules, rule_index, skip_to_everyone_else = True else: - message = 'User "{}" does not meet conditions for targeting rule {}.'.format(user_id, logging_key) + message = 'User "{}" does not meet audience conditions for targeting rule {}.'.format(user_id, logging_key) self.logger.debug(message) decide_reasons.append(message) @@ -489,14 +511,8 @@ def get_variation_for_feature(self, project_config, feature, user_context, ignor Returns: Decision namedtuple consisting of experiment and variation for the user. """ - user_id = user_context.user_id - attributes = user_context.get_user_attributes() - decide_reasons = [] - bucketing_id, reasons = self._get_bucketing_id(user_id, attributes) - decide_reasons += reasons - # Check if the feature flag is under an experiment and the the user is bucketed into one of these experiments if feature.experimentIds: # Evaluate each experiment ID and return the first bucketed experiment variation @@ -511,7 +527,12 @@ def get_variation_for_feature(self, project_config, feature, user_context, ignor # 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_context, ignore_user_profile) - else: + return self.get_variation_for_rollout(project_config, feature, user_context, ignore_user_profile) + + # check if not part of experiment + if not feature.experimentIds: + return Decision(None, None, enums.DecisionSources.FEATURE_TEST), decide_reasons + + # check if not part of rollout + if not feature.rolloutId: return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons diff --git a/optimizely/helpers/enums.py b/optimizely/helpers/enums.py index 8cfbd00e..aed202eb 100644 --- a/optimizely/helpers/enums.py +++ b/optimizely/helpers/enums.py @@ -115,11 +115,15 @@ class Errors(object): UNSUPPORTED_DATAFILE_VERSION = 'This version of the Python SDK does not support the given datafile version: "{}".' -class ForcedDecisionNotificationTypes(object): - USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED = 'Variation "{}" is mapped to flag "{}", rule "{}" and user "{}" in the forced decision map.' - USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED = 'Variation "{}" is mapped to flag "{}" and user "{}" in the forced decision map.' - USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED_BUT_INVALID = 'Invalid variation is mapped to flag "{}", rule "{}" and user "{}" in the forced decision map.' - USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED_BUT_INVALID = 'Invalid variation is mapped to flag "{}" and user "{}" in the forced decision map.' +class ForcedDecisionLogs(object): + USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED = 'Variation ({}) is mapped to flag ({}), rule ({}) and user ({}) ' \ + 'in the forced decision map.' + USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED = 'Variation ({}) is mapped to flag ({}) and user ({}) ' \ + 'in the forced decision map.' + USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED_BUT_INVALID = 'Invalid variation is mapped to flag ({}), rule ({}) ' \ + 'and user ({}) in the forced decision map.' + USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED_BUT_INVALID = 'Invalid variation is mapped to flag ({}) ' \ + 'and user ({}) in the forced decision map.' class HTTPHeaders(object): diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 826239e8..bd0c2f66 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -11,8 +11,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from warnings import warn - from six import string_types from . import decision_service @@ -64,10 +62,12 @@ def __init__( logger: Optional component which provides a log method to log messages. By default nothing would be logged. error_handler: Optional component which provides a handle_error method to handle exceptions. By default all exceptions will be suppressed. - skip_json_validation: Optional boolean param which allows skipping JSON schema validation upon object invocation. + skip_json_validation: Optional boolean param which allows skipping JSON schema validation upon object + invocation. By default JSON schema validation will be performed. user_profile_service: Optional component which provides methods to store and manage user profiles. - sdk_key: Optional string uniquely identifying the datafile corresponding to project and environment combination. + sdk_key: Optional string uniquely identifying the datafile corresponding to project and environment + combination. Must provide at least one of datafile or sdk_key. config_manager: Optional component which implements optimizely.config_manager.BaseConfigManager. notification_center: Optional instance of notification_center.NotificationCenter. Useful when providing own @@ -76,7 +76,8 @@ def __init__( event_processor: Optional component which processes the given event(s). By default optimizely.event.event_processor.ForwardingEventProcessor is used which simply forwards events to the event dispatcher. - To enable event batching configure and use optimizely.event.event_processor.BatchEventProcessor. + 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_decide_options: Optional list of decide options used with the decide APIs. """ @@ -445,10 +446,16 @@ def activate(self, experiment_key, user_id, attributes=None): variation_key = self.get_variation(experiment_key, user_id, attributes) + # check for case where variation_key can be None when attributes are invalid if not variation_key: self.logger.info('Not activating user "%s".' % user_id) return None + # variation_key is normally a tuple object + if not variation_key[0]: + self.logger.info('Not activating user "%s".' % user_id) + return None + experiment = project_config.get_experiment_from_key(experiment_key) variation = project_config.get_variation_from_key(experiment_key, variation_key) @@ -566,10 +573,7 @@ def get_variation(self, experiment_key, user_id, attributes=None): return variation_key def is_feature_enabled(self, feature_key, user_id, attributes=None): - """ Warning: This method is deprecated. Use the decide API instead. - Refer to [the migration guide](https://docs.developers.optimizely.com/full-stack/v4.0/docs/migrate-from-older-versions-python). - - Returns true if the feature is enabled for the given user. + """ Returns true if the feature is enabled for the given user. Args: feature_key: The key of the feature for which we are determining if it is enabled or not for the given user. @@ -579,10 +583,6 @@ def is_feature_enabled(self, feature_key, user_id, attributes=None): Returns: True if the feature is enabled for the user. False otherwise. """ - warn( - "Use 'decide' methods of 'OptimizelyUserContext' instead.", - DeprecationWarning - ) if not self.is_valid: self.logger.error(enums.Errors.INVALID_OPTIMIZELY.format('is_feature_enabled')) @@ -657,10 +657,7 @@ def is_feature_enabled(self, feature_key, user_id, attributes=None): return feature_enabled def get_enabled_features(self, user_id, attributes=None): - """Warning: This method is deprecated. Use the decide API instead. - Refer to [the migration guide](https://docs.developers.optimizely.com/full-stack/v4.0/docs/migrate-from-older-versions-python). - - Returns the list of features that are enabled for the user. + """ Returns the list of features that are enabled for the user. Args: user_id: ID for user. @@ -669,10 +666,6 @@ def get_enabled_features(self, user_id, attributes=None): Returns: A list of the keys of the features that are enabled for the user. """ - warn( - "Use 'decide' methods of 'OptimizelyUserContext' instead.", - DeprecationWarning - ) enabled_features = [] if not self.is_valid: @@ -698,10 +691,7 @@ def get_enabled_features(self, user_id, attributes=None): return enabled_features def get_feature_variable(self, feature_key, variable_key, user_id, attributes=None): - """Warning: This method is deprecated. Use the __decide__ API instead. - Refer to [the migration guide](https://docs.developers.optimizely.com/full-stack/v4.0/docs/migrate-from-older-versions-python). - - Returns value for a variable attached to a feature flag. + """ Returns value for a variable attached to a feature flag. Args: feature_key: Key of the feature whose variable's value is being accessed. @@ -722,10 +712,7 @@ def get_feature_variable(self, feature_key, variable_key, user_id, attributes=No return self._get_feature_variable_for_type(project_config, feature_key, variable_key, None, user_id, attributes) def get_feature_variable_boolean(self, feature_key, variable_key, user_id, attributes=None): - """Warning: This method is deprecated. Use the __decide__ API instead. - Refer to [the migration guide](https://docs.developers.optimizely.com/full-stack/v4.0/docs/migrate-from-older-versions-python). - - Returns value for a certain boolean variable attached to a feature flag. + """ Returns value for a certain boolean variable attached to a feature flag. Args: feature_key: Key of the feature whose variable's value is being accessed. @@ -739,10 +726,6 @@ def get_feature_variable_boolean(self, feature_key, variable_key, user_id, attri - Variable key is invalid. - Mismatch with type of variable. """ - warn( - "Use 'decide' methods of 'OptimizelyUserContext' instead.", - DeprecationWarning - ) variable_type = entities.Variable.Type.BOOLEAN project_config = self.config_manager.get_config() @@ -755,10 +738,7 @@ def get_feature_variable_boolean(self, feature_key, variable_key, user_id, attri ) def get_feature_variable_double(self, feature_key, variable_key, user_id, attributes=None): - """Warning: This method is deprecated. Use the __decide__ API instead. - Refer to [the migration guide](https://docs.developers.optimizely.com/full-stack/v4.0/docs/migrate-from-older-versions-python). - - Returns value for a certain double variable attached to a feature flag. + """ Returns value for a certain double variable attached to a feature flag. Args: feature_key: Key of the feature whose variable's value is being accessed. @@ -772,10 +752,6 @@ def get_feature_variable_double(self, feature_key, variable_key, user_id, attrib - Variable key is invalid. - Mismatch with type of variable. """ - warn( - "Use 'decide' methods of 'OptimizelyUserContext' instead.", - DeprecationWarning - ) variable_type = entities.Variable.Type.DOUBLE project_config = self.config_manager.get_config() @@ -788,10 +764,7 @@ def get_feature_variable_double(self, feature_key, variable_key, user_id, attrib ) def get_feature_variable_integer(self, feature_key, variable_key, user_id, attributes=None): - """Warning: This method is deprecated. Use the __decide__ API instead. - Refer to [the migration guide](https://docs.developers.optimizely.com/full-stack/v4.0/docs/migrate-from-older-versions-python). - - Returns value for a certain integer variable attached to a feature flag. + """ Returns value for a certain integer variable attached to a feature flag. Args: feature_key: Key of the feature whose variable's value is being accessed. @@ -805,10 +778,6 @@ def get_feature_variable_integer(self, feature_key, variable_key, user_id, attri - Variable key is invalid. - Mismatch with type of variable. """ - warn( - "Use 'decide' methods of 'OptimizelyUserContext' instead.", - DeprecationWarning - ) variable_type = entities.Variable.Type.INTEGER project_config = self.config_manager.get_config() @@ -821,10 +790,7 @@ def get_feature_variable_integer(self, feature_key, variable_key, user_id, attri ) def get_feature_variable_string(self, feature_key, variable_key, user_id, attributes=None): - """Warning: This method is deprecated. Use the __decide__ API instead. - Refer to [the migration guide](https://docs.developers.optimizely.com/full-stack/v4.0/docs/migrate-from-older-versions-python). - - Returns value for a certain string variable attached to a feature. + """ Returns value for a certain string variable attached to a feature. Args: feature_key: Key of the feature whose variable's value is being accessed. @@ -838,10 +804,6 @@ def get_feature_variable_string(self, feature_key, variable_key, user_id, attrib - Variable key is invalid. - Mismatch with type of variable. """ - warn( - "Use 'decide' methods of 'OptimizelyUserContext' instead.", - DeprecationWarning - ) variable_type = entities.Variable.Type.STRING project_config = self.config_manager.get_config() @@ -854,10 +816,7 @@ def get_feature_variable_string(self, feature_key, variable_key, user_id, attrib ) def get_feature_variable_json(self, feature_key, variable_key, user_id, attributes=None): - """Warning: This method is deprecated. Use the __decide__ API instead. - Refer to [the migration guide](https://docs.developers.optimizely.com/full-stack/v4.0/docs/migrate-from-older-versions-python). - - Returns value for a certain JSON variable attached to a feature. + """ Returns value for a certain JSON variable attached to a feature. Args: feature_key: Key of the feature whose variable's value is being accessed. @@ -871,10 +830,6 @@ def get_feature_variable_json(self, feature_key, variable_key, user_id, attribut - Variable key is invalid. - Mismatch with type of variable. """ - warn( - "Use 'decide' methods of 'OptimizelyUserContext' instead.", - DeprecationWarning - ) variable_type = entities.Variable.Type.JSON project_config = self.config_manager.get_config() @@ -887,10 +842,7 @@ def get_feature_variable_json(self, feature_key, variable_key, user_id, attribut ) def get_all_feature_variables(self, feature_key, user_id, attributes): - """Warning: This method is deprecated. Use the __decide__ API instead. - Refer to [the migration guide](https://docs.developers.optimizely.com/full-stack/v4.0/docs/migrate-from-older-versions-python). - - Returns dictionary of all variables and their corresponding values in the context of a feature. + """ Returns dictionary of all variables and their corresponding values in the context of a feature. Args: feature_key: Key of the feature whose variable's value is being accessed. @@ -901,10 +853,6 @@ def get_all_feature_variables(self, feature_key, user_id, attributes): Dictionary mapping variable key to variable value. None if: - Feature key is invalid. """ - warn( - "Use 'decide' methods of 'OptimizelyUserContext' instead.", - DeprecationWarning - ) project_config = self.config_manager.get_config() if not project_config: @@ -1247,11 +1195,12 @@ def get_flag_variation_by_key(self, flag_key, variation_key): Variation as a map. """ config = self.config_manager.get_config() - variations = config.flag_variations_map[flag_key] if not config: return None + variations = config.flag_variations_map[flag_key] + for variation in variations: if variation.key == variation_key: return variation diff --git a/optimizely/optimizely_user_context.py b/optimizely/optimizely_user_context.py index f69f01c6..5590aa93 100644 --- a/optimizely/optimizely_user_context.py +++ b/optimizely/optimizely_user_context.py @@ -26,7 +26,6 @@ class OptimizelyUserContext(object): """ Representation of an Optimizely User Context using which APIs are to be called. """ - forced_decisions = [] def __init__(self, optimizely_client, user_id, user_attributes=None): """ Create an instance of the Optimizely User Context. @@ -48,10 +47,10 @@ def __init__(self, optimizely_client, user_id, user_attributes=None): self._user_attributes = user_attributes.copy() if user_attributes else {} self.lock = threading.Lock() + self.forced_decisions = {} + self.log = logger.SimpleLogger(min_level=enums.LogLevels.INFO) - log = logger.SimpleLogger(min_level=enums.LogLevels.INFO) - - ForcedDecision = namedtuple('ForcedDecision', 'flag_key rule_key variation_key') + ForcedDecisionKeys = namedtuple('ForcedDecisionKeys', 'flag_key rule_key') def _clone(self): if not self.client: @@ -59,7 +58,7 @@ def _clone(self): user_context = OptimizelyUserContext(self.client, self.user_id, self.get_user_attributes()) - if len(self.forced_decisions) > 0: + if self.forced_decisions: user_context.forced_decisions = copy.deepcopy(self.forced_decisions) return user_context @@ -152,18 +151,14 @@ def set_forced_decision(self, flag_key, rule_key, variation_key): self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return False - for decision in self.forced_decisions: - if decision.flag_key == flag_key and decision.rule_key == rule_key: - self.forced_decisions.variation_key = variation_key - return True - - self.forced_decisions.append(self.ForcedDecision(flag_key, rule_key, variation_key)) + forced_decision_key = self.ForcedDecisionKeys(flag_key, rule_key) + self.forced_decisions[forced_decision_key] = variation_key return True def get_forced_decision(self, flag_key, rule_key): """ - Sets the forced decision (variation key) for a given flag and an optional rule. + Gets the forced decision (variation key) for a given flag and an optional rule. Args: flag_key: A flag key. @@ -178,7 +173,9 @@ def get_forced_decision(self, flag_key, rule_key): self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return None - return self.find_forced_decision(flag_key, rule_key) + forced_decision_key = self.find_forced_decision(flag_key, rule_key) + + return forced_decision_key if forced_decision_key else None def remove_forced_decision(self, flag_key, rule_key): """ @@ -197,11 +194,9 @@ def remove_forced_decision(self, flag_key, rule_key): self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return False - # remove() built-in function by default removes the first occurrence of the element that meets the condition - for decision in self.forced_decisions: - if decision.flag_key == flag_key and decision.rule_key == rule_key: - self.forced_decisions.remove(decision) - return True + forced_decision_key = self.ForcedDecisionKeys(flag_key, rule_key) + if self.forced_decisions[forced_decision_key]: + del self.forced_decisions[forced_decision_key] return False @@ -224,14 +219,13 @@ def remove_all_forced_decisions(self): def find_forced_decision(self, flag_key, rule_key): - if len(self.forced_decisions) == 0: + if not self.forced_decisions: return None - variation = "" - for decision in self.forced_decisions: - if decision.flag_key == flag_key and decision.rule_key == rule_key: - variation = decision.variation_key - return variation + forced_decision_key = self.ForcedDecisionKeys(flag_key, rule_key) + variation_key = self.forced_decisions.get(forced_decision_key, None) + + return variation_key if variation_key else None def find_validated_forced_decision(self, flag_key, rule_key, options): @@ -241,39 +235,36 @@ def find_validated_forced_decision(self, flag_key, rule_key, options): if variation_key: variation = self.client.get_flag_variation_by_key(flag_key, variation_key) - if rule_key: - user_has_forced_decision = enums.ForcedDecisionNotificationTypes.USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED.format( - variation_key, - flag_key, - rule_key, - self.user_id) - - else: - user_has_forced_decision = enums.ForcedDecisionNotificationTypes.USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED.format( - variation_key, - flag_key, - self.user_id - ) - - reasons.append(user_has_forced_decision) - self.log.logger.info(user_has_forced_decision) + if variation: + if rule_key: + user_has_forced_decision = enums.ForcedDecisionLogs\ + .USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED.format(variation_key, + flag_key, + rule_key, + self.user_id) + + else: + user_has_forced_decision = enums.ForcedDecisionLogs\ + .USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED.format(variation_key, + flag_key, + self.user_id) + + reasons.append(user_has_forced_decision) + self.log.logger.debug(user_has_forced_decision) return variation, reasons else: if rule_key: - user_has_forced_decision_but_invalid = enums.ForcedDecisionNotificationTypes.USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED_BUT_INVALID.format( - flag_key, - rule_key, - self.user_id - ) + user_has_forced_decision_but_invalid = enums.ForcedDecisionLogs\ + .USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED_BUT_INVALID.format(flag_key, + rule_key, + self.user_id) else: - user_has_forced_decision_but_invalid = enums.ForcedDecisionNotificationTypes.USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED_BUT_INVALID.format( - flag_key, - self.user_id - ) + user_has_forced_decision_but_invalid = enums.ForcedDecisionLogs\ + .USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED_BUT_INVALID.format(flag_key, self.user_id) reasons.append(user_has_forced_decision_but_invalid) - self.log.logger.info(user_has_forced_decision_but_invalid) + self.log.logger.debug(user_has_forced_decision_but_invalid) - return None, reasons + return None, reasons diff --git a/optimizely/project_config.py b/optimizely/project_config.py index 71571568..77f46531 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -142,15 +142,19 @@ def __init__(self, datafile, logger, error_handler): self.flag_rules_map = {} for flag in self.feature_flags: - experiments = [self.experiment_id_map[exp_id] for exp_id in flag['experimentIds']] - rollout = self.rollout_id_map[flag['rolloutId']] + experiments = [] + if not flag['experimentIds'] == '': + for exp_id in flag['experimentIds']: + experiments.append(self.experiment_id_map[exp_id]) + if not flag['rolloutId'] == '': + rollout = self.rollout_id_map[flag['rolloutId']] - rollout_experiments = self.get_rollout_experiments_map(rollout) + rollout_experiments = self.get_rollout_experiments_map(rollout) - if rollout and rollout.experiments: - experiments.extend(rollout_experiments) + if rollout and rollout.experiments: + experiments.extend(rollout_experiments) - self.flag_rules_map[flag['key']] = experiments + self.flag_rules_map[flag['key']] = experiments # All variations for each flag # Datafile does not contain a separate entity for this. @@ -378,31 +382,40 @@ def get_audience(self, audience_id): self.logger.error('Audience ID "%s" is not in datafile.' % audience_id) self.error_handler.handle_error(exceptions.InvalidAudienceException((enums.Errors.INVALID_AUDIENCE))) - def get_variation_from_key(self, experiment_key, variation_key): - """ Get variation given experiment and variation key. + def get_variation_from_key(self, experiment_key, variation): + """ Get variation given experiment and variation. Args: experiment: Key representing parent experiment of variation. variation_key: Key representing the variation. + Variation is of type variation object or None. Returns Object representing the variation. """ - variation_map = self.variation_key_map.get(experiment_key) + variation_key = None - if variation_map: - variation = variation_map.get(variation_key) - if variation: - return variation + if isinstance(variation, tuple): + if isinstance(variation[0], entities.Variation): + variation_key, received_reasons = variation + else: + variation_map = self.variation_key_map.get(experiment_key) + + if variation_map: + variation_key = variation_map.get(variation) else: - self.logger.error('Variation key "%s" is not in datafile.' % variation_key) - self.error_handler.handle_error(exceptions.InvalidVariationException(enums.Errors.INVALID_VARIATION)) + self.logger.error('Experiment key "%s" is not in datafile.' % experiment_key) + self.error_handler.handle_error( + exceptions.InvalidExperimentException(enums.Errors.INVALID_EXPERIMENT_KEY)) return None - self.logger.error('Experiment key "%s" is not in datafile.' % experiment_key) - self.error_handler.handle_error(exceptions.InvalidExperimentException(enums.Errors.INVALID_EXPERIMENT_KEY)) - return None + if variation_key: + return variation_key + else: + self.logger.error('Variation key "%s" is not in datafile.' % variation) + self.error_handler.handle_error(exceptions.InvalidVariationException(enums.Errors.INVALID_VARIATION)) + return None def get_variation_from_id(self, experiment_key, variation_id): """ Get variation given experiment and variation ID. @@ -466,8 +479,8 @@ def get_attribute_id(self, attribute_key): if has_reserved_prefix: self.logger.warning( ( - 'Attribute %s unexpectedly has reserved prefix %s; using attribute ID ' - 'instead of reserved attribute name.' % (attribute_key, RESERVED_ATTRIBUTE_PREFIX) + 'Attribute %s unexpectedly has reserved prefix %s; using attribute ID ' + 'instead of reserved attribute name.' % (attribute_key, RESERVED_ATTRIBUTE_PREFIX) ) ) diff --git a/tests/base.py b/tests/base.py index 05127caf..3e5f6ff6 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1058,4 +1058,4 @@ def setUp(self, config_dict='config_dict'): config = getattr(self, config_dict) self.optimizely = optimizely.Optimizely(json.dumps(config)) - self.project_config = self.optimizely.config_manager.get_config() + self.project_config = self.optimizely.config_manager.get_config() \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py index fe0f8f38..e2b52faa 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -12,6 +12,7 @@ # limitations under the License. import json + import mock from optimizely import entities @@ -20,7 +21,6 @@ from optimizely import logger from optimizely import optimizely from optimizely.helpers import enums - from . import base @@ -610,6 +610,7 @@ def test_get_bot_filtering(self): # Assert bot filtering is retrieved as provided in the data file opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) project_config = opt_obj.config_manager.get_config() + self.assertEqual( self.config_dict_with_features['botFiltering'], project_config.get_bot_filtering_value(), ) diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py index 97fefce7..4685e24a 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -12,11 +12,13 @@ # limitations under the License. import json + import mock from optimizely import decision_service from optimizely import entities from optimizely import optimizely +from optimizely import optimizely_user_context from optimizely import user_profile from optimizely.helpers import enums from . import base @@ -51,7 +53,7 @@ def test_get_bucketing_id__no_bucketing_id_attribute(self): def test_get_bucketing_id__bucketing_id_attribute(self): """ Test that _get_bucketing_id returns correct bucketing ID when there is bucketing ID attribute. """ with mock.patch.object( - self.decision_service, "logger" + 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"} @@ -65,7 +67,7 @@ def test_get_bucketing_id__bucketing_id_attribute(self): def test_get_bucketing_id__bucketing_id_attribute_not_a_string(self): """ Test that _get_bucketing_id returns user ID as bucketing ID when bucketing ID attribute is not a string""" with mock.patch.object( - self.decision_service, "logger" + self.decision_service, "logger" ) as mock_decision_service_logging: bucketing_id, _ = self.decision_service._get_bucketing_id( "test_user", {"$opt_bucketing_id": True} @@ -140,7 +142,7 @@ def test_set_forced_variation__invalid_variation_key(self): ) ) with mock.patch.object( - self.decision_service, "logger" + self.decision_service, "logger" ) as mock_decision_service_logging: self.assertIs( self.decision_service.set_forced_variation( @@ -246,7 +248,7 @@ def test_set_forced_variation_when_called_to_remove_forced_variation(self): ) with mock.patch.object( - self.decision_service, "logger" + self.decision_service, "logger" ) as mock_decision_service_logging: self.assertTrue( self.decision_service.set_forced_variation( @@ -264,7 +266,7 @@ def test_set_forced_variation_when_called_to_remove_forced_variation(self): ) with mock.patch.object( - self.decision_service, "logger" + self.decision_service, "logger" ) as mock_decision_service_logging: self.assertTrue( self.decision_service.set_forced_variation( @@ -326,7 +328,7 @@ def test_get_forced_variation_with_none_set_for_user(self): self.decision_service.forced_variation_map["test_user"] = {} with mock.patch.object( - self.decision_service, "logger" + self.decision_service, "logger" ) as mock_decision_service_logging: variation, _ = self.decision_service.get_forced_variation( self.project_config, "test_experiment", "test_user" @@ -347,7 +349,7 @@ def test_get_forced_variation_missing_variation_mapped_to_experiment(self): ] = None with mock.patch.object( - self.decision_service, "logger" + self.decision_service, "logger" ) as mock_decision_service_logging: variation, _ = self.decision_service.get_forced_variation( self.project_config, "test_experiment", "test_user" @@ -365,7 +367,7 @@ def test_get_whitelisted_variation__user_in_forced_variation(self): experiment = self.project_config.get_experiment_from_key("test_experiment") with mock.patch.object( - self.decision_service, "logger" + self.decision_service, "logger" ) as mock_decision_service_logging: variation, _ = self.decision_service.get_whitelisted_variation( self.project_config, experiment, "user_1" @@ -384,8 +386,8 @@ def test_get_whitelisted_variation__user_in_invalid_variation(self): experiment = self.project_config.get_experiment_from_key("test_experiment") with mock.patch( - "optimizely.project_config.ProjectConfig.get_variation_from_key", - return_value=None, + "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" @@ -404,7 +406,7 @@ def test_get_stored_variation__stored_decision_available(self): "test_user", experiment_bucket_map={"111127": {"variation_id": "111128"}} ) with mock.patch.object( - self.decision_service, "logger" + self.decision_service, "logger" ) as mock_decision_service_logging: variation = self.decision_service.get_stored_variation( self.project_config, experiment, profile @@ -433,11 +435,13 @@ def test_get_stored_variation__no_stored_decision_available(self): def test_get_variation__experiment_not_running(self): """ Test that get_variation returns None if experiment is not Running. """ + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={}) experiment = self.project_config.get_experiment_from_key("test_experiment") # Mark experiment paused experiment.status = "Paused" with mock.patch( - "optimizely.decision_service.DecisionService.get_forced_variation" + "optimizely.decision_service.DecisionService.get_forced_variation" ) as mock_get_forced_variation, mock.patch.object( self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( @@ -452,7 +456,7 @@ def test_get_variation__experiment_not_running(self): "optimizely.user_profile.UserProfileService.save" ) as mock_save: variation, _ = self.decision_service.get_variation( - self.project_config, experiment, "test_user", None + self.project_config, experiment, user, None ) self.assertIsNone( variation @@ -472,10 +476,13 @@ def test_get_variation__experiment_not_running(self): def test_get_variation__bucketing_id_provided(self): """ Test that get_variation calls bucket with correct bucketing ID if provided. """ + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={"random_key": "random_value", + "$opt_bucketing_id": "user_bucket_value", }) experiment = self.project_config.get_experiment_from_key("test_experiment") with mock.patch( - "optimizely.decision_service.DecisionService.get_forced_variation", - return_value=[None, []], + "optimizely.decision_service.DecisionService.get_forced_variation", + return_value=[None, []], ), mock.patch( "optimizely.decision_service.DecisionService.get_stored_variation", return_value=None, @@ -488,11 +495,7 @@ def test_get_variation__bucketing_id_provided(self): variation, _ = self.decision_service.get_variation( self.project_config, experiment, - "test_user", - { - "random_key": "random_value", - "$opt_bucketing_id": "user_bucket_value", - }, + user ) # Assert that bucket is called with appropriate bucketing ID @@ -503,10 +506,12 @@ def test_get_variation__bucketing_id_provided(self): def test_get_variation__user_whitelisted_for_variation(self): """ Test that get_variation returns whitelisted variation if user is whitelisted. """ + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={}) 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"), []], + "optimizely.decision_service.DecisionService.get_whitelisted_variation", + 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( @@ -519,7 +524,7 @@ def test_get_variation__user_whitelisted_for_variation(self): "optimizely.user_profile.UserProfileService.save" ) as mock_save: variation, _ = self.decision_service.get_variation( - self.project_config, experiment, "test_user", None + self.project_config, experiment, user ) self.assertEqual( entities.Variation("111128", "control"), @@ -539,10 +544,12 @@ def test_get_variation__user_whitelisted_for_variation(self): def test_get_variation__user_has_stored_decision(self): """ Test that get_variation returns stored decision if user has variation available for given experiment. """ + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={}) experiment = self.project_config.get_experiment_from_key("test_experiment") with mock.patch( - "optimizely.decision_service.DecisionService.get_whitelisted_variation", - return_value=[None, []], + "optimizely.decision_service.DecisionService.get_whitelisted_variation", + return_value=[None, []], ) as mock_get_whitelisted_variation, mock.patch( "optimizely.decision_service.DecisionService.get_stored_variation", return_value=entities.Variation("111128", "control"), @@ -560,7 +567,7 @@ def test_get_variation__user_has_stored_decision(self): "optimizely.user_profile.UserProfileService.save" ) as mock_save: variation, _ = self.decision_service.get_variation( - self.project_config, experiment, "test_user", None + self.project_config, experiment, user, None ) self.assertEqual( entities.Variation("111128", "control"), @@ -584,14 +591,16 @@ def test_get_variation__user_has_stored_decision(self): self.assertEqual(0, mock_save.call_count) def test_get_variation__user_bucketed_for_new_experiment__user_profile_service_available( - self, + self, ): """ Test that get_variation buckets and returns variation if no forced variation or decision available. Also, stores decision if user profile service is available. """ + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={}) experiment = self.project_config.get_experiment_from_key("test_experiment") with mock.patch.object( - self.decision_service, "logger" + self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", return_value=[None, []], @@ -610,7 +619,7 @@ def test_get_variation__user_bucketed_for_new_experiment__user_profile_service_a "optimizely.user_profile.UserProfileService.save" ) as mock_save: variation, _ = self.decision_service.get_variation( - self.project_config, experiment, "test_user", None + self.project_config, experiment, user, None ) self.assertEqual( entities.Variation("111129", "variation"), @@ -619,7 +628,7 @@ def test_get_variation__user_bucketed_for_new_experiment__user_profile_service_a # Assert that user is bucketed and new decision is stored mock_get_whitelisted_variation.assert_called_once_with( - self.project_config, experiment, "test_user" + self.project_config, experiment, user.user_id ) mock_lookup.assert_called_once_with("test_user") self.assertEqual(1, mock_get_stored_variation.call_count) @@ -628,7 +637,7 @@ def test_get_variation__user_bucketed_for_new_experiment__user_profile_service_a experiment.get_audience_conditions_or_ids(), enums.ExperimentAudienceEvaluationLogs, "test_experiment", - None, + user.get_user_attributes(), mock_decision_service_logging ) mock_bucket.assert_called_once_with( @@ -642,7 +651,7 @@ def test_get_variation__user_bucketed_for_new_experiment__user_profile_service_a ) def test_get_variation__user_bucketed_for_new_experiment__user_profile_service_not_available( - self, + self, ): """ Test that get_variation buckets and returns variation if no forced variation and no user profile service available. """ @@ -650,9 +659,11 @@ def test_get_variation__user_bucketed_for_new_experiment__user_profile_service_n # Unset user profile service self.decision_service.user_profile_service = None + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={}) experiment = self.project_config.get_experiment_from_key("test_experiment") with mock.patch.object( - self.decision_service, "logger" + self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", return_value=[None, []], @@ -669,7 +680,7 @@ def test_get_variation__user_bucketed_for_new_experiment__user_profile_service_n "optimizely.user_profile.UserProfileService.save" ) as mock_save: variation, _ = self.decision_service.get_variation( - self.project_config, experiment, "test_user", None + self.project_config, experiment, user, None ) self.assertEqual( entities.Variation("111129", "variation"), @@ -687,7 +698,7 @@ def test_get_variation__user_bucketed_for_new_experiment__user_profile_service_n experiment.get_audience_conditions_or_ids(), enums.ExperimentAudienceEvaluationLogs, "test_experiment", - None, + user.get_user_attributes(), mock_decision_service_logging ) mock_bucket.assert_called_once_with( @@ -698,9 +709,11 @@ def test_get_variation__user_bucketed_for_new_experiment__user_profile_service_n def test_get_variation__user_does_not_meet_audience_conditions(self): """ Test that get_variation returns None if user is not in experiment. """ + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={}) experiment = self.project_config.get_experiment_from_key("test_experiment") with mock.patch.object( - self.decision_service, "logger" + self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", return_value=[None, []], @@ -718,7 +731,7 @@ def test_get_variation__user_does_not_meet_audience_conditions(self): "optimizely.user_profile.UserProfileService.save" ) as mock_save: variation, _ = self.decision_service.get_variation( - self.project_config, experiment, "test_user", None + self.project_config, experiment, user, None ) self.assertIsNone( variation @@ -737,7 +750,7 @@ def test_get_variation__user_does_not_meet_audience_conditions(self): experiment.get_audience_conditions_or_ids(), enums.ExperimentAudienceEvaluationLogs, "test_experiment", - None, + user.get_user_attributes(), mock_decision_service_logging ) self.assertEqual(0, mock_bucket.call_count) @@ -746,9 +759,11 @@ def test_get_variation__user_does_not_meet_audience_conditions(self): def test_get_variation__user_profile_in_invalid_format(self): """ Test that get_variation handles invalid user profile gracefully. """ + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={}) experiment = self.project_config.get_experiment_from_key("test_experiment") with mock.patch.object( - self.decision_service, "logger" + self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", return_value=[None, []], @@ -766,7 +781,7 @@ def test_get_variation__user_profile_in_invalid_format(self): "optimizely.user_profile.UserProfileService.save" ) as mock_save: variation, _ = self.decision_service.get_variation( - self.project_config, experiment, "test_user", None + self.project_config, experiment, user, None ) self.assertEqual( entities.Variation("111129", "variation"), @@ -785,7 +800,7 @@ def test_get_variation__user_profile_in_invalid_format(self): experiment.get_audience_conditions_or_ids(), enums.ExperimentAudienceEvaluationLogs, "test_experiment", - None, + user.get_user_attributes(), mock_decision_service_logging ) mock_decision_service_logging.warning.assert_called_once_with( @@ -804,9 +819,11 @@ def test_get_variation__user_profile_in_invalid_format(self): def test_get_variation__user_profile_lookup_fails(self): """ Test that get_variation acts gracefully when lookup fails. """ + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={}) experiment = self.project_config.get_experiment_from_key("test_experiment") with mock.patch.object( - self.decision_service, "logger" + self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", return_value=[None, []], @@ -824,7 +841,7 @@ def test_get_variation__user_profile_lookup_fails(self): "optimizely.user_profile.UserProfileService.save" ) as mock_save: variation, _ = self.decision_service.get_variation( - self.project_config, experiment, "test_user", None + self.project_config, experiment, user, None ) self.assertEqual( entities.Variation("111129", "variation"), @@ -843,7 +860,7 @@ def test_get_variation__user_profile_lookup_fails(self): experiment.get_audience_conditions_or_ids(), enums.ExperimentAudienceEvaluationLogs, "test_experiment", - None, + user.get_user_attributes(), mock_decision_service_logging ) mock_decision_service_logging.exception.assert_called_once_with( @@ -862,9 +879,11 @@ def test_get_variation__user_profile_lookup_fails(self): def test_get_variation__user_profile_save_fails(self): """ Test that get_variation acts gracefully when save fails. """ + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={}) experiment = self.project_config.get_experiment_from_key("test_experiment") with mock.patch.object( - self.decision_service, "logger" + self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", return_value=[None, []], @@ -882,7 +901,7 @@ def test_get_variation__user_profile_save_fails(self): side_effect=Exception("major problem"), ) as mock_save: variation, _ = self.decision_service.get_variation( - self.project_config, experiment, "test_user", None + self.project_config, experiment, user, None ) self.assertEqual( entities.Variation("111129", "variation"), @@ -900,9 +919,10 @@ def test_get_variation__user_profile_save_fails(self): experiment.get_audience_conditions_or_ids(), enums.ExperimentAudienceEvaluationLogs, "test_experiment", - None, + user.get_user_attributes(), mock_decision_service_logging ) + mock_decision_service_logging.exception.assert_called_once_with( 'Unable to save user profile for user "test_user".' ) @@ -919,9 +939,11 @@ def test_get_variation__user_profile_save_fails(self): def test_get_variation__ignore_user_profile_when_specified(self): """ Test that we ignore the user profile service if specified. """ + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={}) experiment = self.project_config.get_experiment_from_key("test_experiment") with mock.patch.object( - self.decision_service, "logger" + self.decision_service, "logger" ) as mock_decision_service_logging, mock.patch( "optimizely.decision_service.DecisionService.get_whitelisted_variation", return_value=[None, []], @@ -938,8 +960,7 @@ def test_get_variation__ignore_user_profile_when_specified(self): variation, _ = self.decision_service.get_variation( self.project_config, experiment, - "test_user", - None, + user, ignore_user_profile=True, ) self.assertEqual( @@ -956,7 +977,7 @@ def test_get_variation__ignore_user_profile_when_specified(self): experiment.get_audience_conditions_or_ids(), enums.ExperimentAudienceEvaluationLogs, "test_experiment", - None, + user.get_user_attributes(), mock_decision_service_logging ) mock_bucket.assert_called_once_with( @@ -976,13 +997,21 @@ def setUp(self): self.mock_config_logger = mock.patch.object(self.project_config, "logger") def test_get_variation_for_rollout__returns_none_if_no_experiments(self): - """ Test that get_variation_for_rollout returns None if there are no experiments (targeting rules). """ + """ Test that get_variation_for_rollout returns None if there are no experiments (targeting rules). + For this we assign None to the feature parameter. + There is one rolloutId in the datafile that has no experiments associsted with it. + rolloutId is tied to feature. That's why we make feature None which means there are no experiments. + """ + + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={}) with self.mock_config_logger as mock_logging: - no_experiment_rollout = self.project_config.get_rollout_from_id("201111") + feature = None variation_received, _ = self.decision_service.get_variation_for_rollout( - self.project_config, no_experiment_rollout, "test_user" + self.project_config, feature, user, None ) + self.assertEqual( decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), variation_received, @@ -995,16 +1024,18 @@ def test_get_variation_for_rollout__returns_decision_if_user_in_rollout(self): """ Test that get_variation_for_rollout returns Decision with experiment/variation if user meets targeting conditions for a rollout rule. """ - rollout = self.project_config.get_rollout_from_id("211111") + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={}) + feature = self.project_config.get_feature_from_key("test_feature_in_rollout") 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"), []], ) as mock_bucket: variation_received, _ = self.decision_service.get_variation_for_rollout( - self.project_config, rollout, "test_user" + self.project_config, feature, user, None ) self.assertEqual( decision_service.Decision( @@ -1017,33 +1048,35 @@ def test_get_variation_for_rollout__returns_decision_if_user_in_rollout(self): # Check all log messages mock_decision_service_logging.debug.assert_has_calls([ - mock.call('User "test_user" meets audience conditions for targeting rule 1.')] - ) + mock.call('User "test_user" meets audience conditions for targeting rule 1.'), + mock.call('User "test_user" bucketed into a targeting rule 1.')]) # Check that bucket is called with correct parameters mock_bucket.assert_called_once_with( self.project_config, self.project_config.get_experiment_from_id("211127"), "test_user", - "test_user", + ('test_user', []), ) def test_get_variation_for_rollout__calls_bucket_with_bucketing_id(self): """ Test that get_variation_for_rollout calls Bucketer.bucket with bucketing ID when provided. """ - rollout = self.project_config.get_rollout_from_id("211111") + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={"$opt_bucketing_id": "user_bucket_value"}) + feature = self.project_config.get_feature_from_key("test_feature_in_rollout") 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"), []], ) as mock_bucket: variation_received, _ = self.decision_service.get_variation_for_rollout( self.project_config, - rollout, - "test_user", - {"$opt_bucketing_id": "user_bucket_value"}, + feature, + user, + None ) self.assertEqual( decision_service.Decision( @@ -1063,26 +1096,28 @@ def test_get_variation_for_rollout__calls_bucket_with_bucketing_id(self): self.project_config, self.project_config.get_experiment_from_id("211127"), "test_user", - "user_bucket_value", + ('user_bucket_value', []) ) def test_get_variation_for_rollout__skips_to_everyone_else_rule(self): """ Test that if a user is in an audience, but does not qualify for the experiment, then it skips to the Everyone Else rule. """ - rollout = self.project_config.get_rollout_from_id("211111") + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={}) + feature = self.project_config.get_feature_from_key("test_feature_in_rollout") everyone_else_exp = self.project_config.get_experiment_from_id("211147") variation_to_mock = self.project_config.get_variation_from_id( "211147", "211149" ) 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, []]] ): variation_received, _ = self.decision_service.get_variation_for_rollout( - self.project_config, rollout, "test_user" + self.project_config, feature, user, None ) self.assertEqual( decision_service.Decision( @@ -1099,7 +1134,7 @@ def test_get_variation_for_rollout__skips_to_everyone_else_rule(self): self.project_config.get_experiment_from_key("211127").get_audience_conditions_or_ids(), enums.RolloutRuleAudienceEvaluationLogs, '1', - None, + user.get_user_attributes(), mock_decision_service_logging, ), mock.call( @@ -1107,7 +1142,7 @@ def test_get_variation_for_rollout__skips_to_everyone_else_rule(self): self.project_config.get_experiment_from_key("211147").get_audience_conditions_or_ids(), enums.RolloutRuleAudienceEvaluationLogs, 'Everyone Else', - None, + user.get_user_attributes(), mock_decision_service_logging, ), ], @@ -1118,26 +1153,24 @@ def test_get_variation_for_rollout__skips_to_everyone_else_rule(self): mock_decision_service_logging.debug.assert_has_calls( [ mock.call('User "test_user" meets audience conditions for targeting rule 1.'), - mock.call( - 'User "test_user" is not in the traffic group for targeting rule 1. ' - 'Checking "Everyone Else" rule now.' - ), - mock.call( - 'User "test_user" meets conditions for targeting rule "Everyone Else".' - ), + mock.call('User "test_user" not bucketed into a targeting rule 1. Checking "Everyone Else" rule now.'), + mock.call('User "test_user" meets audience conditions for targeting rule Everyone Else.'), + mock.call('User "test_user" bucketed into a targeting rule Everyone Else.'), ] ) def test_get_variation_for_rollout__returns_none_for_user_not_in_rollout(self): """ Test that get_variation_for_rollout returns None for the user not in the associated rollout. """ - rollout = self.project_config.get_rollout_from_id("211111") + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={}) + feature = self.project_config.get_feature_from_key("test_feature_in_rollout") 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.project_config, feature, user, None ) self.assertEqual( decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), @@ -1152,7 +1185,7 @@ def test_get_variation_for_rollout__returns_none_for_user_not_in_rollout(self): self.project_config.get_experiment_from_key("211127").get_audience_conditions_or_ids(), enums.RolloutRuleAudienceEvaluationLogs, "1", - None, + user.get_user_attributes(), mock_decision_service_logging, ), mock.call( @@ -1160,7 +1193,7 @@ def test_get_variation_for_rollout__returns_none_for_user_not_in_rollout(self): self.project_config.get_experiment_from_key("211137").get_audience_conditions_or_ids(), enums.RolloutRuleAudienceEvaluationLogs, "2", - None, + user.get_user_attributes(), mock_decision_service_logging, ), mock.call( @@ -1168,7 +1201,7 @@ def test_get_variation_for_rollout__returns_none_for_user_not_in_rollout(self): self.project_config.get_experiment_from_key("211147").get_audience_conditions_or_ids(), enums.RolloutRuleAudienceEvaluationLogs, "Everyone Else", - None, + user.get_user_attributes(), mock_decision_service_logging, ), ], @@ -1179,20 +1212,22 @@ def test_get_variation_for_rollout__returns_none_for_user_not_in_rollout(self): mock_decision_service_logging.debug.assert_has_calls( [ mock.call( - 'User "test_user" does not meet conditions for targeting rule 1.' + 'User "test_user" does not meet audience conditions for targeting rule 1.' ), mock.call( - 'User "test_user" does not meet conditions for targeting rule 2.' + 'User "test_user" does not meet audience conditions for targeting rule 2.' ), ] ) def test_get_variation_for_feature__returns_variation_for_feature_in_experiment( - self, + self, ): """ Test that get_variation_for_feature returns the variation of the experiment the feature is associated with. """ + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={}) feature = self.project_config.get_feature_from_key("test_feature_in_experiment") expected_experiment = self.project_config.get_experiment_from_key( @@ -1207,7 +1242,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_experiment( ) 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.project_config, feature, user, ignore_user_profile=False ) self.assertEqual( decision_service.Decision( @@ -1221,8 +1256,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_experiment( mock_decision.assert_called_once_with( self.project_config, self.project_config.get_experiment_from_key("test_experiment"), - "test_user", - None, + user, False ) @@ -1230,6 +1264,8 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_rollout(sel """ Test that get_variation_for_feature returns the variation of the experiment in the rollout that the user is bucketed into. """ + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={}) feature = self.project_config.get_feature_from_key("test_feature_in_rollout") expected_variation = self.project_config.get_variation_from_id( @@ -1242,16 +1278,15 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_rollout(sel 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.project_config, feature, user, False ) self.assertEqual( expected_variation, variation_received, ) - expected_rollout = self.project_config.get_rollout_from_id("211111") mock_get_variation_for_rollout.assert_called_once_with( - self.project_config, expected_rollout, "test_user", None + self.project_config, feature, user, False ) # Assert no log messages were generated @@ -1259,11 +1294,13 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_rollout(sel self.assertEqual(0, len(mock_decision_service_logging.method_calls)) def test_get_variation_for_feature__returns_variation_if_user_not_in_experiment_but_in_rollout( - self, + 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. """ + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={}) feature = self.project_config.get_feature_from_key( "test_feature_in_experiment_and_rollout" ) @@ -1273,13 +1310,12 @@ def test_get_variation_for_feature__returns_variation_if_user_not_in_experiment_ "211127", "211129" ) with mock.patch( - "optimizely.helpers.audience.does_user_meet_audience_conditions", - side_effect=[[False, []], [True, []]], + "optimizely.helpers.audience.does_user_meet_audience_conditions", + 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.project_config, feature, user ) self.assertEqual( decision_service.Decision( @@ -1296,7 +1332,7 @@ def test_get_variation_for_feature__returns_variation_if_user_not_in_experiment_ self.project_config.get_experiment_from_key("group_exp_2").get_audience_conditions_or_ids(), enums.ExperimentAudienceEvaluationLogs, "group_exp_2", - None, + {}, mock_decision_service_logging, ) @@ -1305,7 +1341,7 @@ def test_get_variation_for_feature__returns_variation_if_user_not_in_experiment_ self.project_config.get_experiment_from_key("211127").get_audience_conditions_or_ids(), enums.RolloutRuleAudienceEvaluationLogs, "1", - None, + user.get_user_attributes(), mock_decision_service_logging, ) @@ -1313,6 +1349,8 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_group(self) """ Test that get_variation_for_feature returns the variation of the experiment the user is bucketed in the feature's group. """ + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={}) feature = self.project_config.get_feature_from_key("test_feature_in_group") expected_experiment = self.project_config.get_experiment_from_key("group_exp_1") @@ -1320,11 +1358,11 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_group(self) "group_exp_1", "28901" ) with mock.patch( - "optimizely.decision_service.DecisionService.get_variation", - return_value=(expected_variation, []), + "optimizely.decision_service.DecisionService.get_variation", + return_value=(expected_variation, []), ) as mock_decision: variation_received, _ = self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user" + self.project_config, feature, user ) self.assertEqual( decision_service.Decision( @@ -1338,22 +1376,23 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_group(self) mock_decision.assert_called_once_with( self.project_config, self.project_config.get_experiment_from_key("group_exp_1"), - "test_user", - None, + user, False ) def test_get_variation_for_feature__returns_none_for_user_not_in_experiment(self): """ Test that get_variation_for_feature returns None for user not in the associated experiment. """ + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={}) feature = self.project_config.get_feature_from_key("test_feature_in_experiment") with mock.patch( - "optimizely.decision_service.DecisionService.get_variation", - return_value=[None, []], + "optimizely.decision_service.DecisionService.get_variation", + return_value=[None, []], ) as mock_decision: variation_received, _ = self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user" + self.project_config, feature, user ) self.assertEqual( decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), @@ -1363,24 +1402,25 @@ def test_get_variation_for_feature__returns_none_for_user_not_in_experiment(self mock_decision.assert_called_once_with( self.project_config, self.project_config.get_experiment_from_key("test_experiment"), - "test_user", - None, + user, False ) def test_get_variation_for_feature__returns_none_for_user_in_group_experiment_not_associated_with_feature( - self, + self, ): """ Test that if a user is in the mutex group but the experiment is not targeting a feature, then None is returned. """ + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={}) feature = self.project_config.get_feature_from_key("test_feature_in_group") with mock.patch( - "optimizely.decision_service.DecisionService.get_variation", - return_value=[None, []], + "optimizely.decision_service.DecisionService.get_variation", + return_value=[None, []], ) as mock_decision: variation_received, _ = self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user" + self.project_config, feature, user, False ) self.assertEqual( decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), @@ -1388,26 +1428,26 @@ def test_get_variation_for_feature__returns_none_for_user_in_group_experiment_no ) mock_decision.assert_called_once_with( - self.project_config, self.project_config.get_experiment_from_id("32222"), "test_user", None, False + self.project_config, self.project_config.get_experiment_from_id("32222"), user, False ) def test_get_variation_for_feature__returns_variation_for_feature_in_mutex_group_bucket_less_than_2500( - self, + self, ): """ Test that if a user is in the mutex group and the user bucket value should be less than 2500.""" + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={"experiment_attr": "group_experiment"}) feature = self.project_config.get_feature_from_key("test_feature_in_exclusion_group") expected_experiment = self.project_config.get_experiment_from_key("group_2_exp_1") expected_variation = self.project_config.get_variation_from_id( "group_2_exp_1", "38901" ) - user_attr = {"experiment_attr": "group_experiment"} with mock.patch( - 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=2400) as mock_generate_bucket_value,\ + 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=2400) as mock_generate_bucket_value, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: - variation_received, _ = self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user", user_attr + self.project_config, feature, user ) self.assertEqual( @@ -1423,23 +1463,24 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_mutex_group mock_generate_bucket_value.assert_called_with('test_user42222') def test_get_variation_for_feature__returns_variation_for_feature_in_mutex_group_bucket_range_2500_5000( - self, + self, ): """ Test that if a user is in the mutex group and the user bucket value should be equal to 2500 or less than 5000.""" + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={"experiment_attr": "group_experiment"}) + feature = self.project_config.get_feature_from_key("test_feature_in_exclusion_group") expected_experiment = self.project_config.get_experiment_from_key("group_2_exp_2") expected_variation = self.project_config.get_variation_from_id( "group_2_exp_2", "38905" ) - user_attr = {"experiment_attr": "group_experiment"} with mock.patch( - 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=4000) as mock_generate_bucket_value,\ + 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=4000) as mock_generate_bucket_value, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: - variation_received, _ = self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user", user_attr + self.project_config, feature, user ) self.assertEqual( decision_service.Decision( @@ -1453,24 +1494,24 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_mutex_group mock_generate_bucket_value.assert_called_with('test_user42223') def test_get_variation_for_feature__returns_variation_for_feature_in_mutex_group_bucket_range_5000_7500( - self, + self, ): """ Test that if a user is in the mutex group and the user bucket value should be equal to 5000 or less than 7500.""" + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={"experiment_attr": "group_experiment"}) feature = self.project_config.get_feature_from_key("test_feature_in_exclusion_group") expected_experiment = self.project_config.get_experiment_from_key("group_2_exp_3") expected_variation = self.project_config.get_variation_from_id( "group_2_exp_3", "38906" ) - user_attr = {"experiment_attr": "group_experiment"} with mock.patch( - 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=6500) as mock_generate_bucket_value,\ + 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=6500) as mock_generate_bucket_value, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: - variation_received, _ = self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user", user_attr + self.project_config, feature, user ) self.assertEqual( decision_service.Decision( @@ -1484,19 +1525,21 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_mutex_group mock_generate_bucket_value.assert_called_with('test_user42224') def test_get_variation_for_feature__returns_variation_for_rollout_in_mutex_group_bucket_greater_than_7500( - self, + self, ): """ Test that if a user is in the mutex group and the user bucket value should be greater than 7500.""" + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={"experiment_attr": "group_experiment"}) feature = self.project_config.get_feature_from_key("test_feature_in_exclusion_group") - user_attr = {"experiment_attr": "group_experiment"} + with mock.patch( - 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=8000) as mock_generate_bucket_value,\ + 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=8000) as mock_generate_bucket_value, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: - variation_received, _ = self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user", user_attr + self.project_config, feature, user ) + self.assertEqual( decision_service.Decision( None, @@ -1506,27 +1549,28 @@ def test_get_variation_for_feature__returns_variation_for_rollout_in_mutex_group variation_received, ) - mock_generate_bucket_value.assert_called_with('test_user211147') - mock_config_logging.debug.assert_called_with('Assigned bucket 8000 to user with bucketing ID "test_user".') + mock_generate_bucket_value.assert_called_with("('test_user', [])211147") + mock_config_logging.debug.assert_called_with( + 'Assigned bucket 8000 to user with bucketing ID "(\'test_user\', [])".') def test_get_variation_for_feature__returns_variation_for_feature_in_experiment_bucket_less_than_2500( - self, + self, ): """ Test that if a user is in the non-mutex group and the user bucket value should be less than 2500.""" + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={"experiment_attr": "group_experiment"}) feature = self.project_config.get_feature_from_key("test_feature_in_multiple_experiments") expected_experiment = self.project_config.get_experiment_from_key("test_experiment3") expected_variation = self.project_config.get_variation_from_id( "test_experiment3", "222239" ) - user_attr = {"experiment_attr": "group_experiment"} with mock.patch( - 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=2400) as mock_generate_bucket_value,\ + 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=2400) as mock_generate_bucket_value, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: - variation_received, _ = self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user", user_attr + self.project_config, feature, user ) self.assertEqual( decision_service.Decision( @@ -1540,24 +1584,23 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_experiment_ mock_generate_bucket_value.assert_called_with('test_user111134') def test_get_variation_for_feature__returns_variation_for_feature_in_experiment_bucket_range_2500_5000( - self, + self, ): """ Test that if a user is in the non-mutex group and the user bucket value should be equal to 2500 or less than 5000.""" + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={"experiment_attr": "group_experiment"}) feature = self.project_config.get_feature_from_key("test_feature_in_multiple_experiments") expected_experiment = self.project_config.get_experiment_from_key("test_experiment4") expected_variation = self.project_config.get_variation_from_id( "test_experiment4", "222240" ) - user_attr = {"experiment_attr": "group_experiment"} - with mock.patch( - 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=4000) as mock_generate_bucket_value,\ + 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=4000) as mock_generate_bucket_value, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: - variation_received, _ = self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user", user_attr + self.project_config, feature, user ) self.assertEqual( decision_service.Decision( @@ -1571,24 +1614,24 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_experiment_ mock_generate_bucket_value.assert_called_with('test_user111135') def test_get_variation_for_feature__returns_variation_for_feature_in_experiment_bucket_range_5000_7500( - self, + self, ): """ Test that if a user is in the non-mutex group and the user bucket value should be equal to 5000 or less than 7500.""" + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={"experiment_attr": "group_experiment"}) feature = self.project_config.get_feature_from_key("test_feature_in_multiple_experiments") expected_experiment = self.project_config.get_experiment_from_key("test_experiment5") expected_variation = self.project_config.get_variation_from_id( "test_experiment5", "222241" ) - user_attr = {"experiment_attr": "group_experiment"} with mock.patch( - 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=6500) as mock_generate_bucket_value,\ + 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=6500) as mock_generate_bucket_value, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: - variation_received, _ = self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user", user_attr + self.project_config, feature, user ) self.assertEqual( decision_service.Decision( @@ -1606,13 +1649,15 @@ def test_get_variation_for_feature__returns_variation_for_rollout_in_experiment_ ): """ Test that if a user is in the non-mutex group and the user bucket value should be greater than 7500.""" + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={"experiment_attr": "group_experiment"}) feature = self.project_config.get_feature_from_key("test_feature_in_multiple_experiments") - user_attr = {"experiment_attr": "group_experiment"} + with mock.patch( - 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=8000) as mock_generate_bucket_value, \ + 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=8000) as mock_generate_bucket_value, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: variation_received, _ = self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user", user_attr + self.project_config, feature, user ) self.assertEqual( decision_service.Decision( @@ -1622,9 +1667,9 @@ def test_get_variation_for_feature__returns_variation_for_rollout_in_experiment_ ), variation_received, ) - - mock_generate_bucket_value.assert_called_with('test_user211147') - mock_config_logging.debug.assert_called_with('Assigned bucket 8000 to user with bucketing ID "test_user".') + mock_generate_bucket_value.assert_called_with("('test_user', [])211147") + mock_config_logging.debug.assert_called_with( + 'Assigned bucket 8000 to user with bucketing ID "(\'test_user\', [])".') def test_get_variation_for_feature__returns_variation_for_rollout_in_mutex_group_audience_mismatch( self, @@ -1632,19 +1677,20 @@ def test_get_variation_for_feature__returns_variation_for_rollout_in_mutex_group """ Test that if a user is in the mutex group and the user bucket value should be less than 2500 and missing target by audience.""" + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={ + "experiment_attr": "group_experiment_invalid"}) feature = self.project_config.get_feature_from_key("test_feature_in_exclusion_group") expected_experiment = self.project_config.get_experiment_from_id("211147") expected_variation = self.project_config.get_variation_from_id( "211147", "211149" ) - user_attr = {"experiment_attr": "group_experiment_invalid"} with mock.patch( - 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=2400) as mock_generate_bucket_value, \ + 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=2400) as mock_generate_bucket_value, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: variation_received, _ = self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user", user_attr + self.project_config, feature, user ) - self.assertEqual( decision_service.Decision( expected_experiment, @@ -1654,8 +1700,9 @@ def test_get_variation_for_feature__returns_variation_for_rollout_in_mutex_group variation_received, ) - mock_config_logging.debug.assert_called_with('Assigned bucket 2400 to user with bucketing ID "test_user".') - mock_generate_bucket_value.assert_called_with('test_user211147') + mock_config_logging.debug.assert_called_with( + 'Assigned bucket 2400 to user with bucketing ID "(\'test_user\', [])".') + mock_generate_bucket_value.assert_called_with("('test_user', [])211147") def test_get_variation_for_feature_returns_rollout_in_experiment_bucket_range_2500_5000_audience_mismatch( self, @@ -1663,18 +1710,20 @@ def test_get_variation_for_feature_returns_rollout_in_experiment_bucket_range_25 """ Test that if a user is in the non-mutex group and the user bucket value should be equal to 2500 or less than 5000 missing target by audience.""" + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", + user_attributes={ + "experiment_attr": "group_experiment_invalid"}) feature = self.project_config.get_feature_from_key("test_feature_in_multiple_experiments") expected_experiment = self.project_config.get_experiment_from_id("211147") expected_variation = self.project_config.get_variation_from_id( "211147", "211149" ) - user_attr = {"experiment_attr": "group_experiment_invalid"} with mock.patch( - 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=4000) as mock_generate_bucket_value, \ + 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=4000) as mock_generate_bucket_value, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: variation_received, _ = self.decision_service.get_variation_for_feature( - self.project_config, feature, "test_user", user_attr + self.project_config, feature, user ) self.assertEqual( decision_service.Decision( @@ -1684,5 +1733,6 @@ def test_get_variation_for_feature_returns_rollout_in_experiment_bucket_range_25 ), variation_received, ) - mock_config_logging.debug.assert_called_with('Assigned bucket 4000 to user with bucketing ID "test_user".') - mock_generate_bucket_value.assert_called_with('test_user211147') + mock_config_logging.debug.assert_called_with( + 'Assigned bucket 4000 to user with bucketing ID "(\'test_user\', [])".') + mock_generate_bucket_value.assert_called_with("('test_user', [])211147") diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index 23454342..5c3d1a28 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -12,9 +12,10 @@ # limitations under the License. import json -import mock from operator import itemgetter +import mock + from optimizely import config_manager from optimizely import decision_service from optimizely import entities @@ -32,7 +33,6 @@ class OptimizelyTest(base.BaseTest): - strTest = None try: @@ -70,7 +70,7 @@ def _validate_event_object(self, event_obj, expected_url, expected_params, expec self.assertEqual(expected_headers, event_obj.get('headers')) def _validate_event_object_event_tags( - self, event_obj, expected_event_metric_params, expected_event_features_params + self, event_obj, expected_event_metric_params, expected_event_features_params ): """ Helper method to validate properties of the event object related to event tags. """ @@ -199,7 +199,7 @@ def test_init__unsupported_datafile_version__logs_error(self): mock_client_logger = mock.MagicMock() with mock.patch('optimizely.logger.reset_logger', return_value=mock_client_logger), mock.patch( - 'optimizely.error_handler.NoOpErrorHandler.handle_error' + 'optimizely.error_handler.NoOpErrorHandler.handle_error' ) as mock_error_handler: opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_unsupported_version)) @@ -236,7 +236,7 @@ def test_init__sdk_key_only(self): """ Test that if only sdk_key is provided then PollingConfigManager is used. """ with mock.patch('optimizely.config_manager.PollingConfigManager._set_config'), mock.patch( - 'threading.Thread.start' + 'threading.Thread.start' ): opt_obj = optimizely.Optimizely(sdk_key='test_sdk_key') @@ -246,7 +246,7 @@ def test_init__sdk_key_and_datafile(self): """ Test that if both sdk_key and datafile is provided then PollingConfigManager is used. """ with mock.patch('optimizely.config_manager.PollingConfigManager._set_config'), mock.patch( - 'threading.Thread.start' + 'threading.Thread.start' ): opt_obj = optimizely.Optimizely(datafile=json.dumps(self.config_dict), sdk_key='test_sdk_key') @@ -259,7 +259,7 @@ def test_init__sdk_key_and_datafile_access_token(self): """ with mock.patch('optimizely.config_manager.AuthDatafilePollingConfigManager._set_config'), mock.patch( - 'threading.Thread.start' + 'threading.Thread.start' ): opt_obj = optimizely.Optimizely(datafile_access_token='test_datafile_access_token', sdk_key='test_sdk_key') @@ -271,7 +271,7 @@ def test_invalid_json_raises_schema_validation_off(self): # Not JSON mock_client_logger = mock.MagicMock() with mock.patch('optimizely.logger.reset_logger', return_value=mock_client_logger), mock.patch( - 'optimizely.error_handler.NoOpErrorHandler.handle_error' + 'optimizely.error_handler.NoOpErrorHandler.handle_error' ) as mock_error_handler: opt_obj = optimizely.Optimizely('invalid_json', skip_json_validation=True) @@ -286,7 +286,7 @@ def test_invalid_json_raises_schema_validation_off(self): # JSON having valid version, but entities have invalid format with mock.patch('optimizely.logger.reset_logger', return_value=mock_client_logger), mock.patch( - 'optimizely.error_handler.NoOpErrorHandler.handle_error' + 'optimizely.error_handler.NoOpErrorHandler.handle_error' ) as mock_error_handler: opt_obj = optimizely.Optimizely( {'version': '2', 'events': 'invalid_value', 'experiments': 'invalid_value'}, skip_json_validation=True, @@ -302,8 +302,8 @@ def test_activate(self): """ Test that activate calls process with right params and returns expected variation. """ with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + 'optimizely.decision_service.DecisionService.get_variation', + 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( @@ -349,9 +349,10 @@ def test_activate(self): } log_event = EventFactory.create_log_event(mock_process.call_args[0][0], self.optimizely.logger) + user_context = mock_decision.call_args[0][2] mock_decision.assert_called_once_with( - self.project_config, self.project_config.get_experiment_from_key('test_experiment'), 'test_user', None, + self.project_config, self.project_config.get_experiment_from_key('test_experiment'), user_context ) self.assertEqual(1, mock_process.call_count) @@ -381,8 +382,8 @@ def on_activate(experiment, user_id, attributes, variation, event): enums.NotificationTypes.ACTIVATE, on_activate ) with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + 'optimizely.decision_service.DecisionService.get_variation', + 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')) @@ -414,8 +415,8 @@ def on_track(event_key, user_id, attributes, event_tags, event): note_id = self.optimizely.notification_center.add_notification_listener(enums.NotificationTypes.TRACK, on_track) with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + 'optimizely.decision_service.DecisionService.get_variation', + 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') @@ -440,10 +441,11 @@ def on_activate(event_key, user_id, attributes, event_tags, event): pass self.optimizely.notification_center.add_notification_listener(enums.NotificationTypes.ACTIVATE, on_activate) + variation = (self.project_config.get_variation_from_id('test_experiment', '111129'), []) with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + 'optimizely.decision_service.DecisionService.get_variation', + return_value=variation, ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast: @@ -460,7 +462,7 @@ def on_activate(event_key, user_id, attributes, event_tags, event): 'ab-test', 'test_user', {}, - {'experiment_key': 'test_experiment', 'variation_key': 'variation'}, + {'experiment_key': 'test_experiment', 'variation_key': variation}, ), mock.call( enums.NotificationTypes.ACTIVATE, @@ -480,10 +482,11 @@ def on_activate(event_key, user_id, attributes, event_tags, event): pass self.optimizely.notification_center.add_notification_listener(enums.NotificationTypes.ACTIVATE, on_activate) + variation = (self.project_config.get_variation_from_id('test_experiment', '111129'), []) with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + 'optimizely.decision_service.DecisionService.get_variation', + return_value=variation, ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast: @@ -502,7 +505,7 @@ def on_activate(event_key, user_id, attributes, event_tags, event): 'ab-test', 'test_user', {'test_attribute': 'test_value'}, - {'experiment_key': 'test_experiment', 'variation_key': 'variation'}, + {'experiment_key': 'test_experiment', 'variation_key': variation}, ), mock.call( enums.NotificationTypes.ACTIVATE, @@ -515,12 +518,22 @@ def on_activate(event_key, user_id, attributes, event_tags, event): ] ) + """ + mock_broadcast.assert_called_once_with( + enums.NotificationTypes.DECISION, + 'feature-test', + 'test_user', + {}, + {'experiment_key': 'test_experiment', 'variation_key': variation}, + ) + """ + 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( + return_value=(None, []), ), mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' @@ -532,7 +545,7 @@ def test_decision_listener__user_not_in_experiment(self): 'ab-test', 'test_user', {}, - {'experiment_key': 'test_experiment', 'variation_key': None}, + {'experiment_key': 'test_experiment', 'variation_key': (None, [])}, ) def test_track_listener(self): @@ -544,8 +557,8 @@ def on_track(event_key, user_id, attributes, event_tags, event): self.optimizely.notification_center.add_notification_listener(enums.NotificationTypes.TRACK, on_track) with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111128'), []), + 'optimizely.decision_service.DecisionService.get_variation', + 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: @@ -566,8 +579,8 @@ def on_track(event_key, user_id, attributes, event_tags, event): self.optimizely.notification_center.add_notification_listener(enums.NotificationTypes.TRACK, on_track) with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111128'), []), + 'optimizely.decision_service.DecisionService.get_variation', + 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: @@ -593,8 +606,8 @@ def on_track(event_key, user_id, attributes, event_tags, event): self.optimizely.notification_center.add_notification_listener(enums.NotificationTypes.TRACK, on_track) with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111128'), []), + 'optimizely.decision_service.DecisionService.get_variation', + 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: @@ -635,13 +648,15 @@ 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.FEATURE_TEST), []), + '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'): self.assertTrue(opt_obj.is_feature_enabled('test_feature_in_experiment', 'test_user')) - mock_decision.assert_called_once_with(opt_obj.config_manager.get_config(), feature, 'test_user', None) + user_context = mock_decision.call_args[0][2] + mock_decision.assert_called_once_with(opt_obj.config_manager.get_config(), feature, user_context) self.assertTrue(access_callback[0]) def test_is_feature_enabled_rollout_callback_listener(self): @@ -662,15 +677,16 @@ def on_activate(experiment, user_id, attributes, variation, event): 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.ROLLOUT), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + 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: self.assertTrue(opt_obj.is_feature_enabled('test_feature_in_experiment', 'test_user')) - mock_decision.assert_called_once_with(project_config, feature, 'test_user', None) + user_context = mock_decision.call_args[0][2] + mock_decision.assert_called_once_with(project_config, feature, user_context) # Check that impression event is sent for rollout and send_flag_decisions = True self.assertEqual(1, mock_process.call_count) @@ -681,8 +697,8 @@ def test_activate__with_attributes__audience_match(self): variation when attributes are provided and audience conditions are met. """ with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + 'optimizely.decision_service.DecisionService.get_variation', + 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( @@ -731,12 +747,12 @@ def test_activate__with_attributes__audience_match(self): } log_event = EventFactory.create_log_event(mock_process.call_args[0][0], self.optimizely.logger) + user_context = mock_get_variation.call_args[0][2] mock_get_variation.assert_called_once_with( self.project_config, self.project_config.get_experiment_from_key('test_experiment'), - 'test_user', - {'test_attribute': 'test_value'}, + user_context ) self.assertEqual(1, mock_process.call_count) self._validate_event_object( @@ -752,14 +768,13 @@ def test_activate__with_attributes_of_different_types(self): variation when different types of attributes are provided and audience conditions are met. """ with mock.patch( - 'optimizely.bucketer.Bucketer.bucket', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + 'optimizely.bucketer.Bucketer.bucket', + 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( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process: - attributes = { 'test_attribute': 'test_value_1', 'boolean_key': False, @@ -952,7 +967,6 @@ def test_activate__with_attributes__complex_audience_mismatch(self): opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_typed_audiences)) with mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process: - user_attr = {'house': 'Hufflepuff', 'lasers': 45.5} self.assertIsNone(opt_obj.activate('audience_combinations_experiment', 'test_user', user_attr)) @@ -964,7 +978,7 @@ def test_activate__with_attributes__audience_match__forced_bucketing(self): set_forced_variation is called. """ with mock.patch('time.time', return_value=42), mock.patch( - 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' + 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process: self.assertTrue(self.optimizely.set_forced_variation('test_experiment', 'test_user', 'control')) self.assertEqual( @@ -1026,8 +1040,8 @@ def test_activate__with_attributes__audience_match__bucketing_id_provided(self): when attributes (including bucketing ID) are provided and audience conditions are met. """ with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + 'optimizely.decision_service.DecisionService.get_variation', + 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( @@ -1087,12 +1101,12 @@ def test_activate__with_attributes__audience_match__bucketing_id_provided(self): } log_event = EventFactory.create_log_event(mock_process.call_args[0][0], self.optimizely.logger) + user_context = mock_get_variation.call_args[0][2] mock_get_variation.assert_called_once_with( self.project_config, self.project_config.get_experiment_from_key('test_experiment'), - 'test_user', - {'test_attribute': 'test_value', '$opt_bucketing_id': 'user_bucket_value'}, + user_context ) self.assertEqual(1, mock_process.call_count) self._validate_event_object( @@ -1109,7 +1123,7 @@ def test_activate__with_attributes__no_audience_match(self): with mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions', return_value=(False, [])) as mock_audience_check: self.assertIsNone( - self.optimizely.activate('test_experiment', 'test_user', attributes={'test_attribute': 'test_value'},) + self.optimizely.activate('test_experiment', 'test_user', attributes={'test_attribute': 'test_value'}, ) ) expected_experiment = self.project_config.get_experiment_from_key('test_experiment') mock_audience_check.assert_called_once_with( @@ -1125,7 +1139,7 @@ def test_activate__with_attributes__invalid_attributes(self): """ Test that activate returns None and does not bucket or process event when attributes are invalid. """ with mock.patch('optimizely.bucketer.Bucketer.bucket') as mock_bucket, mock.patch( - 'optimizely.event.event_processor.ForwardingEventProcessor.process' + 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process: self.assertIsNone(self.optimizely.activate('test_experiment', 'test_user', attributes='invalid')) @@ -1136,7 +1150,7 @@ def test_activate__experiment_not_running(self): """ Test that activate returns None and does not process event when experiment is not Running. """ 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, mock.patch( 'optimizely.helpers.experiment.is_experiment_running', return_value=False ) as mock_is_experiment_running, mock.patch( @@ -1145,7 +1159,7 @@ def test_activate__experiment_not_running(self): 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process: self.assertIsNone( - self.optimizely.activate('test_experiment', 'test_user', attributes={'test_attribute': 'test_value'},) + self.optimizely.activate('test_experiment', 'test_user', attributes={'test_attribute': 'test_value'}, ) ) mock_is_experiment_running.assert_called_once_with( @@ -1159,7 +1173,7 @@ def test_activate__whitelisting_overrides_audience_check(self): """ Test that during activate whitelist overrides audience check if user is in the whitelist. """ 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, mock.patch( 'optimizely.helpers.experiment.is_experiment_running', return_value=True ) as mock_is_experiment_running: @@ -1173,14 +1187,14 @@ def test_activate__bucketer_returns_none(self): """ Test that activate returns None and does not process event when user is in no variation. """ with mock.patch( - 'optimizely.helpers.audience.does_user_meet_audience_conditions', - return_value=(True, [])), mock.patch( + 'optimizely.helpers.audience.does_user_meet_audience_conditions', + return_value=(True, [])), mock.patch( 'optimizely.bucketer.Bucketer.bucket', return_value=(None, [])) as mock_bucket, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process: self.assertIsNone( - self.optimizely.activate('test_experiment', 'test_user', attributes={'test_attribute': 'test_value'},) + self.optimizely.activate('test_experiment', 'test_user', attributes={'test_attribute': 'test_value'}, ) ) mock_bucket.assert_called_once_with( self.project_config, @@ -1219,7 +1233,7 @@ def test_track__with_attributes(self): """ Test that track calls process with right params when attributes are provided. """ with mock.patch('time.time', return_value=42), mock.patch( - 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' + 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process: self.optimizely.track('test_event', 'test_user', attributes={'test_attribute': 'test_value'}) @@ -1345,7 +1359,7 @@ def test_track__with_attributes__bucketing_id_provided(self): attributes (including bucketing ID) are provided. """ with mock.patch('time.time', return_value=42), mock.patch( - 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' + 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process: self.optimizely.track( 'test_event', @@ -1404,7 +1418,7 @@ def test_track__with_attributes__no_audience_match(self): """ Test that track calls process even if audience conditions do not match. """ with mock.patch('time.time', return_value=42), mock.patch( - 'optimizely.event.event_processor.ForwardingEventProcessor.process' + 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process: self.optimizely.track( 'test_event', 'test_user', attributes={'test_attribute': 'wrong_test_value'}, @@ -1416,7 +1430,7 @@ def test_track__with_attributes__invalid_attributes(self): """ Test that track does not bucket or process event if attributes are invalid. """ with mock.patch('optimizely.bucketer.Bucketer.bucket') as mock_bucket, mock.patch( - 'optimizely.event.event_processor.ForwardingEventProcessor.process' + 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process: self.optimizely.track('test_event', 'test_user', attributes='invalid') @@ -1427,7 +1441,7 @@ def test_track__with_event_tags(self): """ Test that track calls process with right params when event tags are provided. """ with mock.patch('time.time', return_value=42), mock.patch( - 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' + 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process: self.optimizely.track( 'test_event', @@ -1484,7 +1498,7 @@ def test_track__with_event_tags_revenue(self): event tags are provided only. """ with mock.patch('time.time', return_value=42), mock.patch( - 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' + 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process: self.optimizely.track( 'test_event', @@ -1570,7 +1584,7 @@ def test_track__with_event_tags__forced_bucketing(self): after a forced bucket. """ with mock.patch('time.time', return_value=42), mock.patch( - 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' + 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process: self.assertTrue(self.optimizely.set_forced_variation('test_experiment', 'test_user', 'variation')) self.optimizely.track( @@ -1628,7 +1642,7 @@ def test_track__with_invalid_event_tags(self): """ Test that track calls process with right params when invalid event tags are provided. """ with mock.patch('time.time', return_value=42), mock.patch( - 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' + 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process: self.optimizely.track( 'test_event', @@ -1683,7 +1697,7 @@ def test_track__experiment_not_running(self): """ Test that track calls process even if experiment is not running. """ with mock.patch( - 'optimizely.helpers.experiment.is_experiment_running', return_value=False + 'optimizely.helpers.experiment.is_experiment_running', return_value=False ) as mock_is_experiment_running, mock.patch('time.time', return_value=42), mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process: @@ -1697,7 +1711,7 @@ def test_track_invalid_event_key(self): """ Test that track does not call process when event does not exist. """ with mock.patch( - 'optimizely.event.event_processor.ForwardingEventProcessor.process' + 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process, mock.patch.object(self.optimizely, 'logger') as mock_client_logging: self.optimizely.track('aabbcc_event', 'test_user') @@ -1708,7 +1722,7 @@ def test_track__whitelisted_user_overrides_audience_check(self): """ Test that event is tracked when user is whitelisted. """ with mock.patch('time.time', return_value=42), mock.patch( - 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' + 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process: self.optimizely.track('test_event', 'user_1') @@ -1744,7 +1758,7 @@ def test_track__invalid_experiment_key(self): when exp_key is in invalid format. """ with mock.patch.object(self.optimizely, 'logger') as mock_client_logging, mock.patch( - 'optimizely.helpers.validator.is_non_empty_string', return_value=False + 'optimizely.helpers.validator.is_non_empty_string', return_value=False ) as mock_validator: self.assertIsNone(self.optimizely.track(99, 'test_user')) @@ -1764,11 +1778,13 @@ def test_get_variation(self): """ Test that get_variation returns valid variation and broadcasts decision with proper parameters. """ with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + 'optimizely.decision_service.DecisionService.get_variation', + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ), mock.patch('optimizely.notification_center.NotificationCenter.send_notifications') as mock_broadcast: + variation = self.optimizely.get_variation('test_experiment', 'test_user') + variation_key = variation[0].key self.assertEqual( - 'variation', self.optimizely.get_variation('test_experiment', 'test_user'), + 'variation', variation_key, ) self.assertEqual(mock_broadcast.call_count, 1) @@ -1778,7 +1794,7 @@ def test_get_variation(self): 'ab-test', 'test_user', {}, - {'experiment_key': 'test_experiment', 'variation_key': 'variation'}, + {'experiment_key': 'test_experiment', 'variation_key': variation}, ) def test_get_variation_with_experiment_in_feature(self): @@ -1789,10 +1805,12 @@ def test_get_variation_with_experiment_in_feature(self): project_config = opt_obj.config_manager.get_config() with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation', - return_value=(project_config.get_variation_from_id('test_experiment', '111129'), []), + 'optimizely.decision_service.DecisionService.get_variation', + 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')) + variation = opt_obj.get_variation('test_experiment', 'test_user') + variation_key = variation[0].key + self.assertEqual('variation', variation_key) self.assertEqual(mock_broadcast.call_count, 1) @@ -1801,18 +1819,18 @@ def test_get_variation_with_experiment_in_feature(self): 'feature-test', 'test_user', {}, - {'experiment_key': 'test_experiment', 'variation_key': 'variation'}, + {'experiment_key': 'test_experiment', 'variation_key': variation}, ) 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( + return_value=(None, []), ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast: self.assertEqual( - None, + (None, []), self.optimizely.get_variation( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value'}, ), @@ -1825,7 +1843,7 @@ def test_get_variation__returns_none(self): 'ab-test', 'test_user', {'test_attribute': 'test_value'}, - {'experiment_key': 'test_experiment', 'variation_key': None}, + {'experiment_key': 'test_experiment', 'variation_key': (None, [])}, ) def test_get_variation__invalid_object(self): @@ -1868,7 +1886,7 @@ def test_is_feature_enabled__returns_false_for_invalid_feature_key(self): opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) with mock.patch.object(opt_obj, 'logger') as mock_client_logging, mock.patch( - 'optimizely.helpers.validator.is_non_empty_string', return_value=False + 'optimizely.helpers.validator.is_non_empty_string', return_value=False ) as mock_validator: self.assertFalse(opt_obj.is_feature_enabled(None, 'test_user')) @@ -1889,7 +1907,7 @@ def test_is_feature_enabled__returns_false_for__invalid_attributes(self): opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) with mock.patch.object(opt_obj, 'logger') as mock_client_logging, mock.patch( - 'optimizely.helpers.validator.are_attributes_valid', return_value=False + 'optimizely.helpers.validator.are_attributes_valid', return_value=False ) as mock_validator: self.assertFalse(opt_obj.is_feature_enabled('feature_key', 'test_user', attributes='invalid')) @@ -1938,7 +1956,7 @@ def test_is_feature_enabled__returns_false_for_invalid_feature(self): opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature' + 'optimizely.decision_service.DecisionService.get_variation_for_feature' ) as mock_decision, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process: @@ -1949,7 +1967,7 @@ def test_is_feature_enabled__returns_false_for_invalid_feature(self): # Check that no event is sent self.assertEqual(0, mock_process.call_count) - def test_is_feature_enabled__returns_true_for_feature_experiment_if_feature_enabled_for_variation(self,): + def test_is_feature_enabled__returns_true_for_feature_experiment_if_feature_enabled_for_variation(self, ): """ Test that the feature is enabled for the user if bucketed into variation of an experiment and the variation's featureEnabled property is True. Also confirm that impression event is processed and decision listener is called with proper parameters """ @@ -1965,9 +1983,9 @@ def test_is_feature_enabled__returns_true_for_feature_experiment_if_feature_enab 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.FEATURE_TEST), []), + '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, mock.patch( @@ -1979,7 +1997,8 @@ def test_is_feature_enabled__returns_true_for_feature_experiment_if_feature_enab ): self.assertTrue(opt_obj.is_feature_enabled('test_feature_in_experiment', 'test_user')) - mock_decision.assert_called_once_with(opt_obj.config_manager.get_config(), feature, 'test_user', None) + user_context = mock_decision.call_args[0][2] + mock_decision.assert_called_once_with(opt_obj.config_manager.get_config(), feature, user_context) mock_broadcast_decision.assert_called_with( enums.NotificationTypes.DECISION, @@ -2048,7 +2067,7 @@ def test_is_feature_enabled__returns_true_for_feature_experiment_if_feature_enab {'Content-Type': 'application/json'}, ) - def test_is_feature_enabled__returns_false_for_feature_experiment_if_feature_disabled_for_variation(self,): + def test_is_feature_enabled__returns_false_for_feature_experiment_if_feature_disabled_for_variation(self, ): """ Test that the feature is disabled for the user if bucketed into variation of an experiment and the variation's featureEnabled property is False. Also confirm that impression event is processed and decision is broadcasted with proper parameters """ @@ -2064,9 +2083,9 @@ def test_is_feature_enabled__returns_false_for_feature_experiment_if_feature_dis self.assertFalse(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.FEATURE_TEST), []), + '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, mock.patch( @@ -2078,7 +2097,8 @@ def test_is_feature_enabled__returns_false_for_feature_experiment_if_feature_dis ): self.assertFalse(opt_obj.is_feature_enabled('test_feature_in_experiment', 'test_user')) - mock_decision.assert_called_once_with(opt_obj.config_manager.get_config(), feature, 'test_user', None) + user_context = mock_decision.call_args[0][2] + mock_decision.assert_called_once_with(opt_obj.config_manager.get_config(), feature, user_context) mock_broadcast_decision.assert_called_with( enums.NotificationTypes.DECISION, @@ -2147,7 +2167,7 @@ def test_is_feature_enabled__returns_false_for_feature_experiment_if_feature_dis {'Content-Type': 'application/json'}, ) - def test_is_feature_enabled__returns_true_for_feature_rollout_if_feature_enabled(self,): + def test_is_feature_enabled__returns_true_for_feature_rollout_if_feature_enabled(self, ): """ Test that the feature is enabled for the user if bucketed into variation of a rollout and the variation's featureEnabled property is True. Also confirm that no impression event is processed and decision is broadcasted with proper parameters """ @@ -2163,9 +2183,9 @@ def test_is_feature_enabled__returns_true_for_feature_rollout_if_feature_enabled 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.ROLLOUT), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process, mock.patch( @@ -2177,7 +2197,8 @@ def test_is_feature_enabled__returns_true_for_feature_rollout_if_feature_enabled ): self.assertTrue(opt_obj.is_feature_enabled('test_feature_in_experiment', 'test_user')) - mock_decision.assert_called_once_with(opt_obj.config_manager.get_config(), feature, 'test_user', None) + user_context = mock_decision.call_args[0][2] + mock_decision.assert_called_once_with(opt_obj.config_manager.get_config(), feature, user_context) mock_broadcast_decision.assert_called_with( enums.NotificationTypes.DECISION, @@ -2195,7 +2216,7 @@ def test_is_feature_enabled__returns_true_for_feature_rollout_if_feature_enabled # Check that impression event is sent for rollout and send_flag_decisions = True self.assertEqual(1, mock_process.call_count) - def test_is_feature_enabled__returns_true_for_feature_rollout_if_feature_enabled_with_sending_decisions(self,): + def test_is_feature_enabled__returns_true_for_feature_rollout_if_feature_enabled_with_sending_decisions(self, ): """ Test that the feature is enabled for the user if bucketed into variation of a rollout and the variation's featureEnabled property is True. Also confirm that an impression event is processed and decision is broadcasted with proper parameters, as send_flag_decisions is set to true """ @@ -2212,9 +2233,9 @@ def test_is_feature_enabled__returns_true_for_feature_rollout_if_feature_enabled 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.ROLLOUT), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process, mock.patch( @@ -2226,7 +2247,8 @@ def test_is_feature_enabled__returns_true_for_feature_rollout_if_feature_enabled ): self.assertTrue(opt_obj.is_feature_enabled('test_feature_in_experiment', 'test_user')) - mock_decision.assert_called_once_with(opt_obj.config_manager.get_config(), feature, 'test_user', None) + user_context = mock_decision.call_args[0][2] + mock_decision.assert_called_once_with(opt_obj.config_manager.get_config(), feature, user_context) mock_broadcast_decision.assert_called_with( enums.NotificationTypes.DECISION, @@ -2297,7 +2319,7 @@ def test_is_feature_enabled__returns_true_for_feature_rollout_if_feature_enabled {'Content-Type': 'application/json'}, ) - def test_is_feature_enabled__returns_false_for_feature_rollout_if_feature_disabled(self,): + def test_is_feature_enabled__returns_false_for_feature_rollout_if_feature_disabled(self, ): """ Test that the feature is disabled for the user if bucketed into variation of a rollout and the variation's featureEnabled property is False. Also confirm that no impression event is processed and decision is broadcasted with proper parameters """ @@ -2313,9 +2335,9 @@ def test_is_feature_enabled__returns_false_for_feature_rollout_if_feature_disabl mock_variation.featureEnabled = False 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.ROLLOUT), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ) as mock_process, mock.patch( @@ -2327,7 +2349,8 @@ def test_is_feature_enabled__returns_false_for_feature_rollout_if_feature_disabl ): self.assertFalse(opt_obj.is_feature_enabled('test_feature_in_experiment', 'test_user')) - mock_decision.assert_called_once_with(opt_obj.config_manager.get_config(), feature, 'test_user', None) + user_context = mock_decision.call_args[0][2] + mock_decision.assert_called_once_with(opt_obj.config_manager.get_config(), feature, user_context) mock_broadcast_decision.assert_called_with( enums.NotificationTypes.DECISION, @@ -2345,17 +2368,18 @@ def test_is_feature_enabled__returns_false_for_feature_rollout_if_feature_disabl # Check that impression event is sent for rollout and send_flag_decisions = True self.assertEqual(1, mock_process.call_count) - def test_is_feature_enabled__returns_false_when_user_is_not_bucketed_into_any_variation(self,): + def test_is_feature_enabled__returns_false_when_user_is_not_bucketed_into_any_variation(self, ): """ Test that the feature is not enabled for the user if user is neither bucketed for Feature Experiment nor for Feature Rollout. Also confirm that impression event is not 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') with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + 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( @@ -2370,7 +2394,8 @@ def test_is_feature_enabled__returns_false_when_user_is_not_bucketed_into_any_va # Check that impression event is sent for rollout and send_flag_decisions = True self.assertEqual(1, mock_process.call_count) - mock_decision.assert_called_once_with(opt_obj.config_manager.get_config(), feature, 'test_user', None) + user_context = mock_decision.call_args[0][2] + mock_decision.assert_called_once_with(opt_obj.config_manager.get_config(), feature, user_context) mock_broadcast_decision.assert_called_with( enums.NotificationTypes.DECISION, @@ -2388,16 +2413,17 @@ def test_is_feature_enabled__returns_false_when_user_is_not_bucketed_into_any_va # Check that impression event is sent for rollout and send_flag_decisions = True self.assertEqual(1, mock_process.call_count) - def test_is_feature_enabled__returns_false_when_variation_is_nil(self,): + def test_is_feature_enabled__returns_false_when_variation_is_nil(self, ): """ Test that the feature is not enabled with nil variation Also confirm that 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_and_rollout') + with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + 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( @@ -2412,7 +2438,8 @@ def test_is_feature_enabled__returns_false_when_variation_is_nil(self,): # Check that impression event is sent for rollout and send_flag_decisions = True self.assertEqual(1, mock_process.call_count) - mock_decision.assert_called_once_with(opt_obj.config_manager.get_config(), feature, 'test_user', None) + user_context = mock_decision.call_args[0][2] + mock_decision.assert_called_once_with(opt_obj.config_manager.get_config(), feature, user_context) mock_broadcast_decision.assert_called_with( enums.NotificationTypes.DECISION, @@ -2451,7 +2478,7 @@ def test_is_feature_enabled__invalid_config(self): opt_obj = optimizely.Optimizely('invalid_file') with mock.patch.object(opt_obj, 'logger') as mock_client_logging, mock.patch( - 'optimizely.event_dispatcher.EventDispatcher.dispatch_event' + 'optimizely.event_dispatcher.EventDispatcher.dispatch_event' ) as mock_dispatch_event: self.assertFalse(opt_obj.is_feature_enabled('test_feature_in_experiment', 'user_1')) @@ -2475,7 +2502,7 @@ def side_effect(*args, **kwargs): return False with mock.patch( - 'optimizely.optimizely.Optimizely.is_feature_enabled', side_effect=side_effect, + 'optimizely.optimizely.Optimizely.is_feature_enabled', side_effect=side_effect, ) as mock_is_feature_enabled: received_features = opt_obj.get_enabled_features('user_1') @@ -2508,14 +2535,14 @@ def side_effect(*args, **kwargs): response = decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT) elif feature.key == 'test_feature_in_experiment_and_rollout': response = decision_service.Decision( - mock_experiment, mock_variation_2, enums.DecisionSources.FEATURE_TEST,) + mock_experiment, mock_variation_2, enums.DecisionSources.FEATURE_TEST, ) else: 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, + 'optimizely.decision_service.DecisionService.get_variation_for_feature', side_effect=side_effect, ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2591,7 +2618,7 @@ def test_get_enabled_features_invalid_user_id(self): def test_get_enabled_features__invalid_attributes(self): """ Test that get_enabled_features returns empty list if attributes are in an invalid format. """ with mock.patch.object(self.optimizely, 'logger') as mock_client_logging, mock.patch( - 'optimizely.helpers.validator.are_attributes_valid', return_value=False + 'optimizely.helpers.validator.are_attributes_valid', return_value=False ) as mock_validator: self.assertEqual( [], self.optimizely.get_enabled_features('test_user', attributes='invalid'), @@ -2635,9 +2662,9 @@ def test_get_feature_variable_boolean(self): mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('test_experiment') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2673,9 +2700,9 @@ def test_get_feature_variable_double(self): mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('test_experiment') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2711,9 +2738,9 @@ def test_get_feature_variable_integer(self): mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('test_experiment') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2749,9 +2776,9 @@ def test_get_feature_variable_string(self): mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('test_experiment') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2788,9 +2815,9 @@ def test_get_feature_variable_json(self): mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('test_experiment') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2835,15 +2862,15 @@ def test_get_all_feature_variables(self): 'true_object': {'true_test': 1.4}, 'variable_without_usage': 45} with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: self.assertEqual( expected_results, - opt_obj.get_all_feature_variables('test_feature_in_experiment', 'test_user'), + opt_obj.get_all_feature_variables('test_feature_in_experiment', 'test_user', {}), ) self.assertEqual(7, mock_logger.debug.call_count) @@ -2892,9 +2919,9 @@ def test_get_feature_variable(self): mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') # 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2921,9 +2948,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2952,9 +2979,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2983,9 +3010,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3014,9 +3041,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3054,9 +3081,9 @@ def test_get_feature_variable_boolean_for_feature_in_rollout(self): user_attributes = {'test_attribute': 'test_value'} with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3096,9 +3123,9 @@ def test_get_feature_variable_double_for_feature_in_rollout(self): user_attributes = {'test_attribute': 'test_value'} with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3138,9 +3165,9 @@ def test_get_feature_variable_integer_for_feature_in_rollout(self): user_attributes = {'test_attribute': 'test_value'} with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3180,9 +3207,9 @@ def test_get_feature_variable_string_for_feature_in_rollout(self): user_attributes = {'test_attribute': 'test_value'} with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3222,9 +3249,9 @@ def test_get_feature_variable_json_for_feature_in_rollout(self): user_attributes = {'test_attribute': 'test_value'} with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3264,9 +3291,9 @@ def test_get_all_feature_variables_for_feature_in_rollout(self): user_attributes = {'test_attribute': 'test_value'} with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3318,9 +3345,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3351,9 +3378,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3384,9 +3411,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3417,9 +3444,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3451,9 +3478,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3483,7 +3510,7 @@ def test_get_feature_variable_for_feature_in_rollout(self): }, ) - def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_variation(self,): + def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_variation(self, ): """ Test that get_feature_variable_* returns default value if variable usage not present in variation. """ opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) @@ -3495,9 +3522,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + 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') @@ -3505,9 +3532,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + 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'), @@ -3515,9 +3542,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + 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'), @@ -3525,9 +3552,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + 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'), @@ -3535,9 +3562,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + 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'), @@ -3545,34 +3572,34 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + 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'), ) with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + 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'), ) with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + 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'), @@ -3586,8 +3613,8 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3620,8 +3647,8 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3654,8 +3681,8 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3688,8 +3715,8 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3722,8 +3749,8 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3756,8 +3783,8 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3787,8 +3814,8 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): mock_client_logger.info.reset_mock() with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3820,8 +3847,8 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): mock_client_logger.info.reset_mock() with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3853,8 +3880,8 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): mock_client_logger.info.reset_mock() with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3994,9 +4021,8 @@ def test_get_feature_variable__invalid_attributes(self): opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) with mock.patch.object(opt_obj, 'logger') as mock_client_logging, mock.patch( - 'optimizely.helpers.validator.are_attributes_valid', return_value=False + 'optimizely.helpers.validator.are_attributes_valid', return_value=False ) as mock_validator: - # get_feature_variable_boolean self.assertIsNone( opt_obj.get_feature_variable_boolean( @@ -4064,7 +4090,7 @@ def test_get_feature_variable__invalid_attributes(self): mock_client_logging.reset_mock() self.assertIsNone( - opt_obj.get_feature_variable('test_feature_in_experiment', 'cost', 'test_user', attributes='invalid',) + opt_obj.get_feature_variable('test_feature_in_experiment', 'cost', 'test_user', attributes='invalid', ) ) mock_validator.assert_called_once_with('invalid') mock_client_logging.error.assert_called_once_with('Provided attributes are in an invalid format.') @@ -4072,7 +4098,7 @@ def test_get_feature_variable__invalid_attributes(self): mock_client_logging.reset_mock() self.assertIsNone( - opt_obj.get_feature_variable('test_feature_in_experiment', 'count', 'test_user', attributes='invalid',) + opt_obj.get_feature_variable('test_feature_in_experiment', 'count', 'test_user', attributes='invalid', ) ) mock_validator.assert_called_once_with('invalid') mock_client_logging.error.assert_called_once_with('Provided attributes are in an invalid format.') @@ -4166,11 +4192,10 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: - self.assertTrue( opt_obj.get_feature_variable_boolean('test_feature_in_experiment', 'is_working', 'test_user') ) @@ -4182,9 +4207,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 10.99, opt_obj.get_feature_variable_double('test_feature_in_experiment', 'cost', 'test_user'), @@ -4197,9 +4222,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 999, opt_obj.get_feature_variable_integer('test_feature_in_experiment', 'count', 'test_user'), @@ -4212,9 +4237,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 'devel', opt_obj.get_feature_variable_string('test_feature_in_experiment', 'environment', 'test_user'), @@ -4227,9 +4252,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( {"test": 12}, opt_obj.get_feature_variable_json('test_feature_in_experiment', 'object', 'test_user'), @@ -4242,11 +4267,10 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: - self.assertTrue(opt_obj.get_feature_variable('test_feature_in_experiment', 'is_working', 'test_user')) mock_client_logger.info.assert_called_once_with( @@ -4255,9 +4279,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 10.99, opt_obj.get_feature_variable('test_feature_in_experiment', 'cost', 'test_user'), @@ -4269,9 +4293,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 999, opt_obj.get_feature_variable('test_feature_in_experiment', 'count', 'test_user'), @@ -4283,9 +4307,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 'devel', opt_obj.get_feature_variable('test_feature_in_experiment', 'environment', 'test_user'), @@ -4296,7 +4320,7 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self 'Returning the default variable value "devel".' ) - def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_rollout(self,): + def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_rollout(self, ): """ Test that get_feature_variable_* returns default value if feature is not enabled for the user. """ opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) @@ -4305,9 +4329,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertFalse(opt_obj.get_feature_variable_boolean('test_feature_in_rollout', 'is_running', 'test_user')) @@ -4318,9 +4342,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 99.99, opt_obj.get_feature_variable_double('test_feature_in_rollout', 'price', 'test_user'), @@ -4333,9 +4357,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 999, opt_obj.get_feature_variable_integer('test_feature_in_rollout', 'count', 'test_user'), @@ -4348,9 +4372,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 'Hello', opt_obj.get_feature_variable_string('test_feature_in_rollout', 'message', 'test_user'), @@ -4362,9 +4386,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( {"field": 1}, opt_obj.get_feature_variable_json('test_feature_in_rollout', 'object', 'test_user'), @@ -4376,9 +4400,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertFalse(opt_obj.get_feature_variable('test_feature_in_rollout', 'is_running', 'test_user')) @@ -4388,9 +4412,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 99.99, opt_obj.get_feature_variable('test_feature_in_rollout', 'price', 'test_user'), @@ -4402,9 +4426,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 999, opt_obj.get_feature_variable('test_feature_in_rollout', 'count', 'test_user'), @@ -4416,9 +4440,9 @@ 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), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.ROLLOUT), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: self.assertEqual( 'Hello', opt_obj.get_feature_variable('test_feature_in_rollout', 'message', 'test_user'), @@ -4435,9 +4459,9 @@ def test_get_feature_variable__returns_none_if_type_mismatch(self): mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('test_experiment') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + return_value=(decision_service.Decision(mock_experiment, + mock_variation, enums.DecisionSources.FEATURE_TEST), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger: # "is_working" is boolean variable and we are using double method on it. self.assertIsNone( @@ -4456,9 +4480,9 @@ def test_get_feature_variable__returns_none_if_unable_to_cast(self): mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('test_experiment') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + 'optimizely.decision_service.DecisionService.get_variation_for_feature', + 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( @@ -4674,8 +4698,8 @@ def test_activate(self): user_id = 'test_user' with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + 'optimizely.decision_service.DecisionService.get_variation', + 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( @@ -4694,7 +4718,7 @@ def test_track(self): event_builder.Event('logx.optimizely.com', {'event_key': event_key}) with mock.patch( - 'optimizely.event.event_processor.ForwardingEventProcessor.process' + 'optimizely.event.event_processor.ForwardingEventProcessor.process' ), mock_client_logger as mock_client_logging: self.optimizely.track(event_key, user_id) @@ -4708,7 +4732,7 @@ def test_activate__experiment_not_running(self): mock_client_logger = mock.patch.object(self.optimizely, 'logger') mock_decision_logger = mock.patch.object(self.optimizely.decision_service, 'logger') with mock_client_logger as mock_client_logging, mock_decision_logger as mock_decision_logging, mock.patch( - 'optimizely.helpers.experiment.is_experiment_running', return_value=False + 'optimizely.helpers.experiment.is_experiment_running', return_value=False ) as mock_is_experiment_running: self.optimizely.activate( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value'}, @@ -4770,7 +4794,7 @@ def test_get_variation__invalid_experiment_key(self): when exp_key is in invalid format. """ with mock.patch.object(self.optimizely, 'logger') as mock_client_logging, mock.patch( - 'optimizely.helpers.validator.is_non_empty_string', return_value=False + 'optimizely.helpers.validator.is_non_empty_string', return_value=False ) as mock_validator: self.assertIsNone(self.optimizely.get_variation(99, 'test_user')) @@ -4790,7 +4814,7 @@ def test_activate__invalid_experiment_key(self): when exp_key is in invalid format. """ with mock.patch.object(self.optimizely, 'logger') as mock_client_logging, mock.patch( - 'optimizely.helpers.validator.is_non_empty_string', return_value=False + 'optimizely.helpers.validator.is_non_empty_string', return_value=False ) as mock_validator: self.assertIsNone(self.optimizely.activate(99, 'test_user')) @@ -4815,8 +4839,8 @@ def test_activate__empty_user_id(self): user_id = '' with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + 'optimizely.decision_service.DecisionService.get_variation', + 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( @@ -4838,7 +4862,7 @@ def test_get_variation__experiment_not_running(self): """ Test that expected log messages are logged during get variation when experiment is not running. """ with mock.patch.object(self.optimizely.decision_service, 'logger') as mock_decision_logging, mock.patch( - 'optimizely.helpers.experiment.is_experiment_running', return_value=False + 'optimizely.helpers.experiment.is_experiment_running', return_value=False ) as mock_is_experiment_running: self.optimizely.get_variation( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value'}, @@ -4876,13 +4900,14 @@ def test_get_variation__forced_bucketing(self): variation_key = self.optimizely.get_variation( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value'} ) + variation_key = variation_key[0].key self.assertEqual('variation', variation_key) def test_get_variation__experiment_not_running__forced_bucketing(self): """ Test that the expected forced variation is called if an experiment is not running """ with mock.patch( - 'optimizely.helpers.experiment.is_experiment_running', return_value=False + 'optimizely.helpers.experiment.is_experiment_running', return_value=False ) as mock_is_experiment_running: self.optimizely.set_forced_variation('test_experiment', 'test_user', 'variation') self.assertEqual( @@ -4891,7 +4916,7 @@ def test_get_variation__experiment_not_running__forced_bucketing(self): variation_key = self.optimizely.get_variation( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value'}, ) - self.assertIsNone(variation_key) + self.assertIsNone(variation_key[0]) mock_is_experiment_running.assert_called_once_with( self.project_config.get_experiment_from_key('test_experiment') ) @@ -4905,13 +4930,14 @@ def test_get_variation__whitelisted_user_forced_bucketing(self): variation_key = self.optimizely.get_variation( 'group_exp_1', 'user_1', attributes={'test_attribute': 'test_value'} ) + variation_key = variation_key[0].key self.assertEqual('group_exp_1_variation', variation_key) def test_get_variation__user_profile__forced_bucketing(self): """ Test that the expected forced variation is called if a user profile exists """ with mock.patch( - 'optimizely.decision_service.DecisionService.get_stored_variation', - return_value=entities.Variation('111128', 'control'), + 'optimizely.decision_service.DecisionService.get_stored_variation', + return_value=entities.Variation('111128', 'control'), ): self.assertTrue(self.optimizely.set_forced_variation('test_experiment', 'test_user', 'variation')) self.assertEqual( @@ -4920,6 +4946,7 @@ def test_get_variation__user_profile__forced_bucketing(self): variation_key = self.optimizely.get_variation( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value'}, ) + variation_key = variation_key[0].key self.assertEqual('variation', variation_key) def test_get_variation__invalid_attributes__forced_bucketing(self): @@ -4932,6 +4959,7 @@ def test_get_variation__invalid_attributes__forced_bucketing(self): variation_key = self.optimizely.get_variation( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value_invalid'}, ) + variation_key = variation_key[0].key self.assertEqual('variation', variation_key) def test_set_forced_variation__invalid_object(self): @@ -4966,7 +4994,7 @@ def test_set_forced_variation__invalid_experiment_key(self): when exp_key is in invalid format. """ with mock.patch.object(self.optimizely, 'logger') as mock_client_logging, mock.patch( - 'optimizely.helpers.validator.is_non_empty_string', return_value=False + 'optimizely.helpers.validator.is_non_empty_string', return_value=False ) as mock_validator: self.assertFalse(self.optimizely.set_forced_variation(99, 'test_user', 'variation')) @@ -5014,7 +5042,7 @@ def test_get_forced_variation__invalid_experiment_key(self): when exp_key is in invalid format. """ with mock.patch.object(self.optimizely, 'logger') as mock_client_logging, mock.patch( - 'optimizely.helpers.validator.is_non_empty_string', return_value=False + 'optimizely.helpers.validator.is_non_empty_string', return_value=False ) as mock_validator: self.assertIsNone(self.optimizely.get_forced_variation(99, 'test_user')) diff --git a/tests/test_user_context.py b/tests/test_user_context.py index fcffc415..84aa8f8c 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -14,13 +14,13 @@ import mock -from optimizely.decision.optimizely_decision import OptimizelyDecision +from optimizely import optimizely, decision_service from optimizely.decision.optimizely_decide_option import OptimizelyDecideOption as DecideOption +from optimizely.decision.optimizely_decision import OptimizelyDecision from optimizely.helpers import enums -from . import base -from optimizely import optimizely, decision_service from optimizely.optimizely_user_context import OptimizelyUserContext from optimizely.user_profile import UserProfileService +from . import base class UserContextTest(base.BaseTest): @@ -772,6 +772,10 @@ def test_decide__option__include_reasons__feature_test(self): actual = user_context.decide('test_feature_in_experiment', ['INCLUDE_REASONS']) expected_reasons = [ + 'Invalid variation is mapped to flag (test_feature_in_experiment) and user (test_user) ' + 'in the forced decision map.', + 'Invalid variation is mapped to flag (test_feature_in_experiment), rule (test_experiment) and ' + 'user (test_user) in the forced decision map.', '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.' @@ -787,10 +791,14 @@ def test_decide__option__include_reasons__feature_rollout(self): actual = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) expected_reasons = [ + 'Invalid variation is mapped to flag (test_feature_in_rollout) ' + 'and user (test_user) in the forced decision map.', + 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211127) ' + 'and user (test_user) in the forced decision map.', '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.' + 'User "test_user" bucketed into a targeting rule 1.' ] self.assertEqual(expected_reasons, actual.reasons) @@ -1142,15 +1150,18 @@ def test_decide_reasons__hit_everyone_else_rule__fails_bucketing(self): 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.', + 'Invalid variation is mapped to flag (test_feature_in_rollout) and user (test_user) in the forced decision map.', + 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211127) and user (test_user) in the forced decision map.', + 'Evaluating audiences for rule 1: ["11154"].', 'Audiences for rule 1 collectively evaluated to FALSE.', + 'User "test_user" does not meet audience conditions for targeting rule 1.', + 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211137) and user (test_user) in the forced decision map.', + 'Evaluating audiences for rule 2: ["11159"].', 'Audiences for rule 2 collectively evaluated to FALSE.', + 'User "test_user" does not meet audience conditions for targeting rule 2.', + 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211147) and user (test_user) in the forced decision map.', 'Evaluating audiences for rule Everyone Else: [].', 'Audiences for rule Everyone Else collectively evaluated to TRUE.', - 'Bucketed into an empty traffic range. Returning nil.' + 'User "test_user" meets audience conditions for targeting rule Everyone Else.', + 'User "test_user" bucketed into a targeting rule Everyone Else.' ] self.assertEqual(expected_reasons, actual.reasons) @@ -1163,15 +1174,20 @@ def test_decide_reasons__hit_everyone_else_rule(self): actual = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) expected_reasons = [ + 'Invalid variation is mapped to flag (test_feature_in_rollout) and user (abcde) in the forced decision map.', + 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211127) and user (abcde) in the forced decision map.', 'Evaluating audiences for rule 1: ["11154"].', 'Audiences for rule 1 collectively evaluated to FALSE.', - 'User "abcde" does not meet conditions for targeting rule 1.', + 'User "abcde" does not meet audience conditions for targeting rule 1.', + 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211137) and user (abcde) in the forced decision map.', 'Evaluating audiences for rule 2: ["11159"].', 'Audiences for rule 2 collectively evaluated to FALSE.', - 'User "abcde" does not meet conditions for targeting rule 2.', + 'User "abcde" does not meet audience conditions for targeting rule 2.', + 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211147) and user (abcde) in the forced decision map.', 'Evaluating audiences for rule Everyone Else: [].', 'Audiences for rule Everyone Else collectively evaluated to TRUE.', - 'User "abcde" meets conditions for targeting rule "Everyone Else".' + 'User "abcde" meets audience conditions for targeting rule Everyone Else.', + 'User "abcde" bucketed into a targeting rule Everyone Else.' ] self.assertEqual(expected_reasons, actual.reasons) @@ -1184,18 +1200,19 @@ def test_decide_reasons__hit_rule2__fails_bucketing(self): actual = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) expected_reasons = [ + 'Invalid variation is mapped to flag (test_feature_in_rollout) and ' + 'user (test_user) in the forced decision map.', + 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211127) ' + 'and user (test_user) in the forced decision map.', '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.', + 'User "test_user" does not meet audience conditions for targeting rule 1.', + 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211137) ' + 'and user (test_user) in the forced decision map.', '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.' - ] + 'User "test_user" bucketed into a targeting rule 2.'] self.assertEqual(expected_reasons, actual.reasons) @@ -1230,8 +1247,13 @@ def save(self, user_profile): 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.')] + expected_reasons = [ + 'Invalid variation is mapped to flag (test_feature_in_experiment) and user (test_user) ' + 'in the forced decision map.', + 'Invalid variation is mapped to flag (test_feature_in_experiment), rule (test_experiment) ' + 'and user (test_user) in the forced decision map.', + 'Returning previously activated variation ID "control" of experiment "test_experiment" ' + 'for user "test_user" from user profile.'] self.assertEqual(expected_reasons, actual.reasons) @@ -1247,8 +1269,14 @@ def test_decide_reasons__forced_variation(self): 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')] + expected_reasons = [ + 'Invalid variation is mapped to flag (test_feature_in_experiment) and user (test_user) ' + 'in the forced decision map.', + 'Invalid variation is mapped to flag (test_feature_in_experiment), rule (test_experiment) ' + 'and user (test_user) in the forced decision map.', + 'Variation "control" is mapped to experiment "test_experiment" ' + 'and user "test_user" in the forced variation map' + ] self.assertEqual(expected_reasons, actual.reasons) @@ -1262,7 +1290,12 @@ def test_decide_reasons__whitelisted_variation(self): actual = user_context.decide('test_feature_in_experiment', options) - expected_reasons = ['User "user_1" is forced in variation "control".'] + expected_reasons = [ + 'Invalid variation is mapped to flag (test_feature_in_experiment) and user (user_1) ' + 'in the forced decision map.', + 'Invalid variation is mapped to flag (test_feature_in_experiment), rule (test_experiment) ' + 'and user (user_1) in the forced decision map.', + 'User "user_1" is forced in variation "control".'] self.assertEqual(expected_reasons, actual.reasons) From c81a42565217f4a39c61a85e62021361ddbb10bc Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Tue, 19 Oct 2021 19:57:09 +0200 Subject: [PATCH 05/31] Fixed lint errors --- tests/base.py | 2 +- tests/test_decision_service.py | 34 +++++++++++++++++++--------------- tests/test_optimizely.py | 15 +++++++-------- tests/test_user_context.py | 24 ++++++++++++++++-------- 4 files changed, 43 insertions(+), 32 deletions(-) diff --git a/tests/base.py b/tests/base.py index 3e5f6ff6..05127caf 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1058,4 +1058,4 @@ def setUp(self, config_dict='config_dict'): config = getattr(self, config_dict) self.optimizely = optimizely.Optimizely(json.dumps(config)) - self.project_config = self.optimizely.config_manager.get_config() \ No newline at end of file + self.project_config = self.optimizely.config_manager.get_config() diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py index 4685e24a..2796a1d4 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -476,9 +476,12 @@ def test_get_variation__experiment_not_running(self): def test_get_variation__bucketing_id_provided(self): """ Test that get_variation calls bucket with correct bucketing ID if provided. """ - user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, user_id="test_user", - user_attributes={"random_key": "random_value", - "$opt_bucketing_id": "user_bucket_value", }) + user = optimizely_user_context.OptimizelyUserContext(optimizely_client=None, + user_id="test_user", + user_attributes={ + "random_key": "random_value", + "$opt_bucketing_id": "user_bucket_value", + }) experiment = self.project_config.get_experiment_from_key("test_experiment") with mock.patch( "optimizely.decision_service.DecisionService.get_forced_variation", @@ -1312,8 +1315,9 @@ 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, []]], - ) as mock_audience_check, self.mock_decision_logger as mock_decision_service_logging, mock.patch( - "optimizely.bucketer.Bucketer.bucket", return_value=[expected_variation, []]): + ) as mock_audience_check, \ + self.mock_decision_logger as mock_decision_service_logging, mock.patch( + "optimizely.bucketer.Bucketer.bucket", return_value=[expected_variation, []]): decision, _ = self.decision_service.get_variation_for_feature( self.project_config, feature, user ) @@ -1444,7 +1448,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_mutex_group "group_2_exp_1", "38901" ) with mock.patch( - 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=2400) as mock_generate_bucket_value, \ + 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=2400) as mock_generate_bucket_value, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: variation_received, _ = self.decision_service.get_variation_for_feature( self.project_config, feature, user @@ -1477,7 +1481,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_mutex_group "group_2_exp_2", "38905" ) with mock.patch( - 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=4000) as mock_generate_bucket_value, \ + 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=4000) as mock_generate_bucket_value, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: variation_received, _ = self.decision_service.get_variation_for_feature( self.project_config, feature, user @@ -1508,7 +1512,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_mutex_group ) with mock.patch( - 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=6500) as mock_generate_bucket_value, \ + 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=6500) as mock_generate_bucket_value, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: variation_received, _ = self.decision_service.get_variation_for_feature( self.project_config, feature, user @@ -1534,7 +1538,7 @@ def test_get_variation_for_feature__returns_variation_for_rollout_in_mutex_group feature = self.project_config.get_feature_from_key("test_feature_in_exclusion_group") with mock.patch( - 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=8000) as mock_generate_bucket_value, \ + 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=8000) as mock_generate_bucket_value, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: variation_received, _ = self.decision_service.get_variation_for_feature( self.project_config, feature, user @@ -1567,7 +1571,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_experiment_ ) with mock.patch( - 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=2400) as mock_generate_bucket_value, \ + 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=2400) as mock_generate_bucket_value, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: variation_received, _ = self.decision_service.get_variation_for_feature( self.project_config, feature, user @@ -1597,7 +1601,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_experiment_ "test_experiment4", "222240" ) with mock.patch( - 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=4000) as mock_generate_bucket_value, \ + 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=4000) as mock_generate_bucket_value, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: variation_received, _ = self.decision_service.get_variation_for_feature( self.project_config, feature, user @@ -1628,7 +1632,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_experiment_ ) with mock.patch( - 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=6500) as mock_generate_bucket_value, \ + 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=6500) as mock_generate_bucket_value, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: variation_received, _ = self.decision_service.get_variation_for_feature( self.project_config, feature, user @@ -1654,7 +1658,7 @@ def test_get_variation_for_feature__returns_variation_for_rollout_in_experiment_ feature = self.project_config.get_feature_from_key("test_feature_in_multiple_experiments") with mock.patch( - 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=8000) as mock_generate_bucket_value, \ + 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=8000) as mock_generate_bucket_value, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: variation_received, _ = self.decision_service.get_variation_for_feature( self.project_config, feature, user @@ -1686,7 +1690,7 @@ def test_get_variation_for_feature__returns_variation_for_rollout_in_mutex_group "211147", "211149" ) with mock.patch( - 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=2400) as mock_generate_bucket_value, \ + 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=2400) as mock_generate_bucket_value, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: variation_received, _ = self.decision_service.get_variation_for_feature( self.project_config, feature, user @@ -1720,7 +1724,7 @@ def test_get_variation_for_feature_returns_rollout_in_experiment_bucket_range_25 ) with mock.patch( - 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=4000) as mock_generate_bucket_value, \ + 'optimizely.bucketer.Bucketer._generate_bucket_value', return_value=4000) as mock_generate_bucket_value, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: variation_received, _ = self.decision_service.get_variation_for_feature( self.project_config, feature, user diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index 5c3d1a28..ad43baa0 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -648,10 +648,9 @@ 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', + 'optimizely.decision_service.DecisionService.get_variation_for_feature', return_value=( - decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), - []), + 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')) @@ -677,7 +676,7 @@ def on_activate(experiment, user_id, attributes, variation, event): 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', + 'optimizely.decision_service.DecisionService.get_variation_for_feature', return_value=(decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT), []), ) as mock_decision, mock.patch( @@ -697,7 +696,7 @@ def test_activate__with_attributes__audience_match(self): variation when attributes are provided and audience conditions are met. """ with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation', + 'optimizely.decision_service.DecisionService.get_variation', 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' @@ -768,7 +767,7 @@ def test_activate__with_attributes_of_different_types(self): variation when different types of attributes are provided and audience conditions are met. """ with mock.patch( - 'optimizely.bucketer.Bucketer.bucket', + 'optimizely.bucketer.Bucketer.bucket', 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' @@ -1040,7 +1039,7 @@ def test_activate__with_attributes__audience_match__bucketing_id_provided(self): when attributes (including bucketing ID) are provided and audience conditions are met. """ with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation', + 'optimizely.decision_service.DecisionService.get_variation', 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' @@ -1187,7 +1186,7 @@ def test_activate__bucketer_returns_none(self): """ Test that activate returns None and does not process event when user is in no variation. """ with mock.patch( - 'optimizely.helpers.audience.does_user_meet_audience_conditions', + 'optimizely.helpers.audience.does_user_meet_audience_conditions', return_value=(True, [])), mock.patch( 'optimizely.bucketer.Bucketer.bucket', return_value=(None, [])) as mock_bucket, mock.patch( diff --git a/tests/test_user_context.py b/tests/test_user_context.py index 84aa8f8c..42d6b784 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -1150,14 +1150,18 @@ def test_decide_reasons__hit_everyone_else_rule__fails_bucketing(self): actual = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) expected_reasons = [ - 'Invalid variation is mapped to flag (test_feature_in_rollout) and user (test_user) in the forced decision map.', - 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211127) and user (test_user) in the forced decision map.', + 'Invalid variation is mapped to flag (test_feature_in_rollout) and user (test_user) ' + 'in the forced decision map.', + 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211127) and user (test_user) ' + 'in the forced decision map.', 'Evaluating audiences for rule 1: ["11154"].', 'Audiences for rule 1 collectively evaluated to FALSE.', 'User "test_user" does not meet audience conditions for targeting rule 1.', - 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211137) and user (test_user) in the forced decision map.', + 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211137) and user (test_user) ' + 'in the forced decision map.', 'Evaluating audiences for rule 2: ["11159"].', 'Audiences for rule 2 collectively evaluated to FALSE.', 'User "test_user" does not meet audience conditions for targeting rule 2.', - 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211147) and user (test_user) in the forced decision map.', + 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211147) and user (test_user) ' + 'in the forced decision map.', 'Evaluating audiences for rule Everyone Else: [].', 'Audiences for rule Everyone Else collectively evaluated to TRUE.', 'User "test_user" meets audience conditions for targeting rule Everyone Else.', @@ -1174,16 +1178,20 @@ def test_decide_reasons__hit_everyone_else_rule(self): actual = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) expected_reasons = [ - 'Invalid variation is mapped to flag (test_feature_in_rollout) and user (abcde) in the forced decision map.', - 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211127) and user (abcde) in the forced decision map.', + 'Invalid variation is mapped to flag (test_feature_in_rollout) and user (abcde) ' + 'in the forced decision map.', + 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211127) and user (abcde) ' + 'in the forced decision map.', 'Evaluating audiences for rule 1: ["11154"].', 'Audiences for rule 1 collectively evaluated to FALSE.', 'User "abcde" does not meet audience conditions for targeting rule 1.', - 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211137) and user (abcde) in the forced decision map.', + 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211137) and user (abcde) ' + 'in the forced decision map.', 'Evaluating audiences for rule 2: ["11159"].', 'Audiences for rule 2 collectively evaluated to FALSE.', 'User "abcde" does not meet audience conditions for targeting rule 2.', - 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211147) and user (abcde) in the forced decision map.', + 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211147) and user (abcde) ' + 'in the forced decision map.', 'Evaluating audiences for rule Everyone Else: [].', 'Audiences for rule Everyone Else collectively evaluated to TRUE.', 'User "abcde" meets audience conditions for targeting rule Everyone Else.', From c89bc3c03ac4c2da6d85146f6e4a77ca8185726c Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Wed, 20 Oct 2021 15:04:01 +0200 Subject: [PATCH 06/31] fix failing tests in py 3.5 --- optimizely/project_config.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/optimizely/project_config.py b/optimizely/project_config.py index 77f46531..d0448d05 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -12,6 +12,7 @@ # limitations under the License. import json +from collections import OrderedDict from . import entities from . import exceptions @@ -186,7 +187,10 @@ def _generate_key_map(entity_list, key, entity_class): Map mapping key to entity object. """ - key_map = {} + # using ordered dict here to preserve insertion order of entities + # OrderedDict() is needed for Py versions 3.5 and less to work. + # Insertion order has been made default in dicts since Py 3.6 + key_map = OrderedDict() for obj in entity_list: key_map[obj[key]] = entity_class(**obj) @@ -218,6 +222,7 @@ def get_rollout_experiments_map(self, rollout): Returns: Mapped rollout experiments. """ + rollout_experiments_id_map = self._generate_key_map(rollout.experiments, 'id', entities.Experiment) rollout_experiments = [exper for exper in rollout_experiments_id_map.values()] From 2fe78abe87da1e4deef8f50b6d7b98422c016510 Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Wed, 20 Oct 2021 23:35:55 +0200 Subject: [PATCH 07/31] fixed failing logger import for Py2 --- optimizely/optimizely_user_context.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/optimizely/optimizely_user_context.py b/optimizely/optimizely_user_context.py index 5590aa93..c837e11b 100644 --- a/optimizely/optimizely_user_context.py +++ b/optimizely/optimizely_user_context.py @@ -17,7 +17,7 @@ import threading from collections import namedtuple -from optimizely import logger +from . import logger from .decision.optimizely_decision_message import OptimizelyDecisionMessage from .helpers import enums @@ -237,14 +237,14 @@ def find_validated_forced_decision(self, flag_key, rule_key, options): variation = self.client.get_flag_variation_by_key(flag_key, variation_key) if variation: if rule_key: - user_has_forced_decision = enums.ForcedDecisionLogs\ + user_has_forced_decision = enums.ForcedDecisionLogs \ .USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED.format(variation_key, flag_key, rule_key, self.user_id) else: - user_has_forced_decision = enums.ForcedDecisionLogs\ + user_has_forced_decision = enums.ForcedDecisionLogs \ .USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED.format(variation_key, flag_key, self.user_id) @@ -256,12 +256,12 @@ def find_validated_forced_decision(self, flag_key, rule_key, options): else: if rule_key: - user_has_forced_decision_but_invalid = enums.ForcedDecisionLogs\ + user_has_forced_decision_but_invalid = enums.ForcedDecisionLogs \ .USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED_BUT_INVALID.format(flag_key, rule_key, self.user_id) else: - user_has_forced_decision_but_invalid = enums.ForcedDecisionLogs\ + user_has_forced_decision_but_invalid = enums.ForcedDecisionLogs \ .USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED_BUT_INVALID.format(flag_key, self.user_id) reasons.append(user_has_forced_decision_but_invalid) From d80c5558f41f9ade094955f037b7f167148b27e0 Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Wed, 27 Oct 2021 16:04:09 -0700 Subject: [PATCH 08/31] add OptimizelyDecisionContext and OptmizelyForcedDecisions --- optimizely/decision_service.py | 12 +++-- optimizely/optimizely.py | 10 +++- optimizely/optimizely_user_context.py | 76 ++++++++++++++++----------- tests/test_user_context.py | 38 ++++++++++++++ 4 files changed, 101 insertions(+), 35 deletions(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index 40949466..dbf49e0f 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -20,6 +20,7 @@ from .helpers import enums from .helpers import experiment as experiment_helper from .helpers import validator +from .optimizely_user_context import OptimizelyUserContext from .user_profile import UserProfile Decision = namedtuple('Decision', 'experiment variation source') @@ -407,7 +408,11 @@ def get_variation_from_experiment_rule(self, config, flag_key, rule, user, optio decide_reasons = [] # check forced decision first - forced_decision_variation, reasons_received = user.find_validated_forced_decision(flag_key, rule.key, options) + optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(flag_key, rule.key) + + forced_decision_variation, reasons_received = user.find_validated_forced_decision( + optimizely_decision_context, + options) decide_reasons += reasons_received if forced_decision_variation: @@ -442,9 +447,10 @@ def get_variation_from_delivery_rule(self, config, feature, rules, rule_index, u # check forced decision first rule = rules[rule_index] - forced_decision_variation, reasons_received = user.find_validated_forced_decision(feature.key, - rule.key, + optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(feature.key, rule.key) + forced_decision_variation, reasons_received = user.find_validated_forced_decision(optimizely_decision_context, options) + decide_reasons += reasons_received if forced_decision_variation: diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index bd0c2f66..4ac31ca6 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -1036,7 +1036,8 @@ def _decide(self, user_context, key, decide_options=None): ignore_ups = OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE in decide_options # Check forced decisions first - forced_decision_response = user_context.find_validated_forced_decision(flag_key=key, rule_key=rule_key, + optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(flag_key=key, rule_key=rule_key) + forced_decision_response = user_context.find_validated_forced_decision(optimizely_decision_context, options=decide_options) variation, received_response = forced_decision_response @@ -1187,6 +1188,9 @@ def _decide_for_keys(self, user_context, keys, decide_options=None): def get_flag_variation_by_key(self, flag_key, variation_key): """ + Gets variation by key. + variation_key can be a string or in case of forced decisions, it can be an object. + Args: flag_key: flag key variation_key: variation key @@ -1199,6 +1203,10 @@ def get_flag_variation_by_key(self, flag_key, variation_key): if not config: return None + # this will take care of force decision objects which contain variation_key inside them + if isinstance(variation_key, OptimizelyUserContext.OptimizelyForcedDecision): + variation_key = variation_key.variation_key + variations = config.flag_variations_map[flag_key] for variation in variations: diff --git a/optimizely/optimizely_user_context.py b/optimizely/optimizely_user_context.py index c837e11b..6e55656a 100644 --- a/optimizely/optimizely_user_context.py +++ b/optimizely/optimizely_user_context.py @@ -15,7 +15,6 @@ import copy import threading -from collections import namedtuple from . import logger from .decision.optimizely_decision_message import OptimizelyDecisionMessage @@ -50,7 +49,22 @@ def __init__(self, optimizely_client, user_id, user_attributes=None): self.forced_decisions = {} self.log = logger.SimpleLogger(min_level=enums.LogLevels.INFO) - ForcedDecisionKeys = namedtuple('ForcedDecisionKeys', 'flag_key rule_key') + # decision context + class OptimizelyDecisionContext(object): + def __init__(self, flag_key, rule_key): + self.flag_key = flag_key + self.rule_key = rule_key + + def __hash__(self): + return hash((self.flag_key, self.rule_key)) + + def __eq__(self, other): + return (self.flag_key, self.rule_key) == (other.flag_key, other.rule_key) + + # forced decision + class OptimizelyForcedDecision(object): + def __init__(self, variation_key): + self.variation_key = variation_key def _clone(self): if not self.client: @@ -133,14 +147,13 @@ def as_json(self): 'attributes': self.get_user_attributes(), } - def set_forced_decision(self, flag_key, rule_key, variation_key): + def set_forced_decision(self, OptimizelyDecisionContext, OptimizelyForcedDecision): """ - Sets the forced decision (variation key) for a given flag and an optional rule. + Sets the forced decision for a given decision context. Args: - flag_key: A flag key. - rule_key: An experiment or delivery rule key (optional). - variation_key: A variation key. + OptimizelyDecisionContext: a decision context. + OptimizelyForcedDecision: a forced decision. Returns: True if the forced decision has been set successfully. @@ -151,18 +164,19 @@ def set_forced_decision(self, flag_key, rule_key, variation_key): self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return False - forced_decision_key = self.ForcedDecisionKeys(flag_key, rule_key) - self.forced_decisions[forced_decision_key] = variation_key + context = OptimizelyDecisionContext + decision = OptimizelyForcedDecision + + self.forced_decisions[context] = decision return True - def get_forced_decision(self, flag_key, rule_key): + def get_forced_decision(self, OptimizelyDecisionContext): """ - Gets the forced decision (variation key) for a given flag and an optional rule. + Gets the forced decision (variation key) for a given decision context. Args: - flag_key: A flag key. - rule_key: An experiment or delivery rule key (optional). + OptimizelyDecisionContext: a decision context. Returns: A variation key or None if forced decisions are not set for the parameters. @@ -173,17 +187,16 @@ def get_forced_decision(self, flag_key, rule_key): self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return None - forced_decision_key = self.find_forced_decision(flag_key, rule_key) + forced_decision_key = self.find_forced_decision(OptimizelyDecisionContext) return forced_decision_key if forced_decision_key else None - def remove_forced_decision(self, flag_key, rule_key): + def remove_forced_decision(self, OptimizelyDecisionContext): """ Removes the forced decision for a given flag and an optional rule. Args: - flag_key: A flag key. - rule_key: An experiment or delivery rule key (optional). + OptimizelyDecisionContext: a decision context. Returns: Returns: true if the forced decision has been removed successfully. @@ -194,9 +207,9 @@ def remove_forced_decision(self, flag_key, rule_key): self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return False - forced_decision_key = self.ForcedDecisionKeys(flag_key, rule_key) - if self.forced_decisions[forced_decision_key]: - del self.forced_decisions[forced_decision_key] + if self.forced_decisions[OptimizelyDecisionContext]: + del self.forced_decisions[OptimizelyDecisionContext] + return True return False @@ -217,35 +230,36 @@ def remove_all_forced_decisions(self): return True - def find_forced_decision(self, flag_key, rule_key): + def find_forced_decision(self, OptimizelyDecisionContext): if not self.forced_decisions: return None - forced_decision_key = self.ForcedDecisionKeys(flag_key, rule_key) - variation_key = self.forced_decisions.get(forced_decision_key, None) - - return variation_key if variation_key else None + # must allow None to be returned for the Flags only case + return self.forced_decisions.get(OptimizelyDecisionContext) - def find_validated_forced_decision(self, flag_key, rule_key, options): + def find_validated_forced_decision(self, OptimizelyDecisionContext, options): reasons = [] - variation_key = self.find_forced_decision(flag_key, rule_key) + forced_decision_response = self.find_forced_decision(OptimizelyDecisionContext) + + flag_key = OptimizelyDecisionContext.flag_key + rule_key = OptimizelyDecisionContext.rule_key - if variation_key: - variation = self.client.get_flag_variation_by_key(flag_key, variation_key) + if forced_decision_response: + variation = self.client.get_flag_variation_by_key(flag_key, forced_decision_response) if variation: if rule_key: user_has_forced_decision = enums.ForcedDecisionLogs \ - .USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED.format(variation_key, + .USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED.format(forced_decision_response, flag_key, rule_key, self.user_id) else: user_has_forced_decision = enums.ForcedDecisionLogs \ - .USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED.format(variation_key, + .USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED.format(forced_decision_response, flag_key, self.user_id) diff --git a/tests/test_user_context.py b/tests/test_user_context.py index 42d6b784..b8e7d13c 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -1337,3 +1337,41 @@ def test_decide_experiment(self): 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_forced_decision_return_status__invalid_datafile(self): + """ + Should return invalid status for invalid datafile in forced decision calls. + """ + opt_obj = optimizely.Optimizely(json.dumps("invalid datafile")) + user_context = OptimizelyUserContext(opt_obj, "test_user", {}) + + context = OptimizelyUserContext.OptimizelyDecisionContext('test_feature_in_rollout', None) + decision = OptimizelyUserContext.OptimizelyForcedDecision('211129') + + status = user_context.set_forced_decision(context, decision) + self.assertFalse(status) + status = user_context.get_forced_decision(context) + self.assertIsNone(status) + status = user_context.remove_forced_decision(context) + self.assertFalse(status) + status = user_context.remove_all_forced_decisions() + self.assertFalse(status) + + def test_forced_decision_return_status(self): + """ + Should return valid status for a valid datafile in forced decision calls. + """ + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + user_context = OptimizelyUserContext(opt_obj, "test_user", {}) + + context = OptimizelyUserContext.OptimizelyDecisionContext('test_feature_in_rollout', None) + decision = OptimizelyUserContext.OptimizelyForcedDecision('211129') + + status = user_context.set_forced_decision(context, decision) + self.assertTrue(status) + status = user_context.get_forced_decision(context) + self.assertEqual(status.variation_key, '211129') + status = user_context.remove_forced_decision(context) + self.assertTrue(status) + status = user_context.remove_all_forced_decisions() + self.assertTrue(status) From 5ed2fb42b468ea49a84ea52e6854247c005c0a6e Mon Sep 17 00:00:00 2001 From: ozayr-zaviar Date: Tue, 2 Nov 2021 22:12:11 +0500 Subject: [PATCH 09/31] testcases added --- tests/test_user_context.py | 418 +++++++++++++++++++++++++++++++++++++ 1 file changed, 418 insertions(+) diff --git a/tests/test_user_context.py b/tests/test_user_context.py index b8e7d13c..1f3c1685 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -1357,6 +1357,424 @@ def test_forced_decision_return_status__invalid_datafile(self): status = user_context.remove_all_forced_decisions() self.assertFalse(status) + def test_forced_decision_return_status__valid_datafile(self): + """ + Should return valid status for valid datafile in forced decision calls. + """ + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + user_context = OptimizelyUserContext(opt_obj, "test_user", {}) + + context = OptimizelyUserContext.OptimizelyDecisionContext('test_feature_in_rollout', None) + decision = OptimizelyUserContext.OptimizelyForcedDecision('211129') + + status = user_context.set_forced_decision(context, decision) + self.assertTrue(status) + status = user_context.get_forced_decision(context) + self.assertEqual(status.variation_key, '211129') + status = user_context.remove_forced_decision(context) + self.assertTrue(status) + status = user_context.remove_all_forced_decisions() + self.assertTrue(status) + + def test_decide_return_decision__forced_decision(self): + """ + Should return valid forced decision after setting forced decision. + """ + + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + project_config = opt_obj.config_manager.get_config() + user_context = OptimizelyUserContext(opt_obj, "test_user", {}) + + context = OptimizelyUserContext.OptimizelyDecisionContext('test_feature_in_rollout', None) + decision = OptimizelyUserContext.OptimizelyForcedDecision('211129') + + status = user_context.set_forced_decision(context, decision) + self.assertTrue(status) + status = user_context.get_forced_decision(context) + self.assertEqual(status.variation_key, '211129') + + 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: + decide_decision = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) + self.assertEqual(decide_decision.variation_key, '211129') + self.assertIsNone(decide_decision.rule_key) + self.assertTrue(decide_decision.enabled) + self.assertEqual(decide_decision.flag_key, 'test_feature_in_rollout') + self.assertEqual(decide_decision.user_context.user_id, 'test_user') + self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) + self.assertTrue(set(decide_decision.reasons).issuperset(set([ + 'Variation (211129) is mapped to flag (test_feature_in_rollout) and user ' + '(test_user) in the forced decision map.' + ]))) + expected_variables = { + 'is_running': True, + 'message': 'Hello audience', + 'price': 39.99, + 'count': 399, + 'object': {"field": 12} + } + + expected = OptimizelyDecision( + variation_key='211129', + rule_key=None, + enabled=True, + variables=expected_variables, + flag_key='test_feature_in_rollout', + user_context=user_context, + reasons=['Variation (211129) is mapped to flag (test_feature_in_rollout) and ' + 'user (test_user) in the forced decision map.'] + ) + + # 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', + {}, + { + '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, + }, + ) + + expected_experiment = project_config.get_experiment_from_key(expected.rule_key) + expected_var = project_config.get_variation_from_key('211127', expected.variation_key) + mock_send_event.assert_called_with( + project_config, + expected_experiment, + expected_var, + expected.flag_key, + '', + 'feature-test', + expected.enabled, + 'test_user', + {} + ) + + status = user_context.remove_forced_decision(context) + self.assertTrue(status) + + decide_decision = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) + self.assertEqual(decide_decision.variation_key, '211149') + self.assertEqual(decide_decision.rule_key, '211147') + self.assertTrue(decide_decision.enabled) + self.assertEqual(decide_decision.flag_key, 'test_feature_in_rollout') + self.assertEqual(decide_decision.user_context.user_id, 'test_user') + self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) + self.assertTrue(set(decide_decision.reasons).issuperset(set([ + 'Invalid variation is mapped to flag (test_feature_in_rollout) and ' + 'user (test_user) in the forced decision map.' + ]))) + + def test_delivery_rule_return_decision__forced_decision(self): + """ + Should return valid delivery rule decision after setting forced decision. + """ + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + user_context = OptimizelyUserContext(opt_obj, "test_user", {}) + + context = OptimizelyUserContext.OptimizelyDecisionContext('test_feature_in_rollout', '211127') + decision = OptimizelyUserContext.OptimizelyForcedDecision('211129') + + status = user_context.set_forced_decision(context, decision) + self.assertTrue(status) + status = user_context.get_forced_decision(context) + self.assertEqual(status.variation_key, '211129') + + decide_decision = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) + self.assertEqual(decide_decision.variation_key, '211129') + self.assertEqual(decide_decision.rule_key, '211127') + self.assertTrue(decide_decision.enabled) + self.assertEqual(decide_decision.flag_key, 'test_feature_in_rollout') + self.assertEqual(decide_decision.user_context.user_id, 'test_user') + self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) + self.assertTrue(set(decide_decision.reasons).issuperset(set([ + 'Variation (211129) is mapped to flag (test_feature_in_rollout), ' + 'rule (211127) and user (test_user) in the forced decision map.' + ]))) + status = user_context.remove_forced_decision(context) + self.assertTrue(status) + + decide_decision = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) + self.assertEqual(decide_decision.variation_key, '211149') + self.assertEqual(decide_decision.rule_key, '211147') + self.assertTrue(decide_decision.enabled) + self.assertEqual(decide_decision.flag_key, 'test_feature_in_rollout') + self.assertEqual(decide_decision.user_context.user_id, 'test_user') + self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) + self.assertTrue(set(decide_decision.reasons).issuperset(set([ + 'Invalid variation is mapped to flag (test_feature_in_rollout) ' + 'and user (test_user) in the forced decision map.' + ]))) + + def test_experiment_rule_return_decision__forced_decision(self): + """ + Should return valid experiment decision after setting forced decision. + """ + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + user_context = OptimizelyUserContext(opt_obj, "test_user", {}) + + context = OptimizelyUserContext.OptimizelyDecisionContext('test_feature_in_experiment_and_rollout', + 'group_exp_2') + decision = OptimizelyUserContext.OptimizelyForcedDecision('group_exp_2_variation') + + status = user_context.set_forced_decision(context, decision) + self.assertTrue(status) + status = user_context.get_forced_decision(context) + self.assertEqual(status.variation_key, 'group_exp_2_variation') + + decide_decision = user_context.decide('test_feature_in_experiment_and_rollout', ['INCLUDE_REASONS']) + self.assertEqual(decide_decision.variation_key, 'group_exp_2_variation') + self.assertEqual(decide_decision.rule_key, 'group_exp_2') + self.assertFalse(decide_decision.enabled) + self.assertEqual(decide_decision.flag_key, 'test_feature_in_experiment_and_rollout') + self.assertEqual(decide_decision.user_context.user_id, 'test_user') + self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) + self.assertTrue(set(decide_decision.reasons).issuperset(set([ + 'Variation (group_exp_2_variation) is mapped to flag ' + '(test_feature_in_experiment_and_rollout), rule (group_exp_2) and ' + 'user (test_user) in the forced decision map.' + ]))) + status = user_context.remove_forced_decision(context) + self.assertTrue(status) + + decide_decision = user_context.decide('test_feature_in_experiment_and_rollout', ['INCLUDE_REASONS']) + self.assertEqual(decide_decision.variation_key, 'group_exp_2_control') + self.assertEqual(decide_decision.rule_key, 'group_exp_2') + self.assertFalse(decide_decision.enabled) + self.assertEqual(decide_decision.flag_key, 'test_feature_in_experiment_and_rollout') + self.assertEqual(decide_decision.user_context.user_id, 'test_user') + self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) + self.assertTrue(set(decide_decision.reasons).issuperset(set([ + 'Invalid variation is mapped to flag (test_feature_in_experiment_and_rollout) ' + 'and user (test_user) in the forced decision map.', + 'Invalid variation is mapped to flag (test_feature_in_experiment_and_rollout), ' + 'rule (group_exp_2) and user (test_user) in the forced decision map.' + ]))) + + def test_invalid_delivery_rule_return_decision__forced_decision(self): + """ + Should return valid decision after setting invalid delivery rule variation in forced decision. + """ + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + user_context = OptimizelyUserContext(opt_obj, "test_user", {}) + + context = OptimizelyUserContext.OptimizelyDecisionContext('test_feature_in_rollout', '211127') + decision = OptimizelyUserContext.OptimizelyForcedDecision('invalid') + + status = user_context.set_forced_decision(context, decision) + self.assertTrue(status) + status = user_context.get_forced_decision(context) + self.assertEqual(status.variation_key, 'invalid') + + decide_decision = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) + self.assertEqual(decide_decision.variation_key, '211149') + self.assertEqual(decide_decision.rule_key, '211147') + self.assertTrue(decide_decision.enabled) + self.assertEqual(decide_decision.flag_key, 'test_feature_in_rollout') + self.assertEqual(decide_decision.user_context.user_id, 'test_user') + self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) + self.assertTrue(set(decide_decision.reasons).issuperset(set([ + 'Invalid variation is mapped to flag (test_feature_in_rollout) ' + 'and user (test_user) in the forced decision map.' + ]))) + + def test_invalid_experiment_rule_return_decision__forced_decision(self): + """ + Should return valid decision after setting invalid experiemnt + rule variation in forced decision. + """ + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + user_context = OptimizelyUserContext(opt_obj, "test_user", {}) + + context = OptimizelyUserContext.OptimizelyDecisionContext('test_feature_in_experiment_and_rollout', + 'group_exp_2') + decision = OptimizelyUserContext.OptimizelyForcedDecision('invalid') + + status = user_context.set_forced_decision(context, decision) + self.assertTrue(status) + status = user_context.get_forced_decision(context) + self.assertEqual(status.variation_key, 'invalid') + + decide_decision = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) + self.assertEqual(decide_decision.variation_key, '211149') + self.assertEqual(decide_decision.rule_key, '211147') + self.assertTrue(decide_decision.enabled) + self.assertEqual(decide_decision.flag_key, 'test_feature_in_rollout') + self.assertEqual(decide_decision.user_context.user_id, 'test_user') + self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) + self.assertTrue(set(decide_decision.reasons).issuperset(set([ + 'Invalid variation is mapped to flag (test_feature_in_rollout) and ' + 'user (test_user) in the forced decision map.' + ]))) + + def test_conflicts_return_valid_decision__forced_decision(self): + """ + Should return valid forced decision after setting conflicting forced decisions. + """ + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + user_context = OptimizelyUserContext(opt_obj, "test_user", {}) + + context_with_flag = OptimizelyUserContext.OptimizelyDecisionContext('test_feature_in_rollout', None) + decision_for_flag = OptimizelyUserContext.OptimizelyForcedDecision('211129') + + context_with_rule = OptimizelyUserContext.OptimizelyDecisionContext('test_feature_in_rollout', '211127') + decision_for_rule = OptimizelyUserContext.OptimizelyForcedDecision('211229') + + status = user_context.set_forced_decision(context_with_flag, decision_for_flag) + self.assertTrue(status) + + status = user_context.set_forced_decision(context_with_rule, decision_for_rule) + self.assertTrue(status) + + decide_decision = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) + self.assertEqual(decide_decision.variation_key, '211129') + self.assertIsNone(decide_decision.rule_key) + self.assertTrue(decide_decision.enabled) + self.assertEqual(decide_decision.flag_key, 'test_feature_in_rollout') + self.assertEqual(decide_decision.user_context.user_id, 'test_user') + self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) + self.assertTrue(set(decide_decision.reasons).issuperset(set([ + 'Variation (211129) is mapped to flag (test_feature_in_rollout) and ' + 'user (test_user) in the forced decision map.' + ]))) + + def test_get_forced_decision_return_valid_decision__forced_decision(self): + """ + Should return valid forced decision on getting forced decision. + """ + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + user_context = OptimizelyUserContext(opt_obj, "test_user", {}) + + context_with_flag_1 = OptimizelyUserContext.OptimizelyDecisionContext('f1', None) + decision_for_flag_1 = OptimizelyUserContext.OptimizelyForcedDecision('v1') + + context_with_flag_2 = OptimizelyUserContext.OptimizelyDecisionContext('f1', None) + decision_for_flag_2 = OptimizelyUserContext.OptimizelyForcedDecision('v2') + status = user_context.set_forced_decision(context_with_flag_1, decision_for_flag_1) + self.assertTrue(status) + + status = user_context.get_forced_decision(context_with_flag_1) + self.assertEqual(status.variation_key, decision_for_flag_1.variation_key) + + status = user_context.set_forced_decision(context_with_flag_2, decision_for_flag_2) + self.assertTrue(status) + + status = user_context.get_forced_decision(context_with_flag_2) + self.assertEqual(status.variation_key, decision_for_flag_2.variation_key) + + context_with_rule_1 = OptimizelyUserContext.OptimizelyDecisionContext('f1', 'r1') + decision_for_rule_1 = OptimizelyUserContext.OptimizelyForcedDecision('v3') + + context_with_rule_2 = OptimizelyUserContext.OptimizelyDecisionContext('f1', 'r2') + decision_for_rule_2 = OptimizelyUserContext.OptimizelyForcedDecision('v4') + + status = user_context.set_forced_decision(context_with_rule_1, decision_for_rule_1) + self.assertTrue(status) + + status = user_context.get_forced_decision(context_with_rule_1) + self.assertEqual(status.variation_key, decision_for_rule_1.variation_key) + + status = user_context.set_forced_decision(context_with_rule_2, decision_for_rule_2) + self.assertTrue(status) + + status = user_context.get_forced_decision(context_with_rule_2) + self.assertEqual(status.variation_key, decision_for_rule_2.variation_key) + + status = user_context.get_forced_decision(context_with_flag_1) + self.assertEqual(status.variation_key, decision_for_flag_2.variation_key) + + def test_remove_forced_decision_return_valid_decision__forced_decision(self): + """ + Should remove forced decision on removing forced decision. + """ + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + user_context = OptimizelyUserContext(opt_obj, "test_user", {}) + + context_with_flag_1 = OptimizelyUserContext.OptimizelyDecisionContext('f1', None) + decision_for_flag_1 = OptimizelyUserContext.OptimizelyForcedDecision('v1') + + status = user_context.set_forced_decision(context_with_flag_1, decision_for_flag_1) + self.assertTrue(status) + + status = user_context.get_forced_decision(context_with_flag_1) + self.assertEqual(status.variation_key, decision_for_flag_1.variation_key) + + status = user_context.remove_forced_decision(context_with_flag_1) + self.assertTrue(status) + + status = user_context.get_forced_decision(context_with_flag_1) + self.assertIsNone(status) + + context_with_rule_1 = OptimizelyUserContext.OptimizelyDecisionContext('f1', 'r1') + decision_for_rule_1 = OptimizelyUserContext.OptimizelyForcedDecision('v3') + + status = user_context.set_forced_decision(context_with_rule_1, decision_for_rule_1) + self.assertTrue(status) + + status = user_context.get_forced_decision(context_with_rule_1) + self.assertEqual(status.variation_key, decision_for_rule_1.variation_key) + + status = user_context.remove_forced_decision(context_with_rule_1) + self.assertTrue(status) + + status = user_context.get_forced_decision(context_with_rule_1) + self.assertIsNone(status) + + status = user_context.get_forced_decision(context_with_flag_1) + self.assertIsNone(status) + + def test_remove_all_forced_decision_return_valid_decision__forced_decision(self): + """ + Should remove all forced decision on removing all forced decision. + """ + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + user_context = OptimizelyUserContext(opt_obj, "test_user", {}) + + context_with_flag_1 = OptimizelyUserContext.OptimizelyDecisionContext('f1', None) + decision_for_flag_1 = OptimizelyUserContext.OptimizelyForcedDecision('v1') + + status = user_context.remove_all_forced_decisions() + self.assertTrue(status) + + status = user_context.set_forced_decision(context_with_flag_1, decision_for_flag_1) + self.assertTrue(status) + + status = user_context.get_forced_decision(context_with_flag_1) + self.assertEqual(status.variation_key, decision_for_flag_1.variation_key) + + context_with_rule_1 = OptimizelyUserContext.OptimizelyDecisionContext('f1', 'r1') + decision_for_rule_1 = OptimizelyUserContext.OptimizelyForcedDecision('v3') + + status = user_context.set_forced_decision(context_with_rule_1, decision_for_rule_1) + self.assertTrue(status) + + status = user_context.get_forced_decision(context_with_rule_1) + self.assertEqual(status.variation_key, decision_for_rule_1.variation_key) + + status = user_context.remove_all_forced_decisions() + self.assertTrue(status) + + status = user_context.get_forced_decision(context_with_rule_1) + self.assertIsNone(status) + + status = user_context.get_forced_decision(context_with_flag_1) + self.assertIsNone(status) + + status = user_context.remove_all_forced_decisions() + self.assertTrue(status) + def test_forced_decision_return_status(self): """ Should return valid status for a valid datafile in forced decision calls. From 6003fdccdecdb06047f0f9ae15476f5e5e1f5c63 Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Tue, 2 Nov 2021 12:11:27 -0700 Subject: [PATCH 10/31] Update optimizely/optimizely_user_context.py Co-authored-by: ozayr-zaviar <54209343+ozayr-zaviar@users.noreply.github.com> --- optimizely/optimizely_user_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/optimizely/optimizely_user_context.py b/optimizely/optimizely_user_context.py index 6e55656a..6491ece1 100644 --- a/optimizely/optimizely_user_context.py +++ b/optimizely/optimizely_user_context.py @@ -248,7 +248,7 @@ def find_validated_forced_decision(self, OptimizelyDecisionContext, options): rule_key = OptimizelyDecisionContext.rule_key if forced_decision_response: - variation = self.client.get_flag_variation_by_key(flag_key, forced_decision_response) + variation = self.client.get_flag_variation_by_key(flag_key, forced_decision_response.variation_key) if variation: if rule_key: user_has_forced_decision = enums.ForcedDecisionLogs \ From e4dc7455f9505b12af6609904994742cb73577fc Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Tue, 2 Nov 2021 12:11:33 -0700 Subject: [PATCH 11/31] Update optimizely/optimizely_user_context.py Co-authored-by: ozayr-zaviar <54209343+ozayr-zaviar@users.noreply.github.com> --- optimizely/optimizely_user_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/optimizely/optimizely_user_context.py b/optimizely/optimizely_user_context.py index 6491ece1..55affb46 100644 --- a/optimizely/optimizely_user_context.py +++ b/optimizely/optimizely_user_context.py @@ -252,7 +252,7 @@ def find_validated_forced_decision(self, OptimizelyDecisionContext, options): if variation: if rule_key: user_has_forced_decision = enums.ForcedDecisionLogs \ - .USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED.format(forced_decision_response, + .USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED.format(forced_decision_response.variation_key, flag_key, rule_key, self.user_id) From d75f3890997c7b398f0197ed6a088792172fad10 Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Tue, 2 Nov 2021 12:11:39 -0700 Subject: [PATCH 12/31] Update optimizely/optimizely_user_context.py Co-authored-by: ozayr-zaviar <54209343+ozayr-zaviar@users.noreply.github.com> --- optimizely/optimizely_user_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/optimizely/optimizely_user_context.py b/optimizely/optimizely_user_context.py index 55affb46..004b8bbc 100644 --- a/optimizely/optimizely_user_context.py +++ b/optimizely/optimizely_user_context.py @@ -259,7 +259,7 @@ def find_validated_forced_decision(self, OptimizelyDecisionContext, options): else: user_has_forced_decision = enums.ForcedDecisionLogs \ - .USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED.format(forced_decision_response, + .USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED.format(forced_decision_response.variation_key, flag_key, self.user_id) From 68146a1193f564eb8e6e436c495e8616e630d131 Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Tue, 2 Nov 2021 16:13:48 -0700 Subject: [PATCH 13/31] make rule key optional in OptimizelyDecisionContext --- optimizely/optimizely_user_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/optimizely/optimizely_user_context.py b/optimizely/optimizely_user_context.py index 004b8bbc..3043f65a 100644 --- a/optimizely/optimizely_user_context.py +++ b/optimizely/optimizely_user_context.py @@ -51,7 +51,7 @@ def __init__(self, optimizely_client, user_id, user_attributes=None): # decision context class OptimizelyDecisionContext(object): - def __init__(self, flag_key, rule_key): + def __init__(self, flag_key, rule_key=None): self.flag_key = flag_key self.rule_key = rule_key From a261899ce2cbfec2ead8cecd6b5a4aad36bb03e9 Mon Sep 17 00:00:00 2001 From: ozayr-zaviar Date: Wed, 3 Nov 2021 16:24:26 +0500 Subject: [PATCH 14/31] Mutex lock and testcases added --- optimizely/optimizely_user_context.py | 30 ++++--- tests/test_user_context.py | 111 ++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 12 deletions(-) diff --git a/optimizely/optimizely_user_context.py b/optimizely/optimizely_user_context.py index 3043f65a..a4f5e695 100644 --- a/optimizely/optimizely_user_context.py +++ b/optimizely/optimizely_user_context.py @@ -46,7 +46,8 @@ def __init__(self, optimizely_client, user_id, user_attributes=None): self._user_attributes = user_attributes.copy() if user_attributes else {} self.lock = threading.Lock() - self.forced_decisions = {} + with self.lock: + self.forced_decisions = {} self.log = logger.SimpleLogger(min_level=enums.LogLevels.INFO) # decision context @@ -72,8 +73,9 @@ def _clone(self): user_context = OptimizelyUserContext(self.client, self.user_id, self.get_user_attributes()) - if self.forced_decisions: - user_context.forced_decisions = copy.deepcopy(self.forced_decisions) + with self.lock: + if self.forced_decisions: + user_context.forced_decisions = copy.deepcopy(self.forced_decisions) return user_context @@ -167,7 +169,8 @@ def set_forced_decision(self, OptimizelyDecisionContext, OptimizelyForcedDecisio context = OptimizelyDecisionContext decision = OptimizelyForcedDecision - self.forced_decisions[context] = decision + with self.lock: + self.forced_decisions[context] = decision return True @@ -207,9 +210,10 @@ def remove_forced_decision(self, OptimizelyDecisionContext): self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return False - if self.forced_decisions[OptimizelyDecisionContext]: - del self.forced_decisions[OptimizelyDecisionContext] - return True + with self.lock: + if self.forced_decisions[OptimizelyDecisionContext]: + del self.forced_decisions[OptimizelyDecisionContext] + return True return False @@ -226,17 +230,19 @@ def remove_all_forced_decisions(self): self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return False - self.forced_decisions.clear() + with self.lock: + self.forced_decisions.clear() return True def find_forced_decision(self, OptimizelyDecisionContext): - if not self.forced_decisions: - return None + with self.lock: + if not self.forced_decisions: + return None - # must allow None to be returned for the Flags only case - return self.forced_decisions.get(OptimizelyDecisionContext) + # must allow None to be returned for the Flags only case + return self.forced_decisions.get(OptimizelyDecisionContext) def find_validated_forced_decision(self, OptimizelyDecisionContext, options): diff --git a/tests/test_user_context.py b/tests/test_user_context.py index 1f3c1685..c05419f4 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -13,6 +13,7 @@ import json import mock +import threading from optimizely import optimizely, decision_service from optimizely.decision.optimizely_decide_option import OptimizelyDecideOption as DecideOption @@ -1793,3 +1794,113 @@ def test_forced_decision_return_status(self): self.assertTrue(status) status = user_context.remove_all_forced_decisions() self.assertTrue(status) + + def test_forced_decision_clone_return_valid_forced_decision(self): + """ + Should return valid forced decision on cloning. + """ + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + user_context = OptimizelyUserContext(opt_obj, "test_user", {}) + + context_with_flag = OptimizelyUserContext.OptimizelyDecisionContext('f1', None) + decision_for_flag = OptimizelyUserContext.OptimizelyForcedDecision('v1') + context_with_rule = OptimizelyUserContext.OptimizelyDecisionContext('f1', 'r1') + decision_for_rule = OptimizelyUserContext.OptimizelyForcedDecision('v2') + context_with_empty_rule = OptimizelyUserContext.OptimizelyDecisionContext('f1', '') + decision_for_empty_rule = OptimizelyUserContext.OptimizelyForcedDecision('v3') + + user_context.set_forced_decision(context_with_flag, decision_for_flag) + user_context.set_forced_decision(context_with_rule, decision_for_rule) + user_context.set_forced_decision(context_with_empty_rule, decision_for_empty_rule) + + user_context_2 = user_context._clone() + self.assertEqual(user_context_2.user_id, 'test_user') + self.assertEqual(user_context_2.get_user_attributes(), {}) + self.assertIsNotNone(user_context_2.forced_decisions) + + self.assertEqual(user_context_2.get_forced_decision(context_with_flag).variation_key, 'v1') + self.assertEqual(user_context_2.get_forced_decision(context_with_rule).variation_key, 'v2') + self.assertEqual(user_context_2.get_forced_decision(context_with_empty_rule).variation_key, 'v3') + + context_with_rule = OptimizelyUserContext.OptimizelyDecisionContext('x', 'y') + decision_for_rule = OptimizelyUserContext.OptimizelyForcedDecision('z') + user_context.set_forced_decision(context_with_rule, decision_for_rule) + self.assertEqual(user_context.get_forced_decision(context_with_rule).variation_key, 'z') + self.assertIsNone(user_context_2.get_forced_decision(context_with_rule)) + + def test_forced_decision_sync_return_correct_number_of_calls(self): + """ + Should return valid number of call on running forced decision calls in thread. + """ + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) + user_context = OptimizelyUserContext(opt_obj, "test_user", {}) + context_1 = OptimizelyUserContext.OptimizelyDecisionContext('f1', None) + decision_1 = OptimizelyUserContext.OptimizelyForcedDecision('v1') + context_2 = OptimizelyUserContext.OptimizelyDecisionContext('f2', None) + decision_2 = OptimizelyUserContext.OptimizelyForcedDecision('v1') + + with mock.patch( + 'optimizely.optimizely_user_context.OptimizelyUserContext.set_forced_decision' + ) as set_forced_decision_mock, mock.patch( + 'optimizely.optimizely_user_context.OptimizelyUserContext.get_forced_decision' + ) as get_forced_decision_mock, mock.patch( + 'optimizely.optimizely_user_context.OptimizelyUserContext.remove_forced_decision' + ) as remove_forced_decision_mock, mock.patch( + 'optimizely.optimizely_user_context.OptimizelyUserContext.remove_all_forced_decisions' + ) as remove_all_forced_decisions_mock, mock.patch( + 'optimizely.optimizely_user_context.OptimizelyUserContext._clone' + ) as clone_mock: + def set_forced_decision_loop(user_context, context, decision): + for x in range(100): + user_context.set_forced_decision(context, decision) + + def get_forced_decision_loop(user_context, context): + for x in range(100): + user_context.get_forced_decision(context) + + def remove_forced_decision_loop(user_context, context): + for x in range(100): + user_context.remove_forced_decision(context) + + def remove_all_forced_decisions_loop(user_context): + for x in range(100): + user_context.remove_all_forced_decisions() + + def clone_loop(user_context): + for x in range(100): + user_context._clone() + + set_thread_1 = threading.Thread(target=set_forced_decision_loop, args=(user_context, context_1, decision_1)) + set_thread_2 = threading.Thread(target=set_forced_decision_loop, args=(user_context, context_2, decision_2)) + set_thread_3 = threading.Thread(target=get_forced_decision_loop, args=(user_context, context_1)) + set_thread_4 = threading.Thread(target=get_forced_decision_loop, args=(user_context, context_2)) + set_thread_5 = threading.Thread(target=remove_forced_decision_loop, args=(user_context, context_1)) + set_thread_6 = threading.Thread(target=remove_forced_decision_loop, args=(user_context, context_2)) + set_thread_7 = threading.Thread(target=remove_all_forced_decisions_loop, args=(user_context,)) + set_thread_8 = threading.Thread(target=clone_loop, args=(user_context,)) + + # Starting the threads + set_thread_1.start() + set_thread_2.start() + set_thread_3.start() + set_thread_4.start() + set_thread_5.start() + set_thread_6.start() + set_thread_7.start() + set_thread_8.start() + + # Waiting for all the threads to finish executing + set_thread_1.join() + set_thread_2.join() + set_thread_3.join() + set_thread_4.join() + set_thread_5.join() + set_thread_6.join() + set_thread_7.join() + set_thread_8.join() + + self.assertEqual(200, set_forced_decision_mock.call_count) + self.assertEqual(200, get_forced_decision_mock.call_count) + self.assertEqual(200, remove_forced_decision_mock.call_count) + self.assertEqual(100, remove_all_forced_decisions_mock.call_count) + self.assertEqual(100, clone_mock.call_count) From de4a31cb511c6cbdc4432541f08619f4d6b73ea6 Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Thu, 4 Nov 2021 18:35:37 -0700 Subject: [PATCH 15/31] Update optimizely/optimizely_user_context.py Co-authored-by: ozayr-zaviar <54209343+ozayr-zaviar@users.noreply.github.com> --- optimizely/optimizely_user_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/optimizely/optimizely_user_context.py b/optimizely/optimizely_user_context.py index a4f5e695..d761bb07 100644 --- a/optimizely/optimizely_user_context.py +++ b/optimizely/optimizely_user_context.py @@ -211,7 +211,7 @@ def remove_forced_decision(self, OptimizelyDecisionContext): return False with self.lock: - if self.forced_decisions[OptimizelyDecisionContext]: + if OptimizelyDecisionContext in self.forced_decisions.keys() del self.forced_decisions[OptimizelyDecisionContext] return True From 0c52707fb6a04659e2ddaa6455561392c007e09f Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Thu, 4 Nov 2021 18:43:48 -0700 Subject: [PATCH 16/31] use get() vs [] in remove_forced_decision --- optimizely/optimizely_user_context.py | 2 +- similar_rule_keys_audience.json | 171 ++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 similar_rule_keys_audience.json diff --git a/optimizely/optimizely_user_context.py b/optimizely/optimizely_user_context.py index 3043f65a..10149a0a 100644 --- a/optimizely/optimizely_user_context.py +++ b/optimizely/optimizely_user_context.py @@ -207,7 +207,7 @@ def remove_forced_decision(self, OptimizelyDecisionContext): self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return False - if self.forced_decisions[OptimizelyDecisionContext]: + if self.forced_decisions.get(OptimizelyDecisionContext): del self.forced_decisions[OptimizelyDecisionContext] return True diff --git a/similar_rule_keys_audience.json b/similar_rule_keys_audience.json new file mode 100644 index 00000000..af9522d5 --- /dev/null +++ b/similar_rule_keys_audience.json @@ -0,0 +1,171 @@ +{ + "version": "4", + "rollouts": [ + { + "experiments": [ + { + "status": "Running", + "audienceConditions": ["or", "20406066925"], + "audienceIds": ["20406066925"], + "variations": [ + { + "variables": [], + "id": "8048", + "key": "variation2", + "featureEnabled": true + } + ], + "forcedVariations": {}, + "key": "targeted_delivery", + "layerId": "9300000007573", + "trafficAllocation": [{ "entityId": "8048", "endOfRange": 10000 }], + "id": "9300000007573" + }, + { + "status": "Running", + "audienceConditions": [], + "audienceIds": [], + "variations": [ + { + "variables": [], + "id": "8046", + "key": "off", + "featureEnabled": false + } + ], + "forcedVariations": {}, + "key": "default-rollout-3046-20390585493", + "layerId": "default-layer-rollout-3046-20390585493", + "trafficAllocation": [{ "entityId": "8046", "endOfRange": 10000 }], + "id": "default-rollout-3046-20390585493" + } + ], + "id": "rollout-3046-20390585493" + }, + { + "experiments": [ + { + "status": "Running", + "audienceConditions": ["or", "20415611520"], + "audienceIds": ["20415611520"], + "variations": [ + { + "variables": [], + "id": "8045", + "key": "variation1", + "featureEnabled": true + } + ], + "forcedVariations": {}, + "key": "targeted_delivery", + "layerId": "9300000007569", + "trafficAllocation": [{ "entityId": "8045", "endOfRange": 10000 }], + "id": "9300000007569" + }, + { + "status": "Running", + "audienceConditions": [], + "audienceIds": [], + "variations": [ + { + "variables": [], + "id": "8043", + "key": "off", + "featureEnabled": false + } + ], + "forcedVariations": {}, + "key": "default-rollout-3045-20390585493", + "layerId": "default-layer-rollout-3045-20390585493", + "trafficAllocation": [{ "entityId": "8043", "endOfRange": 10000 }], + "id": "default-rollout-3045-20390585493" + } + ], + "id": "rollout-3045-20390585493" + } + ], + "typedAudiences": [ + { + "id": "20415611520", + "conditions": [ + "and", + [ + "or", + [ + "or", + { + "value": true, + "type": "custom_attribute", + "name": "hiddenLiveEnabled", + "match": "exact" + } + ] + ] + ], + "name": "polina-test1" + }, + { + "id": "20406066925", + "conditions": [ + "and", + [ + "or", + [ + "or", + { + "value": false, + "type": "custom_attribute", + "name": "hiddenLiveEnabled", + "match": "exact" + } + ] + ] + ], + "name": "polina-test2" + } + ], + "anonymizeIP": true, + "projectId": "20430981610", + "variables": [], + "featureFlags": [ + { + "experimentIds": [], + "rolloutId": "rollout-3046-20390585493", + "variables": [], + "id": "3046", + "key": "flag2" + }, + { + "experimentIds": [], + "rolloutId": "rollout-3045-20390585493", + "variables": [], + "id": "3045", + "key": "flag1" + } + ], + "experiments": [], + "audiences": [ + { + "id": "20415611520", + "conditions": "[\"or\", {\"match\": \"exact\", \"name\": \"$opt_dummy_attribute\", \"type\": \"custom_attribute\", \"value\": \"$opt_dummy_value\"}]", + "name": "polina-test1" + }, + { + "id": "20406066925", + "conditions": "[\"or\", {\"match\": \"exact\", \"name\": \"$opt_dummy_attribute\", \"type\": \"custom_attribute\", \"value\": \"$opt_dummy_value\"}]", + "name": "polina-test2" + }, + { + "conditions": "[\"or\", {\"match\": \"exact\", \"name\": \"$opt_dummy_attribute\", \"type\": \"custom_attribute\", \"value\": \"$opt_dummy_value\"}]", + "id": "$opt_dummy_audience", + "name": "Optimizely-Generated Audience for Backwards Compatibility" + } + ], + "groups": [], + "attributes": [{ "id": "20408641883", "key": "hiddenLiveEnabled" }], + "botFiltering": false, + "accountId": "17882702980", + "events": [], + "revision": "25", + "sendFlagDecisions": true +} From 081cd79d737ba50442ae1816a05b238b48f7e3a7 Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Sun, 7 Nov 2021 14:53:26 -0800 Subject: [PATCH 17/31] add missing colon --- optimizely/optimizely_user_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/optimizely/optimizely_user_context.py b/optimizely/optimizely_user_context.py index 2b5d882b..f09923f5 100644 --- a/optimizely/optimizely_user_context.py +++ b/optimizely/optimizely_user_context.py @@ -211,7 +211,7 @@ def remove_forced_decision(self, OptimizelyDecisionContext): return False with self.lock: - if OptimizelyDecisionContext in self.forced_decisions.keys():g + if OptimizelyDecisionContext in self.forced_decisions.keys(): del self.forced_decisions[OptimizelyDecisionContext] return True From e061abc4a99e296ef4b597af50f68bd007e631b0 Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Wed, 10 Nov 2021 16:58:09 -0800 Subject: [PATCH 18/31] fix displaying reasons --- optimizely/optimizely_user_context.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/optimizely/optimizely_user_context.py b/optimizely/optimizely_user_context.py index f09923f5..b11173c1 100644 --- a/optimizely/optimizely_user_context.py +++ b/optimizely/optimizely_user_context.py @@ -272,19 +272,19 @@ def find_validated_forced_decision(self, OptimizelyDecisionContext, options): reasons.append(user_has_forced_decision) self.log.logger.debug(user_has_forced_decision) - return variation, reasons - - else: - if rule_key: - user_has_forced_decision_but_invalid = enums.ForcedDecisionLogs \ - .USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED_BUT_INVALID.format(flag_key, - rule_key, - self.user_id) + return variation, reasons + else: - user_has_forced_decision_but_invalid = enums.ForcedDecisionLogs \ - .USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED_BUT_INVALID.format(flag_key, self.user_id) + if rule_key: + user_has_forced_decision_but_invalid = enums.ForcedDecisionLogs \ + .USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED_BUT_INVALID.format(flag_key, + rule_key, + self.user_id) + else: + user_has_forced_decision_but_invalid = enums.ForcedDecisionLogs \ + .USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED_BUT_INVALID.format(flag_key, self.user_id) - reasons.append(user_has_forced_decision_but_invalid) - self.log.logger.debug(user_has_forced_decision_but_invalid) + reasons.append(user_has_forced_decision_but_invalid) + self.log.logger.debug(user_has_forced_decision_but_invalid) - return None, reasons + return None, reasons From 337f8d98f1b5c59286855af1d0a805629fe8434b Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Fri, 12 Nov 2021 10:04:14 -0800 Subject: [PATCH 19/31] Update optimizely/optimizely.py Co-authored-by: Jae Kim <45045038+jaeopt@users.noreply.github.com> --- optimizely/optimizely.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 4ac31ca6..adf76f9e 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -1040,7 +1040,7 @@ def _decide(self, user_context, key, decide_options=None): forced_decision_response = user_context.find_validated_forced_decision(optimizely_decision_context, options=decide_options) - variation, received_response = forced_decision_response + variation, decision_reasons = forced_decision_response reasons += received_response if variation: From a71f50e6851df888786c9423cda7b6a22db4f43e Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Mon, 15 Nov 2021 16:12:00 -0800 Subject: [PATCH 20/31] address PR comments --- optimizely/decision_service.py | 2 +- optimizely/optimizely.py | 22 ++--- optimizely/optimizely_user_context.py | 56 +++++------- optimizely/project_config.py | 36 +++----- tests/test_user_context.py | 127 +++++++++++--------------- 5 files changed, 101 insertions(+), 142 deletions(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index dbf49e0f..67ff5259 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -357,7 +357,7 @@ def get_variation_for_rollout(self, project_config, feature, user, options): decide_reasons.append(message) return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons - rollout_rules = project_config.get_rollout_experiments_map(rollout) + rollout_rules = project_config.get_rollout_experiments(rollout) if not rollout_rules: message = 'Rollout {} has no experiments.'.format(rollout.id) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 4ac31ca6..b84be22b 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -444,20 +444,20 @@ def activate(self, experiment_key, user_id, attributes=None): self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('activate')) return None - variation_key = self.get_variation(experiment_key, user_id, attributes) + variation = self.get_variation(experiment_key, user_id, attributes) - # check for case where variation_key can be None when attributes are invalid - if not variation_key: + if not variation: self.logger.info('Not activating user "%s".' % user_id) return None - # variation_key is normally a tuple object - if not variation_key[0]: + variation_key, reasons = variation + + if not variation_key: self.logger.info('Not activating user "%s".' % user_id) return None experiment = project_config.get_experiment_from_key(experiment_key) - variation = project_config.get_variation_from_key(experiment_key, variation_key) + variation = project_config.get_variation_from_key(experiment_key, variation_key.key) # Create and dispatch impression event self.logger.info('Activating user "%s" in experiment "%s".' % (user_id, experiment.key)) @@ -1040,8 +1040,8 @@ def _decide(self, user_context, key, decide_options=None): forced_decision_response = user_context.find_validated_forced_decision(optimizely_decision_context, options=decide_options) - variation, received_response = forced_decision_response - reasons += received_response + variation, decision_reasons = forced_decision_response + reasons += decision_reasons if variation: decision = Decision(None, variation, enums.DecisionSources.FEATURE_TEST) @@ -1203,12 +1203,10 @@ def get_flag_variation_by_key(self, flag_key, variation_key): if not config: return None - # this will take care of force decision objects which contain variation_key inside them - if isinstance(variation_key, OptimizelyUserContext.OptimizelyForcedDecision): - variation_key = variation_key.variation_key - variations = config.flag_variations_map[flag_key] for variation in variations: if variation.key == variation_key: return variation + + return None diff --git a/optimizely/optimizely_user_context.py b/optimizely/optimizely_user_context.py index b11173c1..837fdc14 100644 --- a/optimizely/optimizely_user_context.py +++ b/optimizely/optimizely_user_context.py @@ -46,8 +46,7 @@ def __init__(self, optimizely_client, user_id, user_attributes=None): self._user_attributes = user_attributes.copy() if user_attributes else {} self.lock = threading.Lock() - with self.lock: - self.forced_decisions = {} + self.forced_decisions = {} self.log = logger.SimpleLogger(min_level=enums.LogLevels.INFO) # decision context @@ -149,70 +148,61 @@ def as_json(self): 'attributes': self.get_user_attributes(), } - def set_forced_decision(self, OptimizelyDecisionContext, OptimizelyForcedDecision): + def set_forced_decision(self, decision_context, decision): """ Sets the forced decision for a given decision context. Args: - OptimizelyDecisionContext: a decision context. - OptimizelyForcedDecision: a forced decision. + decision_context: a decision context. + decision: a forced decision. Returns: True if the forced decision has been set successfully. """ - config = self.client.get_optimizely_config() - - if self.client is None or config is None: + if not self.client.config_manager.get_config(): self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return False - context = OptimizelyDecisionContext - decision = OptimizelyForcedDecision - with self.lock: - self.forced_decisions[context] = decision + self.forced_decisions[decision_context] = decision return True - def get_forced_decision(self, OptimizelyDecisionContext): + def get_forced_decision(self, decision_context): """ Gets the forced decision (variation key) for a given decision context. Args: - OptimizelyDecisionContext: a decision context. + decision_context: a decision context. Returns: A variation key or None if forced decisions are not set for the parameters. """ - config = self.client.get_optimizely_config() - - if self.client is None or config is None: + if not self.client.config_manager.get_config(): self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return None - forced_decision_key = self.find_forced_decision(OptimizelyDecisionContext) + forced_decision_key = self.find_forced_decision(decision_context) return forced_decision_key if forced_decision_key else None - def remove_forced_decision(self, OptimizelyDecisionContext): + def remove_forced_decision(self, decision_context): """ Removes the forced decision for a given flag and an optional rule. Args: - OptimizelyDecisionContext: a decision context. + decision_context: a decision context. Returns: Returns: true if the forced decision has been removed successfully. """ - config = self.client.get_optimizely_config() - - if self.client is None or config is None: + if not self.client.config_manager.get_config(): self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return False with self.lock: - if OptimizelyDecisionContext in self.forced_decisions.keys(): - del self.forced_decisions[OptimizelyDecisionContext] + if decision_context in self.forced_decisions.keys(): + del self.forced_decisions[decision_context] return True return False @@ -224,9 +214,7 @@ def remove_all_forced_decisions(self): Returns: True if forced decisions have been removed successfully. """ - config = self.client.get_optimizely_config() - - if self.client is None or config is None: + if not self.client.config_manager.get_config(): self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return False @@ -235,23 +223,23 @@ def remove_all_forced_decisions(self): return True - def find_forced_decision(self, OptimizelyDecisionContext): + def find_forced_decision(self, decision_context): with self.lock: if not self.forced_decisions: return None # must allow None to be returned for the Flags only case - return self.forced_decisions.get(OptimizelyDecisionContext) + return self.forced_decisions.get(decision_context) - def find_validated_forced_decision(self, OptimizelyDecisionContext, options): + def find_validated_forced_decision(self, decision_context, options): reasons = [] - forced_decision_response = self.find_forced_decision(OptimizelyDecisionContext) + forced_decision_response = self.find_forced_decision(decision_context) - flag_key = OptimizelyDecisionContext.flag_key - rule_key = OptimizelyDecisionContext.rule_key + flag_key = decision_context.flag_key + rule_key = decision_context.rule_key if forced_decision_response: variation = self.client.get_flag_variation_by_key(flag_key, forced_decision_response.variation_key) diff --git a/optimizely/project_config.py b/optimizely/project_config.py index d0448d05..f1c3732b 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -150,7 +150,7 @@ def __init__(self, datafile, logger, error_handler): if not flag['rolloutId'] == '': rollout = self.rollout_id_map[flag['rolloutId']] - rollout_experiments = self.get_rollout_experiments_map(rollout) + rollout_experiments = self.get_rollout_experiments(rollout) if rollout and rollout.experiments: experiments.extend(rollout_experiments) @@ -213,7 +213,7 @@ def _deserialize_audience(audience_map): return audience_map - def get_rollout_experiments_map(self, rollout): + def get_rollout_experiments(self, rollout): """ Helper method to get rollout experiments as a map. Args: @@ -387,8 +387,8 @@ def get_audience(self, audience_id): self.logger.error('Audience ID "%s" is not in datafile.' % audience_id) self.error_handler.handle_error(exceptions.InvalidAudienceException((enums.Errors.INVALID_AUDIENCE))) - def get_variation_from_key(self, experiment_key, variation): - """ Get variation given experiment and variation. + def get_variation_from_key(self, experiment_key, variation_key): + """ Get variation given experiment and variation key. Args: experiment: Key representing parent experiment of variation. @@ -399,28 +399,20 @@ def get_variation_from_key(self, experiment_key, variation): Object representing the variation. """ - variation_key = None + variation_map = self.variation_key_map.get(experiment_key) - if isinstance(variation, tuple): - if isinstance(variation[0], entities.Variation): - variation_key, received_reasons = variation - else: - variation_map = self.variation_key_map.get(experiment_key) - - if variation_map: - variation_key = variation_map.get(variation) + if variation_map: + variation = variation_map.get(variation_key) + if variation: + return variation else: - self.logger.error('Experiment key "%s" is not in datafile.' % experiment_key) - self.error_handler.handle_error( - exceptions.InvalidExperimentException(enums.Errors.INVALID_EXPERIMENT_KEY)) + self.logger.error('Variation key "%s" is not in datafile.' % variation_key) + self.error_handler.handle_error(exceptions.InvalidVariationException(enums.Errors.INVALID_VARIATION)) return None - if variation_key: - return variation_key - else: - self.logger.error('Variation key "%s" is not in datafile.' % variation) - self.error_handler.handle_error(exceptions.InvalidVariationException(enums.Errors.INVALID_VARIATION)) - return None + self.logger.error('Experiment key "%s" is not in datafile.' % experiment_key) + self.error_handler.handle_error(exceptions.InvalidExperimentException(enums.Errors.INVALID_EXPERIMENT_KEY)) + return None def get_variation_from_id(self, experiment_key, variation_id): """ Get variation given experiment and variation ID. diff --git a/tests/test_user_context.py b/tests/test_user_context.py index c05419f4..0ffefeef 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -773,10 +773,6 @@ def test_decide__option__include_reasons__feature_test(self): actual = user_context.decide('test_feature_in_experiment', ['INCLUDE_REASONS']) expected_reasons = [ - 'Invalid variation is mapped to flag (test_feature_in_experiment) and user (test_user) ' - 'in the forced decision map.', - 'Invalid variation is mapped to flag (test_feature_in_experiment), rule (test_experiment) and ' - 'user (test_user) in the forced decision map.', '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.' @@ -792,10 +788,6 @@ def test_decide__option__include_reasons__feature_rollout(self): actual = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) expected_reasons = [ - 'Invalid variation is mapped to flag (test_feature_in_rollout) ' - 'and user (test_user) in the forced decision map.', - 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211127) ' - 'and user (test_user) in the forced decision map.', 'Evaluating audiences for rule 1: ["11154"].', 'Audiences for rule 1 collectively evaluated to TRUE.', 'User "test_user" meets audience conditions for targeting rule 1.', @@ -1151,18 +1143,12 @@ def test_decide_reasons__hit_everyone_else_rule__fails_bucketing(self): actual = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) expected_reasons = [ - 'Invalid variation is mapped to flag (test_feature_in_rollout) and user (test_user) ' - 'in the forced decision map.', - 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211127) and user (test_user) ' - 'in the forced decision map.', - 'Evaluating audiences for rule 1: ["11154"].', 'Audiences for rule 1 collectively evaluated to FALSE.', + 'Evaluating audiences for rule 1: ["11154"].', + 'Audiences for rule 1 collectively evaluated to FALSE.', 'User "test_user" does not meet audience conditions for targeting rule 1.', - 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211137) and user (test_user) ' - 'in the forced decision map.', - 'Evaluating audiences for rule 2: ["11159"].', 'Audiences for rule 2 collectively evaluated to FALSE.', + 'Evaluating audiences for rule 2: ["11159"].', + 'Audiences for rule 2 collectively evaluated to FALSE.', 'User "test_user" does not meet audience conditions for targeting rule 2.', - 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211147) and user (test_user) ' - 'in the forced decision map.', 'Evaluating audiences for rule Everyone Else: [].', 'Audiences for rule Everyone Else collectively evaluated to TRUE.', 'User "test_user" meets audience conditions for targeting rule Everyone Else.', @@ -1179,20 +1165,12 @@ def test_decide_reasons__hit_everyone_else_rule(self): actual = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) expected_reasons = [ - 'Invalid variation is mapped to flag (test_feature_in_rollout) and user (abcde) ' - 'in the forced decision map.', - 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211127) and user (abcde) ' - 'in the forced decision map.', 'Evaluating audiences for rule 1: ["11154"].', 'Audiences for rule 1 collectively evaluated to FALSE.', 'User "abcde" does not meet audience conditions for targeting rule 1.', - 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211137) and user (abcde) ' - 'in the forced decision map.', 'Evaluating audiences for rule 2: ["11159"].', 'Audiences for rule 2 collectively evaluated to FALSE.', 'User "abcde" does not meet audience conditions for targeting rule 2.', - 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211147) and user (abcde) ' - 'in the forced decision map.', 'Evaluating audiences for rule Everyone Else: [].', 'Audiences for rule Everyone Else collectively evaluated to TRUE.', 'User "abcde" meets audience conditions for targeting rule Everyone Else.', @@ -1209,19 +1187,14 @@ def test_decide_reasons__hit_rule2__fails_bucketing(self): actual = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) expected_reasons = [ - 'Invalid variation is mapped to flag (test_feature_in_rollout) and ' - 'user (test_user) in the forced decision map.', - 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211127) ' - 'and user (test_user) in the forced decision map.', 'Evaluating audiences for rule 1: ["11154"].', 'Audiences for rule 1 collectively evaluated to FALSE.', 'User "test_user" does not meet audience conditions for targeting rule 1.', - 'Invalid variation is mapped to flag (test_feature_in_rollout), rule (211137) ' - 'and user (test_user) in the forced decision map.', 'Evaluating audiences for rule 2: ["11159"].', 'Audiences for rule 2 collectively evaluated to TRUE.', 'User "test_user" meets audience conditions for targeting rule 2.', - 'User "test_user" bucketed into a targeting rule 2.'] + 'User "test_user" bucketed into a targeting rule 2.' + ] self.assertEqual(expected_reasons, actual.reasons) @@ -1257,12 +1230,9 @@ def save(self, user_profile): actual = user_context.decide('test_feature_in_experiment', options) expected_reasons = [ - 'Invalid variation is mapped to flag (test_feature_in_experiment) and user (test_user) ' - 'in the forced decision map.', - 'Invalid variation is mapped to flag (test_feature_in_experiment), rule (test_experiment) ' - 'and user (test_user) in the forced decision map.', - 'Returning previously activated variation ID "control" of experiment "test_experiment" ' - 'for user "test_user" from user profile.'] + 'Returning previously activated variation ID "control" of experiment ' + '"test_experiment" for user "test_user" from user profile.' + ] self.assertEqual(expected_reasons, actual.reasons) @@ -1279,12 +1249,8 @@ def test_decide_reasons__forced_variation(self): actual = user_context.decide('test_feature_in_experiment', options) expected_reasons = [ - 'Invalid variation is mapped to flag (test_feature_in_experiment) and user (test_user) ' - 'in the forced decision map.', - 'Invalid variation is mapped to flag (test_feature_in_experiment), rule (test_experiment) ' - 'and user (test_user) in the forced decision map.', - 'Variation "control" is mapped to experiment "test_experiment" ' - 'and user "test_user" in the forced variation map' + 'Variation "control" is mapped to experiment "test_experiment" and ' + 'user "test_user" in the forced variation map' ] self.assertEqual(expected_reasons, actual.reasons) @@ -1298,13 +1264,7 @@ def test_decide_reasons__whitelisted_variation(self): options = ['INCLUDE_REASONS'] actual = user_context.decide('test_feature_in_experiment', options) - - expected_reasons = [ - 'Invalid variation is mapped to flag (test_feature_in_experiment) and user (user_1) ' - 'in the forced decision map.', - 'Invalid variation is mapped to flag (test_feature_in_experiment), rule (test_experiment) ' - 'and user (user_1) in the forced decision map.', - 'User "user_1" is forced in variation "control".'] + expected_reasons = ['User "user_1" is forced in variation "control".'] self.assertEqual(expected_reasons, actual.reasons) @@ -1463,6 +1423,9 @@ def test_decide_return_decision__forced_decision(self): {} ) + self.assertTrue('Variation (211129) is mapped to flag (test_feature_in_rollout) ' + 'and user (test_user) in the forced decision map.' in decide_decision.reasons) + status = user_context.remove_forced_decision(context) self.assertTrue(status) @@ -1473,10 +1436,6 @@ def test_decide_return_decision__forced_decision(self): self.assertEqual(decide_decision.flag_key, 'test_feature_in_rollout') self.assertEqual(decide_decision.user_context.user_id, 'test_user') self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) - self.assertTrue(set(decide_decision.reasons).issuperset(set([ - 'Invalid variation is mapped to flag (test_feature_in_rollout) and ' - 'user (test_user) in the forced decision map.' - ]))) def test_delivery_rule_return_decision__forced_decision(self): """ @@ -1514,10 +1473,20 @@ def test_delivery_rule_return_decision__forced_decision(self): self.assertEqual(decide_decision.flag_key, 'test_feature_in_rollout') self.assertEqual(decide_decision.user_context.user_id, 'test_user') self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) - self.assertTrue(set(decide_decision.reasons).issuperset(set([ - 'Invalid variation is mapped to flag (test_feature_in_rollout) ' - 'and user (test_user) in the forced decision map.' - ]))) + + expected_reasons = [ + 'Evaluating audiences for rule 1: ["11154"].', + 'Audiences for rule 1 collectively evaluated to FALSE.', + 'User "test_user" does not meet audience 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 audience conditions for targeting rule 2.', + 'Evaluating audiences for rule Everyone Else: [].', + 'Audiences for rule Everyone Else collectively evaluated to TRUE.', + 'User "test_user" meets audience conditions for targeting rule Everyone Else.', + 'User "test_user" bucketed into a targeting rule Everyone Else.' + ] + self.assertEqual(decide_decision.reasons, expected_reasons) def test_experiment_rule_return_decision__forced_decision(self): """ @@ -1557,12 +1526,15 @@ def test_experiment_rule_return_decision__forced_decision(self): self.assertEqual(decide_decision.flag_key, 'test_feature_in_experiment_and_rollout') self.assertEqual(decide_decision.user_context.user_id, 'test_user') self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) - self.assertTrue(set(decide_decision.reasons).issuperset(set([ - 'Invalid variation is mapped to flag (test_feature_in_experiment_and_rollout) ' - 'and user (test_user) in the forced decision map.', - 'Invalid variation is mapped to flag (test_feature_in_experiment_and_rollout), ' - 'rule (group_exp_2) and user (test_user) in the forced decision map.' - ]))) + + expected_reasons = [ + 'Evaluating audiences for experiment "group_exp_2": [].', + 'Audiences for experiment "group_exp_2" collectively evaluated to TRUE.', + 'User "test_user" is in experiment group_exp_2 of group 19228.', + 'User "test_user" is in variation "group_exp_2_control" of experiment group_exp_2.' + ] + + self.assertEqual(decide_decision.reasons, expected_reasons) def test_invalid_delivery_rule_return_decision__forced_decision(self): """ @@ -1587,13 +1559,13 @@ def test_invalid_delivery_rule_return_decision__forced_decision(self): self.assertEqual(decide_decision.user_context.user_id, 'test_user') self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) self.assertTrue(set(decide_decision.reasons).issuperset(set([ - 'Invalid variation is mapped to flag (test_feature_in_rollout) ' - 'and user (test_user) in the forced decision map.' + 'Invalid variation is mapped to flag (test_feature_in_rollout), ' + 'rule (211127) and user (test_user) in the forced decision map.' ]))) def test_invalid_experiment_rule_return_decision__forced_decision(self): """ - Should return valid decision after setting invalid experiemnt + Should return valid decision after setting invalid experiment rule variation in forced decision. """ opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) @@ -1615,10 +1587,19 @@ def test_invalid_experiment_rule_return_decision__forced_decision(self): self.assertEqual(decide_decision.flag_key, 'test_feature_in_rollout') self.assertEqual(decide_decision.user_context.user_id, 'test_user') self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) - self.assertTrue(set(decide_decision.reasons).issuperset(set([ - 'Invalid variation is mapped to flag (test_feature_in_rollout) and ' - 'user (test_user) in the forced decision map.' - ]))) + + expected_reasons = [ + 'Evaluating audiences for rule 1: ["11154"].', 'Audiences for rule 1 collectively evaluated to FALSE.', + 'User "test_user" does not meet audience 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 audience conditions for targeting rule 2.', + 'Evaluating audiences for rule Everyone Else: [].', + 'Audiences for rule Everyone Else collectively evaluated to TRUE.', + 'User "test_user" meets audience conditions for targeting rule Everyone Else.', + 'User "test_user" bucketed into a targeting rule Everyone Else.' + ] + + self.assertEqual(decide_decision.reasons, expected_reasons) def test_conflicts_return_valid_decision__forced_decision(self): """ From 94d5af9ad2146cd3ad2411991b51b12339a8790d Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Tue, 16 Nov 2021 16:17:50 -0800 Subject: [PATCH 21/31] more PR review fixes --- optimizely/optimizely.py | 33 +++++++++++++++------------ optimizely/optimizely_user_context.py | 10 ++++---- optimizely/project_config.py | 4 ++-- tests/test_optimizely.py | 8 +++++++ 4 files changed, 33 insertions(+), 22 deletions(-) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index b84be22b..9c555f35 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -293,6 +293,9 @@ def _get_feature_variable_for_type( ) if decision.source == enums.DecisionSources.FEATURE_TEST: + + print('DECISION.EXPERIMENT - ', decision.experiment) + source_info = { 'experiment_key': decision.experiment.key, 'variation_key': decision.variation.key, @@ -444,20 +447,14 @@ def activate(self, experiment_key, user_id, attributes=None): self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('activate')) return None - variation = self.get_variation(experiment_key, user_id, attributes) - - if not variation: - self.logger.info('Not activating user "%s".' % user_id) - return None - - variation_key, reasons = variation + variation_key = self.get_variation(experiment_key, user_id, attributes) if not variation_key: self.logger.info('Not activating user "%s".' % user_id) return None experiment = project_config.get_experiment_from_key(experiment_key) - variation = project_config.get_variation_from_key(experiment_key, variation_key.key) + variation = project_config.get_variation_from_key(experiment_key, variation_key) # Create and dispatch impression event self.logger.info('Activating user "%s" in experiment "%s".' % (user_id, experiment.key)) @@ -555,7 +552,10 @@ def get_variation(self, experiment_key, user_id, attributes=None): return None user_context = self.create_user_context(user_id, attributes) - variation_key = self.decision_service.get_variation(project_config, experiment, user_context) + + variation, _ = self.decision_service.get_variation(project_config, experiment, user_context) + if variation: + variation_key = variation.key if project_config.is_feature_experiment(experiment.id): decision_notification_type = enums.DecisionNotificationTypes.FEATURE_TEST @@ -841,7 +841,7 @@ def get_feature_variable_json(self, feature_key, variable_key, user_id, attribut project_config, feature_key, variable_key, variable_type, user_id, attributes, ) - def get_all_feature_variables(self, feature_key, user_id, attributes): + def get_all_feature_variables(self, feature_key, user_id, attributes=None): """ Returns dictionary of all variables and their corresponding values in the context of a feature. Args: @@ -1047,10 +1047,10 @@ def _decide(self, user_context, key, decide_options=None): decision = Decision(None, variation, enums.DecisionSources.FEATURE_TEST) else: # Regular decision - decision = decision_service.DecisionService.get_variation_for_feature(self.decision_service, config, - feature_flag, - user_context, ignore_ups) - decision, decision_reasons = decision + decision, decision_reasons = self.decision_service.get_variation_for_feature(config, + feature_flag, + user_context, ignore_ups) + reasons += decision_reasons # Fill in experiment and variation if returned (rollouts can have featureEnabled variables as well.) @@ -1203,7 +1203,10 @@ def get_flag_variation_by_key(self, flag_key, variation_key): if not config: return None - variations = config.flag_variations_map[flag_key] + if not flag_key: + return None + + variations = config.flag_variations_map.get(flag_key) for variation in variations: if variation.key == variation_key: diff --git a/optimizely/optimizely_user_context.py b/optimizely/optimizely_user_context.py index 837fdc14..a502db80 100644 --- a/optimizely/optimizely_user_context.py +++ b/optimizely/optimizely_user_context.py @@ -176,19 +176,19 @@ def get_forced_decision(self, decision_context): decision_context: a decision context. Returns: - A variation key or None if forced decisions are not set for the parameters. + A forced_decision or None if forced decisions are not set for the parameters. """ if not self.client.config_manager.get_config(): self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return None - forced_decision_key = self.find_forced_decision(decision_context) + forced_decision = self.find_forced_decision(decision_context) - return forced_decision_key if forced_decision_key else None + return forced_decision if forced_decision else None def remove_forced_decision(self, decision_context): """ - Removes the forced decision for a given flag and an optional rule. + Removes the forced decision for a given decision context. Args: decision_context: a decision context. @@ -201,7 +201,7 @@ def remove_forced_decision(self, decision_context): return False with self.lock: - if decision_context in self.forced_decisions.keys(): + if decision_context in self.forced_decisions: del self.forced_decisions[decision_context] return True diff --git a/optimizely/project_config.py b/optimizely/project_config.py index f1c3732b..37d3095c 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -155,7 +155,7 @@ def __init__(self, datafile, logger, error_handler): if rollout and rollout.experiments: experiments.extend(rollout_experiments) - self.flag_rules_map[flag['key']] = experiments + self.flag_rules_map[flag['key']] = experiments # All variations for each flag # Datafile does not contain a separate entity for this. @@ -214,7 +214,7 @@ def _deserialize_audience(audience_map): return audience_map def get_rollout_experiments(self, rollout): - """ Helper method to get rollout experiments as a map. + """ Helper method to get rollout experiments as. Args: rollout: rollout diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index ad43baa0..2506e010 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -5067,3 +5067,11 @@ def test_user_context_invalid_user_id(self): for u in user_ids: uc = self.optimizely.create_user_context(u) self.assertIsNone(uc, "invalid user id should return none") + + def test_invalid_flag_key(self): + """ + Tests invalid flag key in function get_flag_variation_by_key(). + """ + # TODO mock function get_flag_variation_by_key? + pass + From e9cd3049b6857b864c51cbca247ecc8e9710e416 Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Tue, 16 Nov 2021 17:12:12 -0800 Subject: [PATCH 22/31] fixed few more PR comments --- optimizely/decision_service.py | 18 +++++++++++------- optimizely/optimizely.py | 4 ++-- optimizely/project_config.py | 2 +- tests/test_optimizely.py | 1 - 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index 67ff5259..a8f0071c 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -349,6 +349,9 @@ def get_variation_for_rollout(self, project_config, feature, user, options): if not feature: return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons + if not feature.rolloutId: + return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons + rollout = project_config.get_rollout_from_id(feature.rolloutId) if not rollout: @@ -375,7 +378,11 @@ def get_variation_for_rollout(self, project_config, feature, user, options): decide_reasons += reasons_received - if decision_response: + if not decision_response: + # TODO - MATJAZ - careful - check how this exists the loop and terminates properly + # when return is hit + return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons + else: variation, skip_to_everyone_else = decision_response if variation: @@ -431,6 +438,7 @@ def get_variation_from_delivery_rule(self, config, feature, rules, rule_index, u config: Instance of ProjectConfig. flag_key: Key of the flag. rules: Experiment rule. + rule_index: integer index of the rule in the list. user: ID and attributes for user. options: Decide options. @@ -459,7 +467,8 @@ def get_variation_from_delivery_rule(self, config, feature, rules, rule_index, u # regular decision user_id = user.user_id attributes = user.get_user_attributes() - bucketing_id = self._get_bucketing_id(user_id, attributes) + # TODO this bucket_reasons go somewhere? + bucketing_id, bucket_reasons = self._get_bucketing_id(user_id, attributes) everyone_else = (rule_index == len(rules) - 1) logging_key = "Everyone Else" if everyone_else else str(rule_index + 1) @@ -473,7 +482,6 @@ def get_variation_from_delivery_rule(self, config, feature, rules, rule_index, u decide_reasons += reasons_received_audience if audience_decision_response: - message = 'User "{}" meets audience conditions for targeting rule {}.'.format(user_id, logging_key) self.logger.debug(message) decide_reasons.append(message) @@ -535,10 +543,6 @@ def get_variation_for_feature(self, project_config, feature, user_context, ignor if feature.rolloutId: return self.get_variation_for_rollout(project_config, feature, user_context, ignore_user_profile) - # check if not part of experiment - if not feature.experimentIds: - return Decision(None, None, enums.DecisionSources.FEATURE_TEST), decide_reasons - # check if not part of rollout if not feature.rolloutId: return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 9c555f35..85cac248 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -1048,8 +1048,8 @@ def _decide(self, user_context, key, decide_options=None): else: # Regular decision decision, decision_reasons = self.decision_service.get_variation_for_feature(config, - feature_flag, - user_context, ignore_ups) + feature_flag, + user_context, ignore_ups) reasons += decision_reasons diff --git a/optimizely/project_config.py b/optimizely/project_config.py index 37d3095c..d8050e02 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -214,7 +214,7 @@ def _deserialize_audience(audience_map): return audience_map def get_rollout_experiments(self, rollout): - """ Helper method to get rollout experiments as. + """ Helper method to get rollout experiments. Args: rollout: rollout diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index 2506e010..4a419113 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -5074,4 +5074,3 @@ def test_invalid_flag_key(self): """ # TODO mock function get_flag_variation_by_key? pass - From 2dff4c6aed5451bc36ab7a0e1162087bf58808e9 Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Tue, 16 Nov 2021 17:49:22 -0800 Subject: [PATCH 23/31] added bucket reasons --- optimizely/decision_service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index a8f0071c..576e2910 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -467,8 +467,9 @@ def get_variation_from_delivery_rule(self, config, feature, rules, rule_index, u # regular decision user_id = user.user_id attributes = user.get_user_attributes() - # TODO this bucket_reasons go somewhere? + bucketing_id, bucket_reasons = self._get_bucketing_id(user_id, attributes) + decide_reasons += bucket_reasons everyone_else = (rule_index == len(rules) - 1) logging_key = "Everyone Else" if everyone_else else str(rule_index + 1) From e2f1db3d1c4622a5534e204835d37fadd5707923 Mon Sep 17 00:00:00 2001 From: ozayr-zaviar Date: Thu, 18 Nov 2021 15:40:59 +0500 Subject: [PATCH 24/31] FSC fixes --- optimizely/event/user_event_factory.py | 2 ++ optimizely/optimizely.py | 15 ++++++++++++++- optimizely/project_config.py | 25 ++++++++++++++++++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/optimizely/event/user_event_factory.py b/optimizely/event/user_event_factory.py index 1db9fc95..df74b21d 100644 --- a/optimizely/event/user_event_factory.py +++ b/optimizely/event/user_event_factory.py @@ -51,6 +51,8 @@ def create_impression_event( if variation_id and experiment_id: variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id) + elif variation_id and flag_key: + variation = project_config.get_flag_variation_by_id(flag_key, variation_id) event_context = user_event.EventContext( project_config.account_id, project_config.project_id, project_config.revision, project_config.anonymize_ip, ) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 85cac248..6a59cecc 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -198,6 +198,19 @@ def _send_impression_event(self, project_config, experiment, variation, flag_key user_id: ID for user. attributes: Dict representing user attributes and values which need to be recorded. """ + if not experiment: + experiment = entities.Experiment( + id='', + key='', + layerId='', + status='', + variations=[], + trafficAllocation=[], + audienceIds=[], + audienceConditions=[], + forcedVariations={} + ) + variation_id = variation.id if variation is not None else None user_event = user_event_factory.UserEventFactory.create_impression_event( project_config, experiment, variation_id, flag_key, rule_key, rule_type, enabled, user_id, attributes @@ -1057,7 +1070,7 @@ def _decide(self, user_context, key, decide_options=None): if decision.experiment is not None: experiment = decision.experiment source_info["experiment"] = experiment - rule_key = experiment.key + rule_key = experiment.key if experiment else None if decision.variation is not None: variation = decision.variation variation_key = variation.key diff --git a/optimizely/project_config.py b/optimizely/project_config.py index d8050e02..c9e4c35a 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -166,8 +166,8 @@ def __init__(self, datafile, logger, error_handler): variations = [] for rule in rules: # get variations as objects (rule.variations gives list) - variation_objects = self.variation_key_map[rule.key].values() + variation_objects = self.variation_id_map_by_experiment_id[rule.id].values() for variation in variation_objects: if variation.id not in [var.id for var in variations]: variations.append(variation) @@ -651,3 +651,26 @@ def get_variation_from_key_by_experiment_id(self, experiment_id, variation_key): variation_key, experiment_id) return {} + + def get_flag_variation_by_id(self, flag_key, variation_id): + """ + Gets variation by id. + variation_id can be a string or in case of forced decisions, it can be an object. + + Args: + flag_key: flag key + variation_key: variation id + + Returns: + Variation as a map. + """ + + if not flag_key: + return None + + variations = self.flag_variations_map.get(flag_key) + for variation in variations: + if variation.id == variation_id: + return variation + + return None From 6849c339cd7a30c9b5695563fbf9170ad9923d59 Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Thu, 18 Nov 2021 17:58:54 -0800 Subject: [PATCH 25/31] addressed more PR comments, fixed FSC test failuer about impressin events --- optimizely/decision_service.py | 36 ++--- optimizely/event/user_event_factory.py | 6 +- optimizely/optimizely.py | 7 +- similar_rule_keys_audience.json | 171 ---------------------- tests/test_decision_service.py | 32 ++--- tests/test_optimizely.py | 23 ++- tests/test_user_context.py | 190 ++++++++++++++----------- 7 files changed, 154 insertions(+), 311 deletions(-) delete mode 100644 similar_rule_keys_audience.json diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index 576e2910..dfbb28bf 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -21,6 +21,7 @@ from .helpers import experiment as experiment_helper from .helpers import validator from .optimizely_user_context import OptimizelyUserContext +from .decision.optimizely_decide_option import OptimizelyDecideOption from .user_profile import UserProfile Decision = namedtuple('Decision', 'experiment variation source') @@ -224,9 +225,7 @@ def get_stored_variation(self, project_config, experiment, user_profile): return None - def get_variation( - self, project_config, experiment, user_context, ignore_user_profile=False - ): + def get_variation(self, project_config, experiment, user_context, options=None): """ Top-level function to help determine variation user should be put in. First, check if experiment is running. @@ -239,7 +238,7 @@ def get_variation( project_config: Instance of ProjectConfig. experiment: Experiment for which user variation needs to be determined. user_context: contains user id and attributes - ignore_user_profile: True to ignore the user profile lookup. Defaults to False. + options: Decide options. Returns: Variation user should see. None if user is not in experiment or experiment is not running @@ -248,6 +247,11 @@ def get_variation( user_id = user_context.user_id attributes = user_context.get_user_attributes() + if options: + ignore_user_profile = OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE in options + else: + ignore_user_profile = False + decide_reasons = [] # Check if experiment is running if not experiment_helper.is_experiment_running(experiment): @@ -346,10 +350,7 @@ def get_variation_for_rollout(self, project_config, feature, user, options): """ decide_reasons = [] - if not feature: - return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons - - if not feature.rolloutId: + if not feature or not feature.rolloutId: return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons rollout = project_config.get_rollout_from_id(feature.rolloutId) @@ -378,12 +379,7 @@ def get_variation_for_rollout(self, project_config, feature, user, options): decide_reasons += reasons_received - if not decision_response: - # TODO - MATJAZ - careful - check how this exists the loop and terminates properly - # when return is hit - return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons - else: - variation, skip_to_everyone_else = decision_response + variation, skip_to_everyone_else = decision_response if variation: rule = rollout_rules[index] @@ -513,7 +509,7 @@ def get_variation_from_delivery_rule(self, config, feature, rules, rule_index, u return (bucketed_variation, skip_to_everyone_else), decide_reasons - def get_variation_for_feature(self, project_config, feature, user_context, ignore_user_profile=False): + def get_variation_for_feature(self, project_config, feature, user_context, options=None): """ Returns the experiment/variation the user is bucketed in for the given feature. Args: @@ -521,7 +517,7 @@ def get_variation_for_feature(self, project_config, feature, user_context, ignor feature: Feature for which we are determining if it is enabled or not for the given user. user: user context for user. attributes: Dict representing user attributes. - ignore_user_profile: True if we should bypass the user profile service + options: Decide options. Returns: Decision namedtuple consisting of experiment and variation for the user. @@ -535,15 +531,13 @@ def get_variation_for_feature(self, project_config, feature, user_context, ignor experiment = project_config.get_experiment_from_id(experiment) if experiment: variation, variation_reasons = self.get_variation_from_experiment_rule( - project_config, feature.key, experiment, user_context, ignore_user_profile) + project_config, feature.key, experiment, user_context, options) decide_reasons += variation_reasons if variation: return Decision(experiment, variation, enums.DecisionSources.FEATURE_TEST), decide_reasons # Next check if user is part of a rollout if feature.rolloutId: - return self.get_variation_for_rollout(project_config, feature, user_context, ignore_user_profile) - - # check if not part of rollout - if not feature.rolloutId: + return self.get_variation_for_rollout(project_config, feature, user_context, options) + else: return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons diff --git a/optimizely/event/user_event_factory.py b/optimizely/event/user_event_factory.py index df74b21d..09a6dd23 100644 --- a/optimizely/event/user_event_factory.py +++ b/optimizely/event/user_event_factory.py @@ -43,7 +43,7 @@ def create_impression_event( """ if not activated_experiment and rule_type is not enums.DecisionSources.ROLLOUT: - return None + return None # TODO - we get this when event not sent. Now we allow None for activated_experiment variation, experiment_id = None, None if activated_experiment: @@ -60,11 +60,11 @@ def create_impression_event( return user_event.ImpressionEvent( event_context, user_id, - activated_experiment, + activated_experiment, # TODO - check here also. that exper is used, that is not as None, - or pass empty strings etc event_factory.EventFactory.build_attribute_list(user_attributes, project_config), variation, flag_key, - rule_key, + rule_key, # TODO needs to be ampty string here - in relevnt APIs? or is variation that needs to be ampty string? rule_type, enabled, project_config.get_bot_filtering_value(), diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 6a59cecc..c6bfa05e 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -306,9 +306,6 @@ def _get_feature_variable_for_type( ) if decision.source == enums.DecisionSources.FEATURE_TEST: - - print('DECISION.EXPERIMENT - ', decision.experiment) - source_info = { 'experiment_key': decision.experiment.key, 'variation_key': decision.variation.key, @@ -1046,7 +1043,6 @@ def _decide(self, user_context, key, decide_options=None): decision_source = DecisionSources.ROLLOUT source_info = {} decision_event_dispatched = False - ignore_ups = OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE in decide_options # Check forced decisions first optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(flag_key=key, rule_key=rule_key) @@ -1062,7 +1058,7 @@ def _decide(self, user_context, key, decide_options=None): # Regular decision decision, decision_reasons = self.decision_service.get_variation_for_feature(config, feature_flag, - user_context, ignore_ups) + user_context, decide_options) reasons += decision_reasons @@ -1085,6 +1081,7 @@ def _decide(self, user_context, key, decide_options=None): self._send_impression_event(config, experiment, variation, flag_key, rule_key or '', decision_source, feature_enabled, user_id, attributes) + decision_event_dispatched = True # Generate all variables map if decide options doesn't include excludeVariables diff --git a/similar_rule_keys_audience.json b/similar_rule_keys_audience.json deleted file mode 100644 index af9522d5..00000000 --- a/similar_rule_keys_audience.json +++ /dev/null @@ -1,171 +0,0 @@ -{ - "version": "4", - "rollouts": [ - { - "experiments": [ - { - "status": "Running", - "audienceConditions": ["or", "20406066925"], - "audienceIds": ["20406066925"], - "variations": [ - { - "variables": [], - "id": "8048", - "key": "variation2", - "featureEnabled": true - } - ], - "forcedVariations": {}, - "key": "targeted_delivery", - "layerId": "9300000007573", - "trafficAllocation": [{ "entityId": "8048", "endOfRange": 10000 }], - "id": "9300000007573" - }, - { - "status": "Running", - "audienceConditions": [], - "audienceIds": [], - "variations": [ - { - "variables": [], - "id": "8046", - "key": "off", - "featureEnabled": false - } - ], - "forcedVariations": {}, - "key": "default-rollout-3046-20390585493", - "layerId": "default-layer-rollout-3046-20390585493", - "trafficAllocation": [{ "entityId": "8046", "endOfRange": 10000 }], - "id": "default-rollout-3046-20390585493" - } - ], - "id": "rollout-3046-20390585493" - }, - { - "experiments": [ - { - "status": "Running", - "audienceConditions": ["or", "20415611520"], - "audienceIds": ["20415611520"], - "variations": [ - { - "variables": [], - "id": "8045", - "key": "variation1", - "featureEnabled": true - } - ], - "forcedVariations": {}, - "key": "targeted_delivery", - "layerId": "9300000007569", - "trafficAllocation": [{ "entityId": "8045", "endOfRange": 10000 }], - "id": "9300000007569" - }, - { - "status": "Running", - "audienceConditions": [], - "audienceIds": [], - "variations": [ - { - "variables": [], - "id": "8043", - "key": "off", - "featureEnabled": false - } - ], - "forcedVariations": {}, - "key": "default-rollout-3045-20390585493", - "layerId": "default-layer-rollout-3045-20390585493", - "trafficAllocation": [{ "entityId": "8043", "endOfRange": 10000 }], - "id": "default-rollout-3045-20390585493" - } - ], - "id": "rollout-3045-20390585493" - } - ], - "typedAudiences": [ - { - "id": "20415611520", - "conditions": [ - "and", - [ - "or", - [ - "or", - { - "value": true, - "type": "custom_attribute", - "name": "hiddenLiveEnabled", - "match": "exact" - } - ] - ] - ], - "name": "polina-test1" - }, - { - "id": "20406066925", - "conditions": [ - "and", - [ - "or", - [ - "or", - { - "value": false, - "type": "custom_attribute", - "name": "hiddenLiveEnabled", - "match": "exact" - } - ] - ] - ], - "name": "polina-test2" - } - ], - "anonymizeIP": true, - "projectId": "20430981610", - "variables": [], - "featureFlags": [ - { - "experimentIds": [], - "rolloutId": "rollout-3046-20390585493", - "variables": [], - "id": "3046", - "key": "flag2" - }, - { - "experimentIds": [], - "rolloutId": "rollout-3045-20390585493", - "variables": [], - "id": "3045", - "key": "flag1" - } - ], - "experiments": [], - "audiences": [ - { - "id": "20415611520", - "conditions": "[\"or\", {\"match\": \"exact\", \"name\": \"$opt_dummy_attribute\", \"type\": \"custom_attribute\", \"value\": \"$opt_dummy_value\"}]", - "name": "polina-test1" - }, - { - "id": "20406066925", - "conditions": "[\"or\", {\"match\": \"exact\", \"name\": \"$opt_dummy_attribute\", \"type\": \"custom_attribute\", \"value\": \"$opt_dummy_value\"}]", - "name": "polina-test2" - }, - { - "conditions": "[\"or\", {\"match\": \"exact\", \"name\": \"$opt_dummy_attribute\", \"type\": \"custom_attribute\", \"value\": \"$opt_dummy_value\"}]", - "id": "$opt_dummy_audience", - "name": "Optimizely-Generated Audience for Backwards Compatibility" - } - ], - "groups": [], - "attributes": [{ "id": "20408641883", "key": "hiddenLiveEnabled" }], - "botFiltering": false, - "accountId": "17882702980", - "events": [], - "revision": "25", - "sendFlagDecisions": true -} diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py index 2796a1d4..ffa0ac21 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -964,7 +964,7 @@ def test_get_variation__ignore_user_profile_when_specified(self): self.project_config, experiment, user, - ignore_user_profile=True, + options=['IGNORE_USER_PROFILE_SERVICE'], ) self.assertEqual( entities.Variation("111129", "variation"), @@ -1059,7 +1059,7 @@ def test_get_variation_for_rollout__returns_decision_if_user_in_rollout(self): self.project_config, self.project_config.get_experiment_from_id("211127"), "test_user", - ('test_user', []), + 'test_user', ) def test_get_variation_for_rollout__calls_bucket_with_bucketing_id(self): @@ -1099,7 +1099,7 @@ def test_get_variation_for_rollout__calls_bucket_with_bucketing_id(self): self.project_config, self.project_config.get_experiment_from_id("211127"), "test_user", - ('user_bucket_value', []) + 'user_bucket_value' ) def test_get_variation_for_rollout__skips_to_everyone_else_rule(self): @@ -1245,7 +1245,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_experiment( ) with decision_patch as mock_decision, self.mock_decision_logger: variation_received, _ = self.decision_service.get_variation_for_feature( - self.project_config, feature, user, ignore_user_profile=False + self.project_config, feature, user, options=None ) self.assertEqual( decision_service.Decision( @@ -1260,7 +1260,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_experiment( self.project_config, self.project_config.get_experiment_from_key("test_experiment"), user, - False + None ) def test_get_variation_for_feature__returns_variation_for_feature_in_rollout(self): @@ -1366,7 +1366,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_group(self) return_value=(expected_variation, []), ) as mock_decision: variation_received, _ = self.decision_service.get_variation_for_feature( - self.project_config, feature, user + self.project_config, feature, user, options=None ) self.assertEqual( decision_service.Decision( @@ -1381,7 +1381,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_group(self) self.project_config, self.project_config.get_experiment_from_key("group_exp_1"), user, - False + None ) def test_get_variation_for_feature__returns_none_for_user_not_in_experiment(self): @@ -1407,7 +1407,7 @@ def test_get_variation_for_feature__returns_none_for_user_not_in_experiment(self self.project_config, self.project_config.get_experiment_from_key("test_experiment"), user, - False + None ) def test_get_variation_for_feature__returns_none_for_user_in_group_experiment_not_associated_with_feature( @@ -1553,9 +1553,9 @@ def test_get_variation_for_feature__returns_variation_for_rollout_in_mutex_group variation_received, ) - mock_generate_bucket_value.assert_called_with("('test_user', [])211147") + mock_generate_bucket_value.assert_called_with("test_user211147") mock_config_logging.debug.assert_called_with( - 'Assigned bucket 8000 to user with bucketing ID "(\'test_user\', [])".') + 'Assigned bucket 8000 to user with bucketing ID "test_user".') def test_get_variation_for_feature__returns_variation_for_feature_in_experiment_bucket_less_than_2500( self, @@ -1671,9 +1671,9 @@ def test_get_variation_for_feature__returns_variation_for_rollout_in_experiment_ ), variation_received, ) - mock_generate_bucket_value.assert_called_with("('test_user', [])211147") + mock_generate_bucket_value.assert_called_with("test_user211147") mock_config_logging.debug.assert_called_with( - 'Assigned bucket 8000 to user with bucketing ID "(\'test_user\', [])".') + 'Assigned bucket 8000 to user with bucketing ID "test_user".') def test_get_variation_for_feature__returns_variation_for_rollout_in_mutex_group_audience_mismatch( self, @@ -1705,8 +1705,8 @@ def test_get_variation_for_feature__returns_variation_for_rollout_in_mutex_group ) mock_config_logging.debug.assert_called_with( - 'Assigned bucket 2400 to user with bucketing ID "(\'test_user\', [])".') - mock_generate_bucket_value.assert_called_with("('test_user', [])211147") + 'Assigned bucket 2400 to user with bucketing ID "test_user".') + mock_generate_bucket_value.assert_called_with("test_user211147") def test_get_variation_for_feature_returns_rollout_in_experiment_bucket_range_2500_5000_audience_mismatch( self, @@ -1738,5 +1738,5 @@ def test_get_variation_for_feature_returns_rollout_in_experiment_bucket_range_25 variation_received, ) mock_config_logging.debug.assert_called_with( - 'Assigned bucket 4000 to user with bucketing ID "(\'test_user\', [])".') - mock_generate_bucket_value.assert_called_with("('test_user', [])211147") + 'Assigned bucket 4000 to user with bucketing ID "test_user".') + mock_generate_bucket_value.assert_called_with("test_user211147") diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index 4a419113..cb9a7390 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -462,7 +462,7 @@ def on_activate(event_key, user_id, attributes, event_tags, event): 'ab-test', 'test_user', {}, - {'experiment_key': 'test_experiment', 'variation_key': variation}, + {'experiment_key': 'test_experiment', 'variation_key': variation[0].key}, ), mock.call( enums.NotificationTypes.ACTIVATE, @@ -505,7 +505,7 @@ def on_activate(event_key, user_id, attributes, event_tags, event): 'ab-test', 'test_user', {'test_attribute': 'test_value'}, - {'experiment_key': 'test_experiment', 'variation_key': variation}, + {'experiment_key': 'test_experiment', 'variation_key': variation[0].key}, ), mock.call( enums.NotificationTypes.ACTIVATE, @@ -545,7 +545,7 @@ def test_decision_listener__user_not_in_experiment(self): 'ab-test', 'test_user', {}, - {'experiment_key': 'test_experiment', 'variation_key': (None, [])}, + {'experiment_key': 'test_experiment', 'variation_key': None}, ) def test_track_listener(self): @@ -1781,9 +1781,8 @@ def test_get_variation(self): return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), ), mock.patch('optimizely.notification_center.NotificationCenter.send_notifications') as mock_broadcast: variation = self.optimizely.get_variation('test_experiment', 'test_user') - variation_key = variation[0].key self.assertEqual( - 'variation', variation_key, + 'variation', variation, ) self.assertEqual(mock_broadcast.call_count, 1) @@ -1808,8 +1807,7 @@ def test_get_variation_with_experiment_in_feature(self): return_value=(project_config.get_variation_from_id('test_experiment', '111129'), []), ), mock.patch('optimizely.notification_center.NotificationCenter.send_notifications') as mock_broadcast: variation = opt_obj.get_variation('test_experiment', 'test_user') - variation_key = variation[0].key - self.assertEqual('variation', variation_key) + self.assertEqual('variation', variation) self.assertEqual(mock_broadcast.call_count, 1) @@ -1829,7 +1827,7 @@ def test_get_variation__returns_none(self): 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast: self.assertEqual( - (None, []), + None, self.optimizely.get_variation( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value'}, ), @@ -1842,7 +1840,7 @@ def test_get_variation__returns_none(self): 'ab-test', 'test_user', {'test_attribute': 'test_value'}, - {'experiment_key': 'test_experiment', 'variation_key': (None, [])}, + {'experiment_key': 'test_experiment', 'variation_key': None}, ) def test_get_variation__invalid_object(self): @@ -4899,7 +4897,6 @@ def test_get_variation__forced_bucketing(self): variation_key = self.optimizely.get_variation( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value'} ) - variation_key = variation_key[0].key self.assertEqual('variation', variation_key) def test_get_variation__experiment_not_running__forced_bucketing(self): @@ -4915,7 +4912,7 @@ def test_get_variation__experiment_not_running__forced_bucketing(self): variation_key = self.optimizely.get_variation( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value'}, ) - self.assertIsNone(variation_key[0]) + self.assertIsNone(variation_key) mock_is_experiment_running.assert_called_once_with( self.project_config.get_experiment_from_key('test_experiment') ) @@ -4929,7 +4926,6 @@ def test_get_variation__whitelisted_user_forced_bucketing(self): variation_key = self.optimizely.get_variation( 'group_exp_1', 'user_1', attributes={'test_attribute': 'test_value'} ) - variation_key = variation_key[0].key self.assertEqual('group_exp_1_variation', variation_key) def test_get_variation__user_profile__forced_bucketing(self): @@ -4945,7 +4941,6 @@ def test_get_variation__user_profile__forced_bucketing(self): variation_key = self.optimizely.get_variation( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value'}, ) - variation_key = variation_key[0].key self.assertEqual('variation', variation_key) def test_get_variation__invalid_attributes__forced_bucketing(self): @@ -4958,7 +4953,7 @@ def test_get_variation__invalid_attributes__forced_bucketing(self): variation_key = self.optimizely.get_variation( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value_invalid'}, ) - variation_key = variation_key[0].key + variation_key = variation_key self.assertEqual('variation', variation_key) def test_set_forced_variation__invalid_object(self): diff --git a/tests/test_user_context.py b/tests/test_user_context.py index 0ffefeef..4bd3071a 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -1143,16 +1143,14 @@ def test_decide_reasons__hit_everyone_else_rule__fails_bucketing(self): 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.', + 'Evaluating audiences for rule 1: ["11154"].', 'Audiences for rule 1 collectively evaluated to FALSE.', 'User "test_user" does not meet audience conditions for targeting rule 1.', - 'Evaluating audiences for rule 2: ["11159"].', - 'Audiences for rule 2 collectively evaluated to FALSE.', + 'Evaluating audiences for rule 2: ["11159"].', 'Audiences for rule 2 collectively evaluated to FALSE.', 'User "test_user" does not meet audience conditions for targeting rule 2.', 'Evaluating audiences for rule Everyone Else: [].', 'Audiences for rule Everyone Else collectively evaluated to TRUE.', 'User "test_user" meets audience conditions for targeting rule Everyone Else.', - 'User "test_user" bucketed into a targeting rule Everyone Else.' + 'Bucketed into an empty traffic range. Returning nil.' ] self.assertEqual(expected_reasons, actual.reasons) @@ -1187,13 +1185,16 @@ def test_decide_reasons__hit_rule2__fails_bucketing(self): 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.', + 'Evaluating audiences for rule 1: ["11154"].', 'Audiences for rule 1 collectively evaluated to FALSE.', 'User "test_user" does not meet audience conditions for targeting rule 1.', - 'Evaluating audiences for rule 2: ["11159"].', - 'Audiences for rule 2 collectively evaluated to TRUE.', + 'Evaluating audiences for rule 2: ["11159"].', 'Audiences for rule 2 collectively evaluated to TRUE.', 'User "test_user" meets audience conditions for targeting rule 2.', - 'User "test_user" bucketed into a targeting rule 2.' + 'Bucketed into an empty traffic range. Returning nil.', + 'User "test_user" not bucketed into a targeting rule 2. Checking "Everyone Else" rule now.', + 'Evaluating audiences for rule Everyone Else: [].', + 'Audiences for rule Everyone Else collectively evaluated to TRUE.', + 'User "test_user" meets audience conditions for targeting rule Everyone Else.', + 'Bucketed into an empty traffic range. Returning nil.' ] self.assertEqual(expected_reasons, actual.reasons) @@ -1337,16 +1338,16 @@ def test_forced_decision_return_status__valid_datafile(self): status = user_context.remove_all_forced_decisions() self.assertTrue(status) - def test_decide_return_decision__forced_decision(self): + # TODO - EXAMPLE - THIS TEST IS NOW REFACTORED AND WORKS ----> FIX REMAINING THREE FAILING TESTS JUST LIKE THIS ONE (use flag "test_feature_in_experiment") + def test_should_return_valid_decision_after_setting_and_removing_forced_decision(self): """ - Should return valid forced decision after setting forced decision. + Should return valid forced decision after setting and removing forced decision. """ - opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) project_config = opt_obj.config_manager.get_config() user_context = OptimizelyUserContext(opt_obj, "test_user", {}) - context = OptimizelyUserContext.OptimizelyDecisionContext('test_feature_in_rollout', None) + context = OptimizelyUserContext.OptimizelyDecisionContext('test_feature_in_experiment', None) decision = OptimizelyUserContext.OptimizelyForcedDecision('211129') status = user_context.set_forced_decision(context, decision) @@ -1359,34 +1360,43 @@ def test_decide_return_decision__forced_decision(self): ) as mock_broadcast_decision, mock.patch( 'optimizely.optimizely.Optimizely._send_impression_event' ) as mock_send_event: - decide_decision = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) - self.assertEqual(decide_decision.variation_key, '211129') - self.assertIsNone(decide_decision.rule_key) - self.assertTrue(decide_decision.enabled) - self.assertEqual(decide_decision.flag_key, 'test_feature_in_rollout') + decide_decision = user_context.decide('test_feature_in_experiment', ['INCLUDE_REASONS']) + + self.assertEqual(decide_decision.variation_key, 'control') + self.assertEqual(decide_decision.rule_key, 'test_experiment') + self.assertFalse(decide_decision.enabled) + self.assertEqual(decide_decision.flag_key, 'test_feature_in_experiment') self.assertEqual(decide_decision.user_context.user_id, 'test_user') self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) - self.assertTrue(set(decide_decision.reasons).issuperset(set([ - 'Variation (211129) is mapped to flag (test_feature_in_rollout) and user ' - '(test_user) in the forced decision map.' - ]))) + self.assertEqual(decide_decision.reasons, [ + 'Invalid variation is mapped to flag (test_feature_in_experiment) ' + 'and user (test_user) in the forced decision map.', + '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.']) + expected_variables = { - 'is_running': True, - 'message': 'Hello audience', - 'price': 39.99, - 'count': 399, - 'object': {"field": 12} + '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='211129', - rule_key=None, - enabled=True, + variation_key='control', + rule_key='test_experiment', + enabled=False, variables=expected_variables, - flag_key='test_feature_in_rollout', + flag_key='test_feature_in_experiment', user_context=user_context, - reasons=['Variation (211129) is mapped to flag (test_feature_in_rollout) and ' - 'user (test_user) in the forced decision map.'] + reasons=['Invalid variation is mapped to flag (test_feature_in_experiment) ' + 'and user (test_user) in the forced decision map.', + '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.'] ) # assert notification count @@ -1410,41 +1420,43 @@ def test_decide_return_decision__forced_decision(self): ) expected_experiment = project_config.get_experiment_from_key(expected.rule_key) - expected_var = project_config.get_variation_from_key('211127', expected.variation_key) + expected_var = project_config.get_variation_from_key('test_experiment', expected.variation_key) + mock_send_event.assert_called_with( project_config, expected_experiment, expected_var, expected.flag_key, - '', + 'test_experiment', 'feature-test', expected.enabled, 'test_user', {} ) - self.assertTrue('Variation (211129) is mapped to flag (test_feature_in_rollout) ' - 'and user (test_user) in the forced decision map.' in decide_decision.reasons) + self.assertTrue('User "test_user" is in variation "control" of experiment test_experiment.' + in decide_decision.reasons) status = user_context.remove_forced_decision(context) self.assertTrue(status) - decide_decision = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) - self.assertEqual(decide_decision.variation_key, '211149') - self.assertEqual(decide_decision.rule_key, '211147') - self.assertTrue(decide_decision.enabled) - self.assertEqual(decide_decision.flag_key, 'test_feature_in_rollout') + decide_decision = user_context.decide('test_feature_in_experiment', ['INCLUDE_REASONS']) + + self.assertEqual(decide_decision.variation_key, 'control') + self.assertEqual(decide_decision.rule_key, 'test_experiment') + self.assertFalse(decide_decision.enabled) + self.assertEqual(decide_decision.flag_key, 'test_feature_in_experiment') self.assertEqual(decide_decision.user_context.user_id, 'test_user') self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) - def test_delivery_rule_return_decision__forced_decision(self): + def test_should_return_valid_delivery_rule_decision_after_setting_forced_decision(self): """ Should return valid delivery rule decision after setting forced decision. """ opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) user_context = OptimizelyUserContext(opt_obj, "test_user", {}) - context = OptimizelyUserContext.OptimizelyDecisionContext('test_feature_in_rollout', '211127') + context = OptimizelyUserContext.OptimizelyDecisionContext('test_feature_in_experiment', None) decision = OptimizelyUserContext.OptimizelyForcedDecision('211129') status = user_context.set_forced_decision(context, decision) @@ -1452,43 +1464,36 @@ def test_delivery_rule_return_decision__forced_decision(self): status = user_context.get_forced_decision(context) self.assertEqual(status.variation_key, '211129') - decide_decision = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) - self.assertEqual(decide_decision.variation_key, '211129') - self.assertEqual(decide_decision.rule_key, '211127') - self.assertTrue(decide_decision.enabled) - self.assertEqual(decide_decision.flag_key, 'test_feature_in_rollout') + decide_decision = user_context.decide('test_feature_in_experiment', ['INCLUDE_REASONS']) + self.assertEqual(decide_decision.variation_key, 'control') + self.assertEqual(decide_decision.rule_key, 'test_experiment') + self.assertFalse(decide_decision.enabled) + self.assertEqual(decide_decision.flag_key, 'test_feature_in_experiment') self.assertEqual(decide_decision.user_context.user_id, 'test_user') self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) - self.assertTrue(set(decide_decision.reasons).issuperset(set([ - 'Variation (211129) is mapped to flag (test_feature_in_rollout), ' - 'rule (211127) and user (test_user) in the forced decision map.' - ]))) + self.assertEqual(decide_decision.reasons, [ + 'Invalid variation is mapped to flag (test_feature_in_experiment) and user (test_user) in the ' + 'forced decision map.', '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.']) status = user_context.remove_forced_decision(context) self.assertTrue(status) - decide_decision = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) - self.assertEqual(decide_decision.variation_key, '211149') - self.assertEqual(decide_decision.rule_key, '211147') - self.assertTrue(decide_decision.enabled) - self.assertEqual(decide_decision.flag_key, 'test_feature_in_rollout') + decide_decision = user_context.decide('test_feature_in_experiment', ['INCLUDE_REASONS']) + self.assertEqual(decide_decision.variation_key, 'control') + self.assertEqual(decide_decision.rule_key, 'test_experiment') + self.assertFalse(decide_decision.enabled) + self.assertEqual(decide_decision.flag_key, 'test_feature_in_experiment') self.assertEqual(decide_decision.user_context.user_id, 'test_user') self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) expected_reasons = [ - 'Evaluating audiences for rule 1: ["11154"].', - 'Audiences for rule 1 collectively evaluated to FALSE.', - 'User "test_user" does not meet audience 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 audience conditions for targeting rule 2.', - 'Evaluating audiences for rule Everyone Else: [].', - 'Audiences for rule Everyone Else collectively evaluated to TRUE.', - 'User "test_user" meets audience conditions for targeting rule Everyone Else.', - 'User "test_user" bucketed into a targeting rule Everyone Else.' - ] + '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.'] self.assertEqual(decide_decision.reasons, expected_reasons) - def test_experiment_rule_return_decision__forced_decision(self): + def test_should_return_valid_experiment_decision_after_setting_forced_decision(self): """ Should return valid experiment decision after setting forced decision. """ @@ -1536,7 +1541,7 @@ def test_experiment_rule_return_decision__forced_decision(self): self.assertEqual(decide_decision.reasons, expected_reasons) - def test_invalid_delivery_rule_return_decision__forced_decision(self): + def test_should_return_valid_decision_after_setting_invalid_delivery_rule_variation_in_forced_decision(self): """ Should return valid decision after setting invalid delivery rule variation in forced decision. """ @@ -1552,9 +1557,10 @@ def test_invalid_delivery_rule_return_decision__forced_decision(self): self.assertEqual(status.variation_key, 'invalid') decide_decision = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) - self.assertEqual(decide_decision.variation_key, '211149') - self.assertEqual(decide_decision.rule_key, '211147') - self.assertTrue(decide_decision.enabled) + + self.assertEqual(decide_decision.variation_key, None) + self.assertEqual(decide_decision.rule_key, None) + self.assertFalse(decide_decision.enabled) self.assertEqual(decide_decision.flag_key, 'test_feature_in_rollout') self.assertEqual(decide_decision.user_context.user_id, 'test_user') self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) @@ -1563,7 +1569,8 @@ def test_invalid_delivery_rule_return_decision__forced_decision(self): 'rule (211127) and user (test_user) in the forced decision map.' ]))) - def test_invalid_experiment_rule_return_decision__forced_decision(self): + # TODO - JAE: Can we change the test name and description? Not clear which part is invalid. Also, I see the forced set flag and decide flag is different. Is it intentional? + def test_invalid_experiment_rule_return_decision__forced_decision(self): # TODO - CHECK WITH JAE if this test should return valid decision like docstring says! """ Should return valid decision after setting invalid experiment rule variation in forced decision. @@ -1581,13 +1588,35 @@ def test_invalid_experiment_rule_return_decision__forced_decision(self): self.assertEqual(status.variation_key, 'invalid') decide_decision = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) - self.assertEqual(decide_decision.variation_key, '211149') - self.assertEqual(decide_decision.rule_key, '211147') - self.assertTrue(decide_decision.enabled) + # self.assertEqual(decide_decision.variation_key, '211149') + # self.assertEqual(decide_decision.rule_key, '211147') + # self.assertTrue(decide_decision.enabled) + + # TODO - NEW UPDATED - are they supposed to be None ? + # TODO THIS DECISION IS DISABLED !!!!!! NOT WHAT TEST DOCSTRING SAYS !!! + # - CHECK W JAE IF THIS TEST IS DOING WHAT IS SUPPOSED TO - IF DOCSTRING IS CORRECT + # - SHOULD IT REALLY RETURN A VALID DECISION??? cause we get None and disabled decision. + self.assertEqual(decide_decision.variation_key, None) + self.assertEqual(decide_decision.rule_key, None) + self.assertFalse(decide_decision.enabled) + + self.assertEqual(decide_decision.flag_key, 'test_feature_in_rollout') self.assertEqual(decide_decision.user_context.user_id, 'test_user') self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) + # expected_reasons = [ + # 'Evaluating audiences for rule 1: ["11154"].', 'Audiences for rule 1 collectively evaluated to FALSE.', + # 'User "test_user" does not meet audience 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 audience conditions for targeting rule 2.', + # 'Evaluating audiences for rule Everyone Else: [].', + # 'Audiences for rule Everyone Else collectively evaluated to TRUE.', + # 'User "test_user" meets audience conditions for targeting rule Everyone Else.', + # 'User "test_user" bucketed into a targeting rule Everyone Else.' + # ] + + # TODO - NEW UPDATED REASONS expected_reasons = [ 'Evaluating audiences for rule 1: ["11154"].', 'Audiences for rule 1 collectively evaluated to FALSE.', 'User "test_user" does not meet audience conditions for targeting rule 1.', @@ -1596,8 +1625,7 @@ def test_invalid_experiment_rule_return_decision__forced_decision(self): 'Evaluating audiences for rule Everyone Else: [].', 'Audiences for rule Everyone Else collectively evaluated to TRUE.', 'User "test_user" meets audience conditions for targeting rule Everyone Else.', - 'User "test_user" bucketed into a targeting rule Everyone Else.' - ] + 'Bucketed into an empty traffic range. Returning nil.'] self.assertEqual(decide_decision.reasons, expected_reasons) From 55fe98f1e4f2c5391d446c49cd68e22031c908b9 Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Thu, 18 Nov 2021 19:35:51 -0800 Subject: [PATCH 26/31] address more PR comments --- optimizely/decision_service.py | 44 +++++++++++--------------- optimizely/event/user_event_factory.py | 7 ++-- optimizely/optimizely.py | 1 - optimizely/optimizely_user_context.py | 34 ++++++++++---------- optimizely/project_config.py | 2 +- tests/test_optimizely.py | 2 +- tests/test_user_context.py | 10 ++---- 7 files changed, 44 insertions(+), 56 deletions(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index dfbb28bf..4587f11d 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -354,44 +354,36 @@ def get_variation_for_rollout(self, project_config, feature, user, options): return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons rollout = project_config.get_rollout_from_id(feature.rolloutId) - - if not rollout: - message = 'There is no rollout of feature {}.'.format(feature.key) - self.logger.debug(message) - decide_reasons.append(message) - return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons - rollout_rules = project_config.get_rollout_experiments(rollout) - if not rollout_rules: - message = 'Rollout {} has no experiments.'.format(rollout.id) + if not rollout or not rollout_rules: + message = 'There is no rollout of feature {}.'.format(feature.key) self.logger.debug(message) decide_reasons.append(message) return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons - if rollout and len(rollout_rules) > 0: - index = 0 - while index < len(rollout_rules): - decision_response, reasons_received = self.get_variation_from_delivery_rule(project_config, - feature, - rollout_rules, index, user, - options) + index = 0 + while index < len(rollout_rules): + decision_response, reasons_received = self.get_variation_from_delivery_rule(project_config, + feature, + rollout_rules, index, user, + options) - decide_reasons += reasons_received + decide_reasons += reasons_received - variation, skip_to_everyone_else = decision_response + variation, skip_to_everyone_else = decision_response - if variation: - rule = rollout_rules[index] - feature_decision = Decision(experiment=rule, variation=variation, - source=enums.DecisionSources.ROLLOUT) + if variation: + rule = rollout_rules[index] + feature_decision = Decision(experiment=rule, variation=variation, + source=enums.DecisionSources.ROLLOUT) - return feature_decision, decide_reasons + return feature_decision, decide_reasons - # the last rule is special for "Everyone Else" - index = len(rollout_rules) - 1 if skip_to_everyone_else else index + 1 + # the last rule is special for "Everyone Else" + index = len(rollout_rules) - 1 if skip_to_everyone_else else index + 1 - return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons + return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons def get_variation_from_experiment_rule(self, config, flag_key, rule, user, options): """ Checks for experiment rule if decision is forced and returns it. diff --git a/optimizely/event/user_event_factory.py b/optimizely/event/user_event_factory.py index 09a6dd23..93d67a83 100644 --- a/optimizely/event/user_event_factory.py +++ b/optimizely/event/user_event_factory.py @@ -43,7 +43,7 @@ def create_impression_event( """ if not activated_experiment and rule_type is not enums.DecisionSources.ROLLOUT: - return None # TODO - we get this when event not sent. Now we allow None for activated_experiment + return None variation, experiment_id = None, None if activated_experiment: @@ -51,6 +51,7 @@ def create_impression_event( if variation_id and experiment_id: variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id) + # need this condition when we send events involving forced decisions elif variation_id and flag_key: variation = project_config.get_flag_variation_by_id(flag_key, variation_id) event_context = user_event.EventContext( @@ -60,11 +61,11 @@ def create_impression_event( return user_event.ImpressionEvent( event_context, user_id, - activated_experiment, # TODO - check here also. that exper is used, that is not as None, - or pass empty strings etc + activated_experiment, event_factory.EventFactory.build_attribute_list(user_attributes, project_config), variation, flag_key, - rule_key, # TODO needs to be ampty string here - in relevnt APIs? or is variation that needs to be ampty string? + rule_key, rule_type, enabled, project_config.get_bot_filtering_value(), diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index c6bfa05e..5d819915 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -1048,7 +1048,6 @@ def _decide(self, user_context, key, decide_options=None): optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(flag_key=key, rule_key=rule_key) forced_decision_response = user_context.find_validated_forced_decision(optimizely_decision_context, options=decide_options) - variation, decision_reasons = forced_decision_response reasons += decision_reasons diff --git a/optimizely/optimizely_user_context.py b/optimizely/optimizely_user_context.py index a502db80..1676dfd2 100644 --- a/optimizely/optimizely_user_context.py +++ b/optimizely/optimizely_user_context.py @@ -46,7 +46,7 @@ def __init__(self, optimizely_client, user_id, user_attributes=None): self._user_attributes = user_attributes.copy() if user_attributes else {} self.lock = threading.Lock() - self.forced_decisions = {} + self.forced_decisions_map = {} self.log = logger.SimpleLogger(min_level=enums.LogLevels.INFO) # decision context @@ -73,8 +73,8 @@ def _clone(self): user_context = OptimizelyUserContext(self.client, self.user_id, self.get_user_attributes()) with self.lock: - if self.forced_decisions: - user_context.forced_decisions = copy.deepcopy(self.forced_decisions) + if self.forced_decisions_map: + user_context.forced_decisions_map = copy.deepcopy(self.forced_decisions_map) return user_context @@ -159,12 +159,12 @@ def set_forced_decision(self, decision_context, decision): Returns: True if the forced decision has been set successfully. """ - if not self.client.config_manager.get_config(): + if not self.client or not self.client.config_manager.get_config(): self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return False with self.lock: - self.forced_decisions[decision_context] = decision + self.forced_decisions_map[decision_context] = decision return True @@ -178,7 +178,7 @@ def get_forced_decision(self, decision_context): Returns: A forced_decision or None if forced decisions are not set for the parameters. """ - if not self.client.config_manager.get_config(): + if not self.client or not self.client.config_manager.get_config(): self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return None @@ -196,13 +196,13 @@ def remove_forced_decision(self, decision_context): Returns: Returns: true if the forced decision has been removed successfully. """ - if not self.client.config_manager.get_config(): + if not self.client or not self.client.config_manager.get_config(): self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return False with self.lock: - if decision_context in self.forced_decisions: - del self.forced_decisions[decision_context] + if decision_context in self.forced_decisions_map: + del self.forced_decisions_map[decision_context] return True return False @@ -219,41 +219,41 @@ def remove_all_forced_decisions(self): return False with self.lock: - self.forced_decisions.clear() + self.forced_decisions_map.clear() return True def find_forced_decision(self, decision_context): with self.lock: - if not self.forced_decisions: + if not self.forced_decisions_map: return None # must allow None to be returned for the Flags only case - return self.forced_decisions.get(decision_context) + return self.forced_decisions_map.get(decision_context) def find_validated_forced_decision(self, decision_context, options): reasons = [] - forced_decision_response = self.find_forced_decision(decision_context) + forced_decision = self.find_forced_decision(decision_context) flag_key = decision_context.flag_key rule_key = decision_context.rule_key - if forced_decision_response: - variation = self.client.get_flag_variation_by_key(flag_key, forced_decision_response.variation_key) + if forced_decision: + variation = self.client.get_flag_variation_by_key(flag_key, forced_decision.variation_key) if variation: if rule_key: user_has_forced_decision = enums.ForcedDecisionLogs \ - .USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED.format(forced_decision_response.variation_key, + .USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED.format(forced_decision.variation_key, flag_key, rule_key, self.user_id) else: user_has_forced_decision = enums.ForcedDecisionLogs \ - .USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED.format(forced_decision_response.variation_key, + .USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED.format(forced_decision.variation_key, flag_key, self.user_id) diff --git a/optimizely/project_config.py b/optimizely/project_config.py index c9e4c35a..d9f6ddfa 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -224,7 +224,7 @@ def get_rollout_experiments(self, rollout): """ rollout_experiments_id_map = self._generate_key_map(rollout.experiments, 'id', entities.Experiment) - rollout_experiments = [exper for exper in rollout_experiments_id_map.values()] + rollout_experiments = [experiment for experiment in rollout_experiments_id_map.values()] return rollout_experiments diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index cb9a7390..185f9033 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -5067,5 +5067,5 @@ def test_invalid_flag_key(self): """ Tests invalid flag key in function get_flag_variation_by_key(). """ - # TODO mock function get_flag_variation_by_key? + # TODO mock function get_flag_variation_by_key pass diff --git a/tests/test_user_context.py b/tests/test_user_context.py index 4bd3071a..45d4a2e9 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -1338,7 +1338,6 @@ def test_forced_decision_return_status__valid_datafile(self): status = user_context.remove_all_forced_decisions() self.assertTrue(status) - # TODO - EXAMPLE - THIS TEST IS NOW REFACTORED AND WORKS ----> FIX REMAINING THREE FAILING TESTS JUST LIKE THIS ONE (use flag "test_feature_in_experiment") def test_should_return_valid_decision_after_setting_and_removing_forced_decision(self): """ Should return valid forced decision after setting and removing forced decision. @@ -1592,10 +1591,7 @@ def test_invalid_experiment_rule_return_decision__forced_decision(self): # self.assertEqual(decide_decision.rule_key, '211147') # self.assertTrue(decide_decision.enabled) - # TODO - NEW UPDATED - are they supposed to be None ? - # TODO THIS DECISION IS DISABLED !!!!!! NOT WHAT TEST DOCSTRING SAYS !!! - # - CHECK W JAE IF THIS TEST IS DOING WHAT IS SUPPOSED TO - IF DOCSTRING IS CORRECT - # - SHOULD IT REALLY RETURN A VALID DECISION??? cause we get None and disabled decision. + # TODO - BELOW ARE NEW UPDATED - are they supposed to be None ? self.assertEqual(decide_decision.variation_key, None) self.assertEqual(decide_decision.rule_key, None) self.assertFalse(decide_decision.enabled) @@ -1616,7 +1612,7 @@ def test_invalid_experiment_rule_return_decision__forced_decision(self): # 'User "test_user" bucketed into a targeting rule Everyone Else.' # ] - # TODO - NEW UPDATED REASONS + # TODO - BELOW ARE NEW UPDATED REASONS expected_reasons = [ 'Evaluating audiences for rule 1: ["11154"].', 'Audiences for rule 1 collectively evaluated to FALSE.', 'User "test_user" does not meet audience conditions for targeting rule 1.', @@ -1825,7 +1821,7 @@ def test_forced_decision_clone_return_valid_forced_decision(self): user_context_2 = user_context._clone() self.assertEqual(user_context_2.user_id, 'test_user') self.assertEqual(user_context_2.get_user_attributes(), {}) - self.assertIsNotNone(user_context_2.forced_decisions) + self.assertIsNotNone(user_context_2.forced_decisions_map) self.assertEqual(user_context_2.get_forced_decision(context_with_flag).variation_key, 'v1') self.assertEqual(user_context_2.get_forced_decision(context_with_rule).variation_key, 'v2') From 94a0c26cfb07545f0a2c72325542e1dd08d2121c Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Thu, 18 Nov 2021 19:41:18 -0800 Subject: [PATCH 27/31] use is_valid check on opti client --- optimizely/optimizely_user_context.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/optimizely/optimizely_user_context.py b/optimizely/optimizely_user_context.py index 1676dfd2..fd914c8f 100644 --- a/optimizely/optimizely_user_context.py +++ b/optimizely/optimizely_user_context.py @@ -159,7 +159,7 @@ def set_forced_decision(self, decision_context, decision): Returns: True if the forced decision has been set successfully. """ - if not self.client or not self.client.config_manager.get_config(): + if not self.client.is_valid or not self.client.config_manager.get_config(): self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return False @@ -178,7 +178,7 @@ def get_forced_decision(self, decision_context): Returns: A forced_decision or None if forced decisions are not set for the parameters. """ - if not self.client or not self.client.config_manager.get_config(): + if not self.client.is_valid or not self.client.config_manager.get_config(): self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return None @@ -196,7 +196,7 @@ def remove_forced_decision(self, decision_context): Returns: Returns: true if the forced decision has been removed successfully. """ - if not self.client or not self.client.config_manager.get_config(): + if not self.client.is_valid or not self.client.config_manager.get_config(): self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return False @@ -214,7 +214,7 @@ def remove_all_forced_decisions(self): Returns: True if forced decisions have been removed successfully. """ - if not self.client.config_manager.get_config(): + if not self.client.is_valid or not self.client.config_manager.get_config(): self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY) return False From e5aaccb10efcc7f3cc48a34efcb143daa6fbc1cf Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Fri, 19 Nov 2021 14:21:32 -0800 Subject: [PATCH 28/31] addressed more PR comments --- optimizely/project_config.py | 63 ++++++++++++++++++++++-------------- tests/test_user_context.py | 7 ++-- 2 files changed, 42 insertions(+), 28 deletions(-) diff --git a/optimizely/project_config.py b/optimizely/project_config.py index d9f6ddfa..6d16cbcb 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -124,27 +124,26 @@ def __init__(self, datafile, logger, error_handler): # As we cannot create json variables in datafile directly, here we convert # the variables of string type and json subType to json type # This is needed to fully support json variables - for feature in self.feature_key_map: - for variable in self.feature_key_map[feature].variables: + self.experiment_feature_map = {} + self.flag_rules_map = {} + + for feature_key, feature_value in self.feature_key_map.items(): + for variable in self.feature_key_map[feature_key].variables: sub_type = variable.get('subType', '') if variable['type'] == entities.Variable.Type.STRING and sub_type == entities.Variable.Type.JSON: variable['type'] = entities.Variable.Type.JSON - # Dict containing map of experiment ID to feature ID. - # for checking that experiment is a feature experiment or not. - self.experiment_feature_map = {} - for feature in self.feature_key_map.values(): - feature.variables = self._generate_key_map(feature.variables, 'key', entities.Variable) - for exp_id in feature.experimentIds: + # loop over features=flags already happening + # get feature variables for eacg flag/feature + feature_value.variables = self._generate_key_map(feature_value.variables, 'key', entities.Variable) + for exp_id in feature_value.experimentIds: # Add this experiment in experiment-feature map. - self.experiment_feature_map[exp_id] = [feature.id] + self.experiment_feature_map[exp_id] = [feature_value.id] # all rules(experiment rules and delivery rules) for each flag - self.flag_rules_map = {} for flag in self.feature_flags: - experiments = [] - if not flag['experimentIds'] == '': + if len(flag['experimentIds']) > 0: for exp_id in flag['experimentIds']: experiments.append(self.experiment_id_map[exp_id]) if not flag['rolloutId'] == '': @@ -160,19 +159,7 @@ def __init__(self, datafile, logger, error_handler): # All variations for each flag # Datafile does not contain a separate entity for this. # We collect variations used in each rule (experiment rules and delivery rules) - self.flag_variations_map = {} - - for flag_key, rules in self.flag_rules_map.items(): - variations = [] - for rule in rules: - # get variations as objects (rule.variations gives list) - - variation_objects = self.variation_id_map_by_experiment_id[rule.id].values() - for variation in variation_objects: - if variation.id not in [var.id for var in variations]: - variations.append(variation) - - self.flag_variations_map[flag_key] = variations + self.flag_variations_map = self.get_all_variations_for_each_rule(self.flag_rules_map) @staticmethod def _generate_key_map(entity_list, key, entity_class): @@ -674,3 +661,29 @@ def get_flag_variation_by_id(self, flag_key, variation_id): return variation return None + + def get_all_variations_for_each_rule(self, flag_rules_map): + """ Helper method to get all variation objects from each flag. + collects variations used in each rule (experiment rules and delivery rules). + + Args: + flag_rules_map: A dictionary. A map of all rules per flag. + + Returns: + Map of flag variations. + """ + flag_variations_map = {} + + for flag_key, rules in flag_rules_map.items(): + variations = [] + for rule in rules: + # get variations as objects (rule.variations gives list) + variation_objects = self.variation_id_map_by_experiment_id[rule.id].values() + for variation in variation_objects: + # append variation if it's not already in the list + if variation not in variations: + variations.append(variation) + + flag_variations_map[flag_key] = variations + + return flag_variations_map diff --git a/tests/test_user_context.py b/tests/test_user_context.py index 45d4a2e9..ea1db5d1 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -1568,8 +1568,10 @@ def test_should_return_valid_decision_after_setting_invalid_delivery_rule_variat 'rule (211127) and user (test_user) in the forced decision map.' ]))) - # TODO - JAE: Can we change the test name and description? Not clear which part is invalid. Also, I see the forced set flag and decide flag is different. Is it intentional? - def test_invalid_experiment_rule_return_decision__forced_decision(self): # TODO - CHECK WITH JAE if this test should return valid decision like docstring says! + # TODO - JAE: Can we change the test name and description? Not clear which part is invalid. + # Also, I see the forced set flag and decide flag is different. Is it intentional? + # TODO - CHECK WITH JAE if this test should return valid decision like docstring says! + def test_invalid_experiment_rule_return_decision__forced_decision(self): """ Should return valid decision after setting invalid experiment rule variation in forced decision. @@ -1596,7 +1598,6 @@ def test_invalid_experiment_rule_return_decision__forced_decision(self): self.assertEqual(decide_decision.rule_key, None) self.assertFalse(decide_decision.enabled) - self.assertEqual(decide_decision.flag_key, 'test_feature_in_rollout') self.assertEqual(decide_decision.user_context.user_id, 'test_user') self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) From 44373e9022eaff6380ab5564f5243f74266006c3 Mon Sep 17 00:00:00 2001 From: ozayr-zaviar Date: Mon, 22 Nov 2021 23:50:32 +0500 Subject: [PATCH 29/31] reasons and key name fixed --- tests/test_user_context.py | 36 ++++++++++++++---------------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/tests/test_user_context.py b/tests/test_user_context.py index ea1db5d1..4d601771 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -1571,16 +1571,15 @@ def test_should_return_valid_decision_after_setting_invalid_delivery_rule_variat # TODO - JAE: Can we change the test name and description? Not clear which part is invalid. # Also, I see the forced set flag and decide flag is different. Is it intentional? # TODO - CHECK WITH JAE if this test should return valid decision like docstring says! - def test_invalid_experiment_rule_return_decision__forced_decision(self): + def test_should_return_valid_decision_after_setting_invalid_experiment_rule_variation_in_forced_decision(self): """ - Should return valid decision after setting invalid experiment - rule variation in forced decision. + Should return valid decision after setting invalid experiment rule variation in forced decision. """ opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) user_context = OptimizelyUserContext(opt_obj, "test_user", {}) - context = OptimizelyUserContext.OptimizelyDecisionContext('test_feature_in_experiment_and_rollout', - 'group_exp_2') + context = OptimizelyUserContext.OptimizelyDecisionContext('test_feature_in_experiment', + 'test_experiment') decision = OptimizelyUserContext.OptimizelyForcedDecision('invalid') status = user_context.set_forced_decision(context, decision) @@ -1588,17 +1587,12 @@ def test_invalid_experiment_rule_return_decision__forced_decision(self): status = user_context.get_forced_decision(context) self.assertEqual(status.variation_key, 'invalid') - decide_decision = user_context.decide('test_feature_in_rollout', ['INCLUDE_REASONS']) - # self.assertEqual(decide_decision.variation_key, '211149') - # self.assertEqual(decide_decision.rule_key, '211147') - # self.assertTrue(decide_decision.enabled) - - # TODO - BELOW ARE NEW UPDATED - are they supposed to be None ? - self.assertEqual(decide_decision.variation_key, None) - self.assertEqual(decide_decision.rule_key, None) + decide_decision = user_context.decide('test_feature_in_experiment', ['INCLUDE_REASONS']) + self.assertEqual(decide_decision.variation_key, 'control') + self.assertEqual(decide_decision.rule_key, 'test_experiment') self.assertFalse(decide_decision.enabled) - self.assertEqual(decide_decision.flag_key, 'test_feature_in_rollout') + self.assertEqual(decide_decision.flag_key, 'test_feature_in_experiment') self.assertEqual(decide_decision.user_context.user_id, 'test_user') self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) @@ -1615,14 +1609,12 @@ def test_invalid_experiment_rule_return_decision__forced_decision(self): # TODO - BELOW ARE NEW UPDATED REASONS expected_reasons = [ - 'Evaluating audiences for rule 1: ["11154"].', 'Audiences for rule 1 collectively evaluated to FALSE.', - 'User "test_user" does not meet audience 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 audience conditions for targeting rule 2.', - 'Evaluating audiences for rule Everyone Else: [].', - 'Audiences for rule Everyone Else collectively evaluated to TRUE.', - 'User "test_user" meets audience conditions for targeting rule Everyone Else.', - 'Bucketed into an empty traffic range. Returning nil.'] + 'Invalid variation is mapped to flag (test_feature_in_experiment), rule (test_experiment) ' + 'and user (test_user) in the forced decision map.', + '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.' + ] self.assertEqual(decide_decision.reasons, expected_reasons) From e6c17722cb060216d3985c7cd1ed3550ee605535 Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Mon, 22 Nov 2021 13:18:48 -0800 Subject: [PATCH 30/31] create get_default method for empty experiment object --- optimizely/entities.py | 17 +++++++++++++++++ optimizely/optimizely.py | 12 +----------- tests/test_user_context.py | 15 --------------- 3 files changed, 18 insertions(+), 26 deletions(-) diff --git a/optimizely/entities.py b/optimizely/entities.py index 4960e27e..15576568 100644 --- a/optimizely/entities.py +++ b/optimizely/entities.py @@ -74,6 +74,23 @@ def get_audience_conditions_or_ids(self): def __str__(self): return self.key + @staticmethod + def get_default(): + """ returns an empty experiment object. """ + experiment = Experiment( + id='', + key='', + layerId='', + status='', + variations=[], + trafficAllocation=[], + audienceIds=[], + audienceConditions=[], + forcedVariations={} + ) + + return experiment + class FeatureFlag(BaseEntity): def __init__(self, id, key, experimentIds, rolloutId, variables, groupId=None, **kwargs): diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 5d819915..04b057f7 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -199,17 +199,7 @@ def _send_impression_event(self, project_config, experiment, variation, flag_key attributes: Dict representing user attributes and values which need to be recorded. """ if not experiment: - experiment = entities.Experiment( - id='', - key='', - layerId='', - status='', - variations=[], - trafficAllocation=[], - audienceIds=[], - audienceConditions=[], - forcedVariations={} - ) + experiment = entities.Experiment.get_default() variation_id = variation.id if variation is not None else None user_event = user_event_factory.UserEventFactory.create_impression_event( diff --git a/tests/test_user_context.py b/tests/test_user_context.py index 4d601771..affe1062 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -1568,9 +1568,6 @@ def test_should_return_valid_decision_after_setting_invalid_delivery_rule_variat 'rule (211127) and user (test_user) in the forced decision map.' ]))) - # TODO - JAE: Can we change the test name and description? Not clear which part is invalid. - # Also, I see the forced set flag and decide flag is different. Is it intentional? - # TODO - CHECK WITH JAE if this test should return valid decision like docstring says! def test_should_return_valid_decision_after_setting_invalid_experiment_rule_variation_in_forced_decision(self): """ Should return valid decision after setting invalid experiment rule variation in forced decision. @@ -1596,18 +1593,6 @@ def test_should_return_valid_decision_after_setting_invalid_experiment_rule_vari self.assertEqual(decide_decision.user_context.user_id, 'test_user') self.assertEqual(decide_decision.user_context.get_user_attributes(), {}) - # expected_reasons = [ - # 'Evaluating audiences for rule 1: ["11154"].', 'Audiences for rule 1 collectively evaluated to FALSE.', - # 'User "test_user" does not meet audience 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 audience conditions for targeting rule 2.', - # 'Evaluating audiences for rule Everyone Else: [].', - # 'Audiences for rule Everyone Else collectively evaluated to TRUE.', - # 'User "test_user" meets audience conditions for targeting rule Everyone Else.', - # 'User "test_user" bucketed into a targeting rule Everyone Else.' - # ] - - # TODO - BELOW ARE NEW UPDATED REASONS expected_reasons = [ 'Invalid variation is mapped to flag (test_feature_in_experiment), rule (test_experiment) ' 'and user (test_user) in the forced decision map.', From ab40d9e9a5958ee730d761cb574a864895be6376 Mon Sep 17 00:00:00 2001 From: Matjaz Pirnovar Date: Tue, 23 Nov 2021 17:36:48 -0800 Subject: [PATCH 31/31] fixed further PR comments --- optimizely/decision_service.py | 166 ++++++++++++------------- optimizely/event/user_event_factory.py | 2 +- optimizely/optimizely.py | 31 +---- optimizely/optimizely_user_context.py | 29 ++++- optimizely/project_config.py | 57 +++++---- tests/test_config.py | 2 - tests/test_decision_service.py | 13 +- 7 files changed, 147 insertions(+), 153 deletions(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index 4587f11d..c16a1be2 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -16,12 +16,12 @@ from six import string_types from . import bucketer +from .decision.optimizely_decide_option import OptimizelyDecideOption from .helpers import audience as audience_helper from .helpers import enums from .helpers import experiment as experiment_helper from .helpers import validator from .optimizely_user_context import OptimizelyUserContext -from .decision.optimizely_decide_option import OptimizelyDecideOption from .user_profile import UserProfile Decision = namedtuple('Decision', 'experiment variation source') @@ -44,14 +44,14 @@ def __init__(self, logger, user_profile_service): def _get_bucketing_id(self, user_id, attributes): """ Helper method to determine bucketing ID for the user. - Args: - user_id: ID for user. - attributes: Dict representing user attributes. May consist of bucketing ID to be used. + Args: + user_id: ID for user. + 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 - array of log messages representing decision making. - """ + Returns: + 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) @@ -68,15 +68,15 @@ def _get_bucketing_id(self, user_id, attributes): def set_forced_variation(self, project_config, experiment_key, user_id, variation_key): """ Sets users to a map of experiments to forced variations. - Args: - project_config: Instance of ProjectConfig. - experiment_key: Key for experiment. - user_id: The user ID. - variation_key: Key for variation. If None, then clear the existing experiment-to-variation mapping. + Args: + project_config: Instance of ProjectConfig. + experiment_key: Key for experiment. + user_id: The user ID. + variation_key: Key for variation. If None, then clear the existing experiment-to-variation mapping. - Returns: - A boolean value that indicates if the set completed successfully. - """ + Returns: + A boolean value that indicates if the set completed successfully. + """ experiment = project_config.get_experiment_from_key(experiment_key) if not experiment: # The invalid experiment key will be logged inside this call. @@ -126,15 +126,15 @@ def set_forced_variation(self, project_config, experiment_key, user_id, variatio def get_forced_variation(self, project_config, experiment_key, user_id): """ Gets the forced variation key for the given user and experiment. - Args: - project_config: Instance of ProjectConfig. - experiment_key: Key for experiment. - user_id: The user ID. + Args: + project_config: Instance of ProjectConfig. + experiment_key: Key for experiment. + user_id: The user ID. - Returns: - The variation which the given user and experiment should be forced into and - array of log messages representing decision making. - """ + Returns: + 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: message = 'User "%s" is not in the forced variation map.' % user_id @@ -174,15 +174,15 @@ def get_whitelisted_variation(self, project_config, experiment, user_id): """ Determine if a user is forced into a variation (through whitelisting) for the given experiment and return that variation. - Args: - project_config: Instance of ProjectConfig. - experiment: Object representing the experiment for which user is to be bucketed. - user_id: ID for the user. + Args: + project_config: Instance of ProjectConfig. + experiment: Object representing the experiment for which user is to be bucketed. + user_id: ID for the user. - Returns: - Variation in which the user with ID user_id is forced into. None if no variation and - array of log messages representing decision making. - """ + Returns: + 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 @@ -202,14 +202,14 @@ def get_whitelisted_variation(self, project_config, experiment, user_id): 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. - Args: - project_config: Instance of ProjectConfig. - experiment: Object representing the experiment for which user is to be bucketed. - user_profile: UserProfile object representing the user's profile. + Args: + project_config: Instance of ProjectConfig. + experiment: Object representing the experiment for which user is to be bucketed. + user_profile: UserProfile object representing the user's profile. - Returns: - Variation if available. None otherwise. - """ + Returns: + Variation if available. None otherwise. + """ user_id = user_profile.user_id variation_id = user_profile.get_variation_for_experiment(experiment.id) @@ -228,22 +228,22 @@ def get_stored_variation(self, project_config, experiment, user_profile): def get_variation(self, project_config, experiment, user_context, options=None): """ Top-level function to help determine variation user should be put in. - First, check if experiment is running. - Second, check if user is forced in a variation. - Third, check if there is a stored decision for the user and return the corresponding variation. - Fourth, figure out if user is in the experiment by evaluating audience conditions if any. - Fifth, bucket the user and return the variation. - - Args: - project_config: Instance of ProjectConfig. - experiment: Experiment for which user variation needs to be determined. - user_context: contains user id and attributes - options: Decide options. - - Returns: - 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. - """ + First, check if experiment is running. + Second, check if user is forced in a variation. + Third, check if there is a stored decision for the user and return the corresponding variation. + Fourth, figure out if user is in the experiment by evaluating audience conditions if any. + Fifth, bucket the user and return the variation. + + Args: + project_config: Instance of ProjectConfig. + experiment: Experiment for which user variation needs to be determined. + user_context: contains user id and attributes + options: Decide options. + + Returns: + 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. + """ user_id = user_context.user_id attributes = user_context.get_user_attributes() @@ -333,21 +333,21 @@ def get_variation(self, project_config, experiment, user_context, options=None): decide_reasons.append(message) return None, decide_reasons - def get_variation_for_rollout(self, project_config, feature, user, options): + def get_variation_for_rollout(self, project_config, feature, user): """ Determine which experiment/variation the user is in for a given rollout. Returns the variation of the first experiment the user qualifies for. - Args: - project_config: Instance of ProjectConfig. - flagKey: Feature key. - rollout: Rollout for which we are getting the variation. - user: ID and attributes for user. - options: Decide options. + Args: + project_config: Instance of ProjectConfig. + flagKey: Feature key. + rollout: Rollout for which we are getting the variation. + user: ID and attributes for user. + options: Decide options. - Returns: - Decision namedtuple consisting of experiment and variation for the user and - array of log messages representing decision making. - """ + Returns: + Decision namedtuple consisting of experiment and variation for the user and + array of log messages representing decision making. + """ decide_reasons = [] if not feature or not feature.rolloutId: @@ -366,8 +366,7 @@ def get_variation_for_rollout(self, project_config, feature, user, options): while index < len(rollout_rules): decision_response, reasons_received = self.get_variation_from_delivery_rule(project_config, feature, - rollout_rules, index, user, - options) + rollout_rules, index, user) decide_reasons += reasons_received @@ -406,8 +405,7 @@ def get_variation_from_experiment_rule(self, config, flag_key, rule, user, optio optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(flag_key, rule.key) forced_decision_variation, reasons_received = user.find_validated_forced_decision( - optimizely_decision_context, - options) + optimizely_decision_context) decide_reasons += reasons_received if forced_decision_variation: @@ -418,7 +416,7 @@ def get_variation_from_experiment_rule(self, config, flag_key, rule, user, optio decide_reasons += variation_reasons return decision_variation, decide_reasons - def get_variation_from_delivery_rule(self, config, feature, rules, rule_index, user, options): + def get_variation_from_delivery_rule(self, config, feature, rules, rule_index, user): """ Checks for delivery rule if decision is forced and returns it. Otherwise returns a regular decision. @@ -428,7 +426,6 @@ def get_variation_from_delivery_rule(self, config, feature, rules, rule_index, u rules: Experiment rule. rule_index: integer index of the rule in the list. user: ID and attributes for user. - options: Decide options. Returns: If forced decision, it returns namedtuple consisting of forced_decision_variation and skip_to_everyone_else @@ -444,8 +441,7 @@ def get_variation_from_delivery_rule(self, config, feature, rules, rule_index, u # check forced decision first rule = rules[rule_index] optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(feature.key, rule.key) - forced_decision_variation, reasons_received = user.find_validated_forced_decision(optimizely_decision_context, - options) + forced_decision_variation, reasons_received = user.find_validated_forced_decision(optimizely_decision_context) decide_reasons += reasons_received @@ -504,15 +500,15 @@ def get_variation_from_delivery_rule(self, config, feature, rules, rule_index, u def get_variation_for_feature(self, project_config, feature, user_context, options=None): """ Returns the experiment/variation the user is bucketed in for the given feature. - Args: - project_config: Instance of ProjectConfig. - feature: Feature for which we are determining if it is enabled or not for the given user. - user: user context for user. - attributes: Dict representing user attributes. - options: Decide options. + Args: + project_config: Instance of ProjectConfig. + feature: Feature for which we are determining if it is enabled or not for the given user. + user: user context for user. + attributes: Dict representing user attributes. + options: Decide options. - Returns: - Decision namedtuple consisting of experiment and variation for the user. + Returns: + Decision namedtuple consisting of experiment and variation for the user. """ decide_reasons = [] @@ -528,8 +524,4 @@ def get_variation_for_feature(self, project_config, feature, user_context, optio if variation: return Decision(experiment, variation, enums.DecisionSources.FEATURE_TEST), decide_reasons - # Next check if user is part of a rollout - if feature.rolloutId: - return self.get_variation_for_rollout(project_config, feature, user_context, options) - else: - return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons + return self.get_variation_for_rollout(project_config, feature, user_context) diff --git a/optimizely/event/user_event_factory.py b/optimizely/event/user_event_factory.py index 93d67a83..38217883 100644 --- a/optimizely/event/user_event_factory.py +++ b/optimizely/event/user_event_factory.py @@ -53,7 +53,7 @@ def create_impression_event( variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id) # need this condition when we send events involving forced decisions elif variation_id and flag_key: - variation = project_config.get_flag_variation_by_id(flag_key, variation_id) + variation = project_config.get_flag_variation(flag_key, 'id', variation_id) event_context = user_event.EventContext( project_config.account_id, project_config.project_id, project_config.revision, project_config.anonymize_ip, ) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 04b057f7..3bbb7e76 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -1036,8 +1036,7 @@ def _decide(self, user_context, key, decide_options=None): # Check forced decisions first optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(flag_key=key, rule_key=rule_key) - forced_decision_response = user_context.find_validated_forced_decision(optimizely_decision_context, - options=decide_options) + forced_decision_response = user_context.find_validated_forced_decision(optimizely_decision_context) variation, decision_reasons = forced_decision_response reasons += decision_reasons @@ -1184,31 +1183,3 @@ def _decide_for_keys(self, user_context, keys, decide_options=None): continue decisions[key] = decision return decisions - - def get_flag_variation_by_key(self, flag_key, variation_key): - """ - Gets variation by key. - variation_key can be a string or in case of forced decisions, it can be an object. - - Args: - flag_key: flag key - variation_key: variation key - - Returns: - Variation as a map. - """ - config = self.config_manager.get_config() - - if not config: - return None - - if not flag_key: - return None - - variations = config.flag_variations_map.get(flag_key) - - for variation in variations: - if variation.key == variation_key: - return variation - - return None diff --git a/optimizely/optimizely_user_context.py b/optimizely/optimizely_user_context.py index fd914c8f..927dd68f 100644 --- a/optimizely/optimizely_user_context.py +++ b/optimizely/optimizely_user_context.py @@ -51,6 +51,10 @@ def __init__(self, optimizely_client, user_id, user_attributes=None): # decision context class OptimizelyDecisionContext(object): + """ Using class with attributes here instead of namedtuple because + class is extensible, it's easy to add another attribute if we wanted + to extend decision context. + """ def __init__(self, flag_key, rule_key=None): self.flag_key = flag_key self.rule_key = rule_key @@ -184,7 +188,7 @@ def get_forced_decision(self, decision_context): forced_decision = self.find_forced_decision(decision_context) - return forced_decision if forced_decision else None + return forced_decision def remove_forced_decision(self, decision_context): """ @@ -224,7 +228,15 @@ def remove_all_forced_decisions(self): return True def find_forced_decision(self, decision_context): + """ + Gets forced decision from forced decision map. + + Args: + decision_context: a decision context. + Returns: + Forced decision. + """ with self.lock: if not self.forced_decisions_map: return None @@ -232,8 +244,16 @@ def find_forced_decision(self, decision_context): # must allow None to be returned for the Flags only case return self.forced_decisions_map.get(decision_context) - def find_validated_forced_decision(self, decision_context, options): + def find_validated_forced_decision(self, decision_context): + """ + Gets forced decisions based on flag key, rule key and variation. + + Args: + decision context: a decision context + Returns: + Variation of the forced decision. + """ reasons = [] forced_decision = self.find_forced_decision(decision_context) @@ -242,7 +262,10 @@ def find_validated_forced_decision(self, decision_context, options): rule_key = decision_context.rule_key if forced_decision: - variation = self.client.get_flag_variation_by_key(flag_key, forced_decision.variation_key) + # we use config here so we can use get_flag_variation() function which is defined in project_config + # otherwise we would us self.client instead of config + config = self.client.config_manager.get_config() if self.client else None + variation = config.get_flag_variation(flag_key, 'key', forced_decision.variation_key) if variation: if rule_key: user_has_forced_decision = enums.ForcedDecisionLogs \ diff --git a/optimizely/project_config.py b/optimizely/project_config.py index 6d16cbcb..2ba5c0f5 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -639,29 +639,6 @@ def get_variation_from_key_by_experiment_id(self, experiment_id, variation_key): return {} - def get_flag_variation_by_id(self, flag_key, variation_id): - """ - Gets variation by id. - variation_id can be a string or in case of forced decisions, it can be an object. - - Args: - flag_key: flag key - variation_key: variation id - - Returns: - Variation as a map. - """ - - if not flag_key: - return None - - variations = self.flag_variations_map.get(flag_key) - for variation in variations: - if variation.id == variation_id: - return variation - - return None - def get_all_variations_for_each_rule(self, flag_rules_map): """ Helper method to get all variation objects from each flag. collects variations used in each rule (experiment rules and delivery rules). @@ -687,3 +664,37 @@ def get_all_variations_for_each_rule(self, flag_rules_map): flag_variations_map[flag_key] = variations return flag_variations_map + + def get_flag_variation(self, flag_key, variation_attribute, target_value): + """ + Gets variation by specified variation attribute. + For example if variation_attribute is id, the function gets variation by using variation_id. + If object_attribute is key, the function gets variation by using variation_key. + + We used to have two separate functions: + get_flag_variation_by_id() + get_flag_variation_by_key() + + This function consolidates both functions into one. + + Important to always relate object_attribute to the target value. + Should never enter for example object_attribute=key and target_value=variation_id. + Correct is object_attribute=key and target_value=variation_key. + + Args: + flag_key: flag key + variation_attribute: id or key for example. The part after the dot notation (id in variation.id) + target_value: target value we want to get for example variation_id or variation_key + + Returns: + Variation as a map. + """ + if not flag_key: + return None + + variations = self.flag_variations_map.get(flag_key) + for variation in variations: + if getattr(variation, variation_attribute) == target_value: + return variation + + return None diff --git a/tests/test_config.py b/tests/test_config.py index e2b52faa..96450368 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -12,7 +12,6 @@ # limitations under the License. import json - import mock from optimizely import entities @@ -610,7 +609,6 @@ def test_get_bot_filtering(self): # Assert bot filtering is retrieved as provided in the data file opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) project_config = opt_obj.config_manager.get_config() - self.assertEqual( self.config_dict_with_features['botFiltering'], project_config.get_bot_filtering_value(), ) diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py index ffa0ac21..1d592d20 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -1012,7 +1012,7 @@ def test_get_variation_for_rollout__returns_none_if_no_experiments(self): with self.mock_config_logger as mock_logging: feature = None variation_received, _ = self.decision_service.get_variation_for_rollout( - self.project_config, feature, user, None + self.project_config, feature, user ) self.assertEqual( @@ -1038,7 +1038,7 @@ def test_get_variation_for_rollout__returns_decision_if_user_in_rollout(self): 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, feature, user, None + self.project_config, feature, user ) self.assertEqual( decision_service.Decision( @@ -1078,8 +1078,7 @@ def test_get_variation_for_rollout__calls_bucket_with_bucketing_id(self): variation_received, _ = self.decision_service.get_variation_for_rollout( self.project_config, feature, - user, - None + user ) self.assertEqual( decision_service.Decision( @@ -1120,7 +1119,7 @@ def test_get_variation_for_rollout__skips_to_everyone_else_rule(self): "optimizely.bucketer.Bucketer.bucket", side_effect=[[None, []], [variation_to_mock, []]] ): variation_received, _ = self.decision_service.get_variation_for_rollout( - self.project_config, feature, user, None + self.project_config, feature, user ) self.assertEqual( decision_service.Decision( @@ -1173,7 +1172,7 @@ def test_get_variation_for_rollout__returns_none_for_user_not_in_rollout(self): "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, feature, user, None + self.project_config, feature, user ) self.assertEqual( decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), @@ -1289,7 +1288,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_rollout(sel ) mock_get_variation_for_rollout.assert_called_once_with( - self.project_config, feature, user, False + self.project_config, feature, user ) # Assert no log messages were generated