diff --git a/optimizely/bucketer.py b/optimizely/bucketer.py index 38da3798e..e4ae04efe 100644 --- a/optimizely/bucketer.py +++ b/optimizely/bucketer.py @@ -119,6 +119,34 @@ def bucket( and array of log messages representing decision making. */. """ + variation_id, decide_reasons = self.bucket_to_entity_id(project_config, experiment, user_id, bucketing_id) + if variation_id: + variation = project_config.get_variation_from_id_by_experiment_id(experiment.id, variation_id) + return variation, decide_reasons + + elif not decide_reasons: + message = 'Bucketed into an empty traffic range. Returning nil.' + project_config.logger.info(message) + decide_reasons.append(message) + + return None, decide_reasons + + def bucket_to_entity_id( + self, project_config: ProjectConfig, + experiment: Experiment, user_id: str, bucketing_id: str + ) -> tuple[Optional[str], list[str]]: + """ + For a given experiment and bucketing ID determines variation ID to be shown to user. + + Args: + project_config: Instance of ProjectConfig. + experiment: The experiment object (used for group/groupPolicy logic if needed). + user_id: The user ID string. + bucketing_id: The bucketing ID string for the user. + + Returns: + Tuple of (entity_id or None, list of decide reasons). + """ decide_reasons: list[str] = [] if not experiment: return None, decide_reasons @@ -151,16 +179,16 @@ def bucket( project_config.logger.info(message) decide_reasons.append(message) + traffic_allocations: list[TrafficAllocation] = experiment.trafficAllocation + if experiment.cmab: + traffic_allocations = [ + { + "entityId": "$", + "endOfRange": experiment.cmab['trafficAllocation'] + } + ] # Bucket user if not in white-list and in group (if any) variation_id = self.find_bucket(project_config, bucketing_id, - experiment.id, experiment.trafficAllocation) - if variation_id: - variation = project_config.get_variation_from_id_by_experiment_id(experiment.id, variation_id) - return variation, decide_reasons - - else: - message = 'Bucketed into an empty traffic range. Returning nil.' - project_config.logger.info(message) - decide_reasons.append(message) + experiment.id, traffic_allocations) - return None, decide_reasons + return variation_id, decide_reasons diff --git a/optimizely/cmab/__init__.py b/optimizely/cmab/__init__.py new file mode 100644 index 000000000..2a6fc86c5 --- /dev/null +++ b/optimizely/cmab/__init__.py @@ -0,0 +1,12 @@ +# Copyright 2025, Optimizely +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/optimizely/cmab/cmab_service.py b/optimizely/cmab/cmab_service.py index 418280b86..a7c4b69bc 100644 --- a/optimizely/cmab/cmab_service.py +++ b/optimizely/cmab/cmab_service.py @@ -35,6 +35,18 @@ class CmabCacheValue(TypedDict): class DefaultCmabService: + """ + DefaultCmabService handles decisioning for Contextual Multi-Armed Bandit (CMAB) experiments, + including caching and filtering user attributes for efficient decision retrieval. + + Attributes: + cmab_cache: LRUCache for user CMAB decisions. + cmab_client: Client to fetch decisions from the CMAB backend. + logger: Optional logger. + + Methods: + get_decision: Retrieves a CMAB decision with caching and attribute filtering. + """ def __init__(self, cmab_cache: LRUCache[str, CmabCacheValue], cmab_client: DefaultCmabClient, logger: Optional[_logging.Logger] = None): self.cmab_cache = cmab_cache diff --git a/optimizely/decision/optimizely_decision.py b/optimizely/decision/optimizely_decision.py index 7ae3f1366..cca770662 100644 --- a/optimizely/decision/optimizely_decision.py +++ b/optimizely/decision/optimizely_decision.py @@ -48,3 +48,23 @@ def as_json(self) -> dict[str, Any]: 'user_context': self.user_context.as_json() if self.user_context else None, 'reasons': self.reasons } + + @classmethod + def new_error_decision(cls, key: str, user: OptimizelyUserContext, reasons: list[str]) -> OptimizelyDecision: + """Create a new OptimizelyDecision representing an error state. + Args: + key: The flag key + user: The user context + reasons: List of reasons explaining the error + Returns: + OptimizelyDecision with error state values + """ + return cls( + variation_key=None, + enabled=False, + variables={}, + rule_key=None, + flag_key=key, + user_context=user, + reasons=[reasons[-1]] if reasons else [] + ) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index df85464e6..df9967e80 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -12,7 +12,7 @@ # limitations under the License. from __future__ import annotations -from typing import TYPE_CHECKING, NamedTuple, Optional, Sequence +from typing import TYPE_CHECKING, NamedTuple, Optional, Sequence, List, TypedDict from . import bucketer from . import entities @@ -23,6 +23,8 @@ from .helpers import validator from .optimizely_user_context import OptimizelyUserContext, UserAttributes from .user_profile import UserProfile, UserProfileService, UserProfileTracker +from .cmab.cmab_service import DefaultCmabService, CmabDecision +from optimizely.helpers.enums import Errors if TYPE_CHECKING: # prevent circular dependenacy by skipping import at runtime @@ -30,21 +32,71 @@ from .logger import Logger +class CmabDecisionResult(TypedDict): + """ + TypedDict representing the result of a CMAB (Contextual Multi-Armed Bandit) decision. + + Attributes: + error (bool): Indicates whether an error occurred during the decision process. + result (Optional[CmabDecision]): Resulting CmabDecision object if the decision was successful, otherwise None. + reasons (List[str]): A list of reasons or messages explaining the outcome or any errors encountered. + """ + error: bool + result: Optional[CmabDecision] + reasons: List[str] + + +class VariationResult(TypedDict): + """ + TypedDict representing the result of a variation decision process. + + Attributes: + cmab_uuid (Optional[str]): The unique identifier for the CMAB experiment, if applicable. + error (bool): Indicates whether an error occurred during the decision process. + reasons (List[str]): A list of reasons explaining the outcome or any errors encountered. + variation (Optional[entities.Variation]): The selected variation entity, or None if no variation was assigned. + """ + cmab_uuid: Optional[str] + error: bool + reasons: List[str] + variation: Optional[entities.Variation] + + +class DecisionResult(TypedDict): + """ + A TypedDict representing the result of a decision process. + + Attributes: + decision (Decision): The decision object containing the outcome of the evaluation. + error (bool): Indicates whether an error occurred during the decision process. + reasons (List[str]): A list of reasons explaining the decision or any errors encountered. + """ + decision: Decision + error: bool + reasons: List[str] + + class Decision(NamedTuple): - """Named tuple containing selected experiment, variation and source. + """Named tuple containing selected experiment, variation, source and cmab_uuid. None if no experiment/variation was selected.""" experiment: Optional[entities.Experiment] variation: Optional[entities.Variation] source: Optional[str] + cmab_uuid: Optional[str] class DecisionService: """ Class encapsulating all decision related capabilities. """ - def __init__(self, logger: Logger, user_profile_service: Optional[UserProfileService]): + def __init__(self, + logger: Logger, + user_profile_service: Optional[UserProfileService], + cmab_service: DefaultCmabService): self.bucketer = bucketer.Bucketer() self.logger = logger self.user_profile_service = user_profile_service + self.cmab_service = cmab_service + self.cmab_uuid = None # Map of user IDs to another map of experiments to variations. # This contains all the forced variations set by the user @@ -76,6 +128,71 @@ def _get_bucketing_id(self, user_id: str, attributes: Optional[UserAttributes]) return user_id, decide_reasons + def _get_decision_for_cmab_experiment( + self, + project_config: ProjectConfig, + experiment: entities.Experiment, + user_context: OptimizelyUserContext, + bucketing_id: str, + options: Optional[Sequence[str]] = None + ) -> CmabDecisionResult: + """ + Retrieves a decision for a contextual multi-armed bandit (CMAB) experiment. + + Args: + project_config: Instance of ProjectConfig. + experiment: The experiment object for which the decision is to be made. + user_context: The user context containing user id and attributes. + bucketing_id: The bucketing ID to use for traffic allocation. + options: Optional sequence of decide options. + + Returns: + A dictionary containing: + - "error": Boolean indicating if there was an error. + - "result": The CmabDecision result or None if error. + - "reasons": List of strings with reasons or error messages. + """ + decide_reasons: list[str] = [] + user_id = user_context.user_id + + # Check if user is in CMAB traffic allocation + bucketed_entity_id, bucket_reasons = self.bucketer.bucket_to_entity_id( + project_config, experiment, user_id, bucketing_id + ) + decide_reasons.extend(bucket_reasons) + + if not bucketed_entity_id: + message = f'User "{user_context.user_id}" not in CMAB experiment ' \ + f'"{experiment.key}" due to traffic allocation.' + self.logger.info(message) + decide_reasons.append(message) + return { + "error": False, + "result": None, + "reasons": decide_reasons, + } + + # User is in CMAB allocation, proceed to CMAB decision + try: + options_list = list(options) if options is not None else [] + cmab_decision = self.cmab_service.get_decision( + project_config, user_context, experiment.id, options_list + ) + return { + "error": False, + "result": cmab_decision, + "reasons": decide_reasons, + } + except Exception as e: + error_message = Errors.CMAB_FETCH_FAILED_DETAILED.format(experiment.key) + if self.logger: + self.logger.error(f'{error_message} {str(e)}') + return { + "error": True, + "result": None, + "reasons": [error_message], + } + def set_forced_variation( self, project_config: ProjectConfig, experiment_key: str, user_id: str, variation_key: Optional[str] @@ -250,29 +367,38 @@ def get_variation( user_profile_tracker: Optional[UserProfileTracker], reasons: list[str] = [], options: Optional[Sequence[str]] = None - ) -> tuple[Optional[entities.Variation], list[str]]: - """ 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. + ) -> VariationResult: + """ + Determines the variation a user should be assigned to for a given experiment. + + The decision process is as follows: + 1. Check if the experiment is running. + 2. Check if the user is forced into a variation via the forced variation map. + 3. Check if the user is whitelisted into a variation for the experiment. + 4. If user profile tracking is enabled and not ignored, check for a stored variation. + 5. Evaluate audience conditions to determine if the user qualifies for the experiment. + 6. For CMAB experiments: + a. Check if the user is in the CMAB traffic allocation. + b. If so, fetch the CMAB decision and assign the corresponding variation and cmab_uuid. + 7. For non-CMAB experiments, bucket the user into a variation. + 8. If a variation is assigned, optionally update the user profile. Args: - project_config: Instance of ProjectConfig. - experiment: Experiment for which user variation needs to be determined. - user_context: contains user id and attributes. - user_profile_tracker: tracker for reading and updating user profile of the user. - reasons: Decision reasons. - options: Decide options. + project_config: Instance of ProjectConfig. + experiment: Experiment for which the user's variation needs to be determined. + user_context: Contains user id and attributes. + user_profile_tracker: Tracker for reading and updating the user's profile. + reasons: List of decision reasons. + 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. + A VariationResult dictionary with: + - 'variation': The assigned Variation (or None if not assigned). + - 'reasons': A list of log messages representing decision making. + - 'cmab_uuid': The cmab_uuid if the experiment is a CMAB experiment, otherwise None. + - 'error': Boolean indicating if an error occurred during the decision process. """ user_id = user_context.user_id - if options: ignore_user_profile = OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE in options else: @@ -286,20 +412,35 @@ def get_variation( message = f'Experiment "{experiment.key}" is not running.' self.logger.info(message) decide_reasons.append(message) - return None, decide_reasons + return { + 'cmab_uuid': None, + 'error': False, + 'reasons': decide_reasons, + 'variation': None + } # Check if the user is forced into a variation variation: Optional[entities.Variation] variation, reasons_received = self.get_forced_variation(project_config, experiment.key, user_id) decide_reasons += reasons_received if variation: - return variation, decide_reasons + return { + 'cmab_uuid': None, + 'error': False, + 'reasons': decide_reasons, + 'variation': variation + } # Check to see if user is white-listed for a certain variation variation, reasons_received = self.get_whitelisted_variation(project_config, experiment, user_id) decide_reasons += reasons_received if variation: - return variation, decide_reasons + return { + 'cmab_uuid': None, + 'error': False, + 'reasons': decide_reasons, + 'variation': variation + } # Check to see if user has a decision available for the given experiment if user_profile_tracker is not None and not ignore_user_profile: @@ -309,11 +450,16 @@ def get_variation( f'"{experiment}" for user "{user_id}" from user profile.' self.logger.info(message) decide_reasons.append(message) - return variation, decide_reasons + return { + 'cmab_uuid': None, + 'error': False, + 'reasons': decide_reasons, + 'variation': variation + } else: self.logger.warning('User profile has invalid format.') - # Bucket user and store the new decision + # Check audience conditions audience_conditions = experiment.get_audience_conditions_or_ids() user_meets_audience_conditions, reasons_received = audience_helper.does_user_meet_audience_conditions( project_config, audience_conditions, @@ -325,13 +471,45 @@ def get_variation( message = f'User "{user_id}" does not meet conditions to be in experiment "{experiment.key}".' self.logger.info(message) decide_reasons.append(message) - return None, decide_reasons + return { + 'cmab_uuid': None, + 'error': False, + 'reasons': decide_reasons, + 'variation': None + } # Determine bucketing ID to be used bucketing_id, bucketing_id_reasons = self._get_bucketing_id(user_id, user_context.get_user_attributes()) decide_reasons += bucketing_id_reasons - variation, bucket_reasons = self.bucketer.bucket(project_config, experiment, user_id, bucketing_id) - decide_reasons += bucket_reasons + cmab_uuid = None + + # Check if this is a CMAB experiment + # If so, handle CMAB-specific traffic allocation and decision logic. + # Otherwise, proceed with standard bucketing logic for non-CMAB experiments. + if experiment.cmab: + cmab_decision_result = self._get_decision_for_cmab_experiment(project_config, + experiment, + user_context, + bucketing_id, + options) + decide_reasons += cmab_decision_result.get('reasons', []) + cmab_decision = cmab_decision_result.get('result') + if cmab_decision_result['error']: + return { + 'cmab_uuid': None, + 'error': True, + 'reasons': decide_reasons, + 'variation': None + } + variation_id = cmab_decision['variation_id'] if cmab_decision else None + cmab_uuid = cmab_decision['cmab_uuid'] if cmab_decision else None + variation = project_config.get_variation_from_id(experiment_key=experiment.key, + variation_id=variation_id) if variation_id else None + else: + # Bucket the user + variation, bucket_reasons = self.bucketer.bucket(project_config, experiment, user_id, bucketing_id) + decide_reasons += bucket_reasons + if isinstance(variation, entities.Variation): message = f'User "{user_id}" is in variation "{variation.key}" of experiment {experiment.key}.' self.logger.info(message) @@ -342,11 +520,21 @@ def get_variation( user_profile_tracker.update_user_profile(experiment, variation) except: self.logger.exception(f'Unable to save user profile for user "{user_id}".') - return variation, decide_reasons + return { + 'cmab_uuid': cmab_uuid, + 'error': False, + 'reasons': decide_reasons, + 'variation': variation + } message = f'User "{user_id}" is in no variation.' self.logger.info(message) decide_reasons.append(message) - return None, decide_reasons + return { + 'cmab_uuid': None, + 'error': False, + 'reasons': decide_reasons, + 'variation': None + } def get_variation_for_rollout( self, project_config: ProjectConfig, feature: entities.FeatureFlag, user_context: OptimizelyUserContext @@ -370,7 +558,7 @@ def get_variation_for_rollout( attributes = user_context.get_user_attributes() if not feature or not feature.rolloutId: - return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons + return Decision(None, None, enums.DecisionSources.ROLLOUT, None), decide_reasons rollout = project_config.get_rollout_from_id(feature.rolloutId) @@ -378,7 +566,7 @@ def get_variation_for_rollout( message = f'There is no rollout of feature {feature.key}.' self.logger.debug(message) decide_reasons.append(message) - return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons + return Decision(None, None, enums.DecisionSources.ROLLOUT, None), decide_reasons rollout_rules = project_config.get_rollout_experiments(rollout) @@ -386,7 +574,7 @@ def get_variation_for_rollout( message = f'Rollout {rollout.id} has no experiments.' self.logger.debug(message) decide_reasons.append(message) - return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons + return Decision(None, None, enums.DecisionSources.ROLLOUT, None), decide_reasons index = 0 while index < len(rollout_rules): @@ -401,7 +589,7 @@ def get_variation_for_rollout( if forced_decision_variation: return Decision(experiment=rule, variation=forced_decision_variation, - source=enums.DecisionSources.ROLLOUT), decide_reasons + source=enums.DecisionSources.ROLLOUT, cmab_uuid=None), decide_reasons bucketing_id, bucket_reasons = self._get_bucketing_id(user_id, attributes) decide_reasons += bucket_reasons @@ -435,7 +623,7 @@ def get_variation_for_rollout( self.logger.debug(message) decide_reasons.append(message) return Decision(experiment=rule, variation=bucketed_variation, - source=enums.DecisionSources.ROLLOUT), decide_reasons + source=enums.DecisionSources.ROLLOUT, cmab_uuid=None), decide_reasons elif not everyone_else: # skip this logging for EveryoneElse since this has a message not for everyone_else @@ -455,7 +643,7 @@ def get_variation_for_rollout( # 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, None), decide_reasons def get_variation_for_feature( self, @@ -463,7 +651,7 @@ def get_variation_for_feature( feature: entities.FeatureFlag, user_context: OptimizelyUserContext, options: Optional[list[str]] = None - ) -> tuple[Decision, list[str]]: + ) -> DecisionResult: """ Returns the experiment/variation the user is bucketed in for the given feature. Args: @@ -473,8 +661,11 @@ def get_variation_for_feature( options: Decide options. Returns: - Decision namedtuple consisting of experiment and variation for the user. - """ + A DecisionResult dictionary containing: + - 'decision': Decision namedtuple with experiment, variation, source, and cmab_uuid. + - 'error': Boolean indicating if an error occurred during the decision process. + - 'reasons': List of log messages representing decision making for the feature. + """ return self.get_variations_for_feature_list(project_config, [feature], user_context, options)[0] def validated_forced_decision( @@ -546,17 +737,21 @@ def get_variations_for_feature_list( features: list[entities.FeatureFlag], user_context: OptimizelyUserContext, options: Optional[Sequence[str]] = None - ) -> list[tuple[Decision, list[str]]]: + ) -> list[DecisionResult]: """ Returns the list of experiment/variation the user is bucketed in for the given list of features. + Args: - project_config: Instance of ProjectConfig. - features: List of features for which we are determining if it is enabled or not for the given user. - user_context: user context for user. - options: Decide options. + project_config: Instance of ProjectConfig. + features: List of features for which we are determining if it is enabled or not for the given user. + user_context: user context for user. + options: Decide options. Returns: - List of Decision namedtuple consisting of experiment and variation for the user. + A list of DecisionResult dictionaries, each containing: + - 'decision': Decision namedtuple with experiment, variation, source, and cmab_uuid. + - 'error': Boolean indicating if an error occurred during the decision process. + - 'reasons': List of log messages representing decision making for each feature. """ decide_reasons: list[str] = [] @@ -591,23 +786,46 @@ def get_variations_for_feature_list( if forced_decision_variation: decision_variation = forced_decision_variation + cmab_uuid = None + error = False else: - decision_variation, variation_reasons = self.get_variation( + variation_result = self.get_variation( project_config, experiment, user_context, user_profile_tracker, feature_reasons, options ) + cmab_uuid = variation_result['cmab_uuid'] + variation_reasons = variation_result['reasons'] + decision_variation = variation_result['variation'] + error = variation_result['error'] feature_reasons.extend(variation_reasons) + if error: + decision = Decision(experiment, None, enums.DecisionSources.FEATURE_TEST, cmab_uuid) + decision_result: DecisionResult = { + 'decision': decision, + 'error': True, + 'reasons': feature_reasons + } + decisions.append(decision_result) + experiment_decision_found = True + break + if decision_variation: self.logger.debug( f'User "{user_context.user_id}" ' f'bucketed into experiment "{experiment.key}" of feature "{feature.key}".' ) - decision = Decision(experiment, decision_variation, enums.DecisionSources.FEATURE_TEST) - decisions.append((decision, feature_reasons)) + decision = Decision(experiment, decision_variation, + enums.DecisionSources.FEATURE_TEST, cmab_uuid) + decision_result = { + 'decision': decision, + 'error': False, + 'reasons': feature_reasons + } + decisions.append(decision_result) experiment_decision_found = True # Mark that a decision was found break # Stop after the first successful experiment decision - # Only process rollout if no experiment decision was found + # Only process rollout if no experiment decision was found and no error if not experiment_decision_found: rollout_decision, rollout_reasons = self.get_variation_for_rollout(project_config, feature, @@ -621,7 +839,12 @@ def get_variations_for_feature_list( self.logger.debug(f'User "{user_context.user_id}" ' f'not bucketed into any rollout for feature "{feature.key}".') - decisions.append((rollout_decision, feature_reasons)) + decision_result = { + 'decision': rollout_decision, + 'error': False, + 'reasons': feature_reasons + } + decisions.append(decision_result) if self.user_profile_service is not None and user_profile_tracker is not None and ignore_ups is False: user_profile_tracker.save_user_profile() diff --git a/optimizely/event/event_factory.py b/optimizely/event/event_factory.py index 8a4bb0cf8..715b88c5f 100644 --- a/optimizely/event/event_factory.py +++ b/optimizely/event/event_factory.py @@ -123,7 +123,9 @@ def _create_visitor(cls, event: Optional[user_event.UserEvent], logger: Logger) experiment_layerId = event.experiment.layerId experiment_id = event.experiment.id - metadata = payload.Metadata(event.flag_key, event.rule_key, event.rule_type, variation_key, event.enabled) + metadata = payload.Metadata(event.flag_key, event.rule_key, + event.rule_type, variation_key, + event.enabled, event.cmab_uuid) decision = payload.Decision(experiment_layerId, experiment_id, variation_id, metadata) snapshot_event = payload.SnapshotEvent( experiment_layerId, event.uuid, cls.ACTIVATE_EVENT_KEY, event.timestamp, diff --git a/optimizely/event/payload.py b/optimizely/event/payload.py index ac6f35e42..e352dd10f 100644 --- a/optimizely/event/payload.py +++ b/optimizely/event/payload.py @@ -81,12 +81,15 @@ def __init__(self, campaign_id: str, experiment_id: str, variation_id: str, meta class Metadata: """ Class respresenting Metadata. """ - def __init__(self, flag_key: str, rule_key: str, rule_type: str, variation_key: str, enabled: bool): + def __init__(self, flag_key: str, rule_key: str, rule_type: str, + variation_key: str, enabled: bool, cmab_uuid: Optional[str] = None): self.flag_key = flag_key self.rule_key = rule_key self.rule_type = rule_type self.variation_key = variation_key self.enabled = enabled + if cmab_uuid: + self.cmab_uuid = cmab_uuid class Snapshot: diff --git a/optimizely/event/user_event.py b/optimizely/event/user_event.py index 9cdb623a9..68c1ee78c 100644 --- a/optimizely/event/user_event.py +++ b/optimizely/event/user_event.py @@ -70,7 +70,8 @@ def __init__( rule_key: str, rule_type: str, enabled: bool, - bot_filtering: Optional[bool] = None + bot_filtering: Optional[bool] = None, + cmab_uuid: Optional[str] = None ): super().__init__(event_context, user_id, visitor_attributes, bot_filtering) self.experiment = experiment @@ -79,6 +80,7 @@ def __init__( self.rule_key = rule_key self.rule_type = rule_type self.enabled = enabled + self.cmab_uuid = cmab_uuid class ConversionEvent(UserEvent): diff --git a/optimizely/event/user_event_factory.py b/optimizely/event/user_event_factory.py index ef07d06be..b41be39a0 100644 --- a/optimizely/event/user_event_factory.py +++ b/optimizely/event/user_event_factory.py @@ -40,7 +40,8 @@ def create_impression_event( rule_type: str, enabled: bool, user_id: str, - user_attributes: Optional[UserAttributes] + user_attributes: Optional[UserAttributes], + cmab_uuid: Optional[str] ) -> Optional[user_event.ImpressionEvent]: """ Create impression Event to be sent to the logging endpoint. @@ -90,6 +91,7 @@ def create_impression_event( rule_type, enabled, project_config.get_bot_filtering_value(), + cmab_uuid, ) @classmethod diff --git a/optimizely/helpers/enums.py b/optimizely/helpers/enums.py index 2d6febabc..e3acafef2 100644 --- a/optimizely/helpers/enums.py +++ b/optimizely/helpers/enums.py @@ -127,8 +127,9 @@ class Errors: ODP_INVALID_DATA: Final = 'ODP data is not valid.' ODP_INVALID_ACTION: Final = 'ODP action is not valid (cannot be empty).' MISSING_SDK_KEY: Final = 'SDK key not provided/cannot be found in the datafile.' - CMAB_FETCH_FAILED: Final = 'CMAB decision fetch failed with status: {}' - INVALID_CMAB_FETCH_RESPONSE = 'Invalid CMAB fetch response' + CMAB_FETCH_FAILED: Final = 'CMAB decision fetch failed with status: {}.' + INVALID_CMAB_FETCH_RESPONSE: Final = 'Invalid CMAB fetch response.' + CMAB_FETCH_FAILED_DETAILED: Final = 'Failed to fetch CMAB data for experiment {}.' class ForcedDecisionLogs: diff --git a/optimizely/helpers/event_tag_utils.py b/optimizely/helpers/event_tag_utils.py index 0efbafb7d..cb577950b 100644 --- a/optimizely/helpers/event_tag_utils.py +++ b/optimizely/helpers/event_tag_utils.py @@ -81,7 +81,7 @@ def get_numeric_value(event_tags: Optional[EventTags], logger: Optional[Logger] """ logger_message_debug = None - numeric_metric_value = None + numeric_metric_value: Optional[float] = None if event_tags is None: return numeric_metric_value @@ -141,4 +141,4 @@ def get_numeric_value(event_tags: Optional[EventTags], logger: Optional[Logger] ' is in an invalid format and will not be sent to results.' ) - return numeric_metric_value # type: ignore[no-any-return] + return numeric_metric_value diff --git a/optimizely/lib/pymmh3.py b/optimizely/lib/pymmh3.py index b37bf944a..7a8ca1797 100755 --- a/optimizely/lib/pymmh3.py +++ b/optimizely/lib/pymmh3.py @@ -399,7 +399,7 @@ def fmix(h: int) -> int: return h4 << 96 | h3 << 64 | h2 << 32 | h1 - key = bytearray(xencode(key)) + key = bytes(xencode(key)) if x64arch: return hash128_x64(key, seed) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index af4422244..8d4fb8f6e 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Any, Optional + from . import decision_service from . import entities from . import event_builder @@ -44,12 +45,18 @@ from .optimizely_config import OptimizelyConfig, OptimizelyConfigService from .optimizely_user_context import OptimizelyUserContext, UserAttributes from .project_config import ProjectConfig +from .cmab.cmab_client import DefaultCmabClient, CmabRetryConfig +from .cmab.cmab_service import DefaultCmabService, CmabCacheValue if TYPE_CHECKING: # prevent circular dependency by skipping import at runtime from .user_profile import UserProfileService from .helpers.event_tag_utils import EventTags +# Default constants for CMAB cache +DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 * 1000 # 30 minutes in milliseconds +DEFAULT_CMAB_CACHE_SIZE = 1000 + class Optimizely: """ Class encapsulating all SDK functionality. """ @@ -69,7 +76,7 @@ def __init__( datafile_access_token: Optional[str] = None, default_decide_options: Optional[list[str]] = None, event_processor_options: Optional[dict[str, Any]] = None, - settings: Optional[OptimizelySdkSettings] = None + settings: Optional[OptimizelySdkSettings] = None, ) -> None: """ Optimizely init method for managing Custom projects. @@ -169,7 +176,19 @@ def __init__( self._setup_odp(self.config_manager.get_sdk_key()) self.event_builder = event_builder.EventBuilder() - self.decision_service = decision_service.DecisionService(self.logger, user_profile_service) + + # Initialize CMAB components + self.cmab_client = DefaultCmabClient( + retry_config=CmabRetryConfig(), + logger=self.logger + ) + self.cmab_cache: LRUCache[str, CmabCacheValue] = LRUCache(DEFAULT_CMAB_CACHE_SIZE, DEFAULT_CMAB_CACHE_TIMEOUT) + self.cmab_service = DefaultCmabService( + cmab_cache=self.cmab_cache, + cmab_client=self.cmab_client, + logger=self.logger + ) + self.decision_service = decision_service.DecisionService(self.logger, user_profile_service, self.cmab_service) self.user_profile_service = user_profile_service def _validate_instantiation_options(self) -> None: @@ -242,7 +261,7 @@ def _validate_user_inputs( def _send_impression_event( self, project_config: project_config.ProjectConfig, experiment: Optional[entities.Experiment], variation: Optional[entities.Variation], flag_key: str, rule_key: str, rule_type: str, - enabled: bool, user_id: str, attributes: Optional[UserAttributes] + enabled: bool, user_id: str, attributes: Optional[UserAttributes], cmab_uuid: Optional[str] = None ) -> None: """ Helper method to send impression event. @@ -262,7 +281,9 @@ def _send_impression_event( 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 + project_config, experiment, variation_id, + flag_key, rule_key, rule_type, + enabled, user_id, attributes, cmab_uuid ) if user_event is None: @@ -339,7 +360,8 @@ def _get_feature_variable_for_type( user_context = OptimizelyUserContext(self, self.logger, user_id, attributes, False) - decision, _ = self.decision_service.get_variation_for_feature(project_config, feature_flag, user_context) + decision_result = self.decision_service.get_variation_for_feature(project_config, feature_flag, user_context) + decision = decision_result['decision'] if decision.variation: @@ -426,7 +448,9 @@ def _get_all_feature_variables_for_type( user_context = OptimizelyUserContext(self, self.logger, user_id, attributes, False) - decision, _ = self.decision_service.get_variation_for_feature(project_config, feature_flag, user_context) + decision = self.decision_service.get_variation_for_feature(project_config, + feature_flag, + user_context)['decision'] if decision.variation: @@ -634,10 +658,9 @@ def get_variation( user_context = OptimizelyUserContext(self, self.logger, user_id, attributes, False) user_profile_tracker = user_profile.UserProfileTracker(user_id, self.user_profile_service, self.logger) user_profile_tracker.load_user_profile() - variation, _ = self.decision_service.get_variation(project_config, - experiment, - user_context, - user_profile_tracker) + variation_result = self.decision_service.get_variation(project_config, experiment, + user_context, user_profile_tracker) + variation = variation_result['variation'] user_profile_tracker.save_user_profile() if variation: variation_key = variation.key @@ -698,7 +721,9 @@ def is_feature_enabled(self, feature_key: str, user_id: str, attributes: Optiona user_context = OptimizelyUserContext(self, self.logger, user_id, attributes, False) - decision, _ = self.decision_service.get_variation_for_feature(project_config, feature, user_context) + decision_result = self.decision_service.get_variation_for_feature(project_config, feature, user_context) + decision = decision_result['decision'] + cmab_uuid = decision_result['decision'].cmab_uuid is_source_experiment = decision.source == enums.DecisionSources.FEATURE_TEST is_source_rollout = decision.source == enums.DecisionSources.ROLLOUT @@ -709,7 +734,7 @@ def is_feature_enabled(self, feature_key: str, user_id: str, attributes: Optiona if (is_source_rollout or not decision.variation) and project_config.get_send_flag_decisions_value(): self._send_impression_event( project_config, decision.experiment, decision.variation, feature.key, decision.experiment.key if - decision.experiment else '', str(decision.source), feature_enabled, user_id, attributes + decision.experiment else '', str(decision.source), feature_enabled, user_id, attributes, cmab_uuid ) # Send event if Decision came from an experiment. @@ -720,7 +745,7 @@ def is_feature_enabled(self, feature_key: str, user_id: str, attributes: Optiona } self._send_impression_event( project_config, decision.experiment, decision.variation, feature.key, decision.experiment.key, - str(decision.source), feature_enabled, user_id, attributes + str(decision.source), feature_enabled, user_id, attributes, cmab_uuid ) if feature_enabled: @@ -1173,7 +1198,9 @@ def _create_optimizely_decision( flag_decision.variation, flag_key, rule_key or '', str(decision_source), feature_enabled, - user_id, attributes) + user_id, attributes, + flag_decision.cmab_uuid + ) decision_event_dispatched = True @@ -1338,7 +1365,7 @@ def _decide_for_keys( decision_reasons_dict[key] += decision_reasons if variation: - decision = Decision(None, variation, enums.DecisionSources.FEATURE_TEST) + decision = Decision(None, variation, enums.DecisionSources.FEATURE_TEST, None) flag_decisions[key] = decision else: flags_without_forced_decision.append(feature_flag) @@ -1349,11 +1376,18 @@ def _decide_for_keys( user_context, merged_decide_options ) - for i in range(0, len(flags_without_forced_decision)): - decision = decision_list[i][0] - reasons = decision_list[i][1] + decision = decision_list[i]['decision'] + reasons = decision_list[i]['reasons'] + error = decision_list[i]['error'] flag_key = flags_without_forced_decision[i].key + # store error decision against key and remove key from valid keys + if error: + optimizely_decision = OptimizelyDecision.new_error_decision(flags_without_forced_decision[i].key, + user_context, reasons) + decisions[flag_key] = optimizely_decision + if flag_key in valid_keys: + valid_keys.remove(flag_key) flag_decisions[flag_key] = decision decision_reasons_dict[flag_key] += reasons diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py index 6c5862a53..d906a3cfc 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -457,9 +457,10 @@ def test_get_variation__experiment_not_running(self): ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: - variation, _ = self.decision_service.get_variation( + variation_result = self.decision_service.get_variation( self.project_config, experiment, user, None ) + variation = variation_result['variation'] self.assertIsNone( variation ) @@ -500,7 +501,7 @@ def test_get_variation__bucketing_id_provided(self): "optimizely.bucketer.Bucketer.bucket", return_value=[self.project_config.get_variation_from_id("211127", "211129"), []], ) as mock_bucket: - variation, _ = self.decision_service.get_variation( + _ = self.decision_service.get_variation( self.project_config, experiment, user, @@ -535,9 +536,9 @@ def test_get_variation__user_whitelisted_for_variation(self): ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: - variation, _ = self.decision_service.get_variation( + variation = self.decision_service.get_variation( self.project_config, experiment, user, user_profile_tracker - ) + )['variation'] self.assertEqual( entities.Variation("111128", "control"), variation, @@ -573,9 +574,9 @@ def test_get_variation__user_has_stored_decision(self): ) as mock_audience_check, mock.patch( "optimizely.bucketer.Bucketer.bucket" ) as mock_bucket: - variation, _ = self.decision_service.get_variation( + variation = self.decision_service.get_variation( self.project_config, experiment, user, user_profile_tracker - ) + )['variation'] self.assertEqual( entities.Variation("111128", "control"), variation, @@ -619,9 +620,9 @@ def test_get_variation__user_bucketed_for_new_experiment__user_profile_tracker_a "optimizely.bucketer.Bucketer.bucket", return_value=[entities.Variation("111129", "variation"), []], ) as mock_bucket: - variation, _ = self.decision_service.get_variation( + variation = self.decision_service.get_variation( self.project_config, experiment, user, user_profile_tracker - ) + )['variation'] self.assertEqual( entities.Variation("111129", "variation"), variation, @@ -669,9 +670,9 @@ def test_get_variation__user_does_not_meet_audience_conditions(self): ) as mock_bucket, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: - variation, _ = self.decision_service.get_variation( + variation = self.decision_service.get_variation( self.project_config, experiment, user, user_profile_tracker - ) + )['variation'] self.assertIsNone( variation ) @@ -719,14 +720,14 @@ def test_get_variation__ignore_user_profile_when_specified(self): ) as mock_lookup, mock.patch( "optimizely.user_profile.UserProfileService.save" ) as mock_save: - variation, _ = self.decision_service.get_variation( + variation = self.decision_service.get_variation( self.project_config, experiment, user, user_profile_tracker, [], options=['IGNORE_USER_PROFILE_SERVICE'], - ) + )['variation'] self.assertEqual( entities.Variation("111129", "variation"), variation, @@ -750,6 +751,326 @@ def test_get_variation__ignore_user_profile_when_specified(self): self.assertEqual(0, mock_lookup.call_count) self.assertEqual(0, mock_save.call_count) + def test_get_variation_cmab_experiment_user_in_traffic_allocation(self): + """Test get_variation with CMAB experiment where user is in traffic allocation.""" + + # Create a user context + user = optimizely_user_context.OptimizelyUserContext( + optimizely_client=None, + logger=None, + user_id="test_user", + user_attributes={} + ) + + # Create a CMAB experiment + cmab_experiment = entities.Experiment( + '111150', + 'cmab_experiment', + 'Running', + '111150', + [], # No audience IDs + {}, + [ + entities.Variation('111151', 'variation_1'), + entities.Variation('111152', 'variation_2') + ], + [ + {'entityId': '111151', 'endOfRange': 5000}, + {'entityId': '111152', 'endOfRange': 10000} + ], + cmab={'trafficAllocation': 5000} + ) + + with mock.patch('optimizely.helpers.experiment.is_experiment_running', return_value=True), \ + mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions', return_value=[True, []]), \ + mock.patch.object(self.decision_service.bucketer, 'bucket_to_entity_id', + return_value=['$', []]) as mock_bucket, \ + mock.patch.object(self.decision_service, 'cmab_service') as mock_cmab_service, \ + mock.patch.object(self.project_config, 'get_variation_from_id', + return_value=entities.Variation('111151', 'variation_1')), \ + mock.patch.object(self.decision_service, + 'logger') as mock_logger: + + # Configure CMAB service to return a decision + mock_cmab_service.get_decision.return_value = { + 'variation_id': '111151', + 'cmab_uuid': 'test-cmab-uuid-123' + } + + # Call get_variation with the CMAB experiment + variation_result = self.decision_service.get_variation( + self.project_config, + cmab_experiment, + user, + None + ) + cmab_uuid = variation_result['cmab_uuid'] + variation = variation_result['variation'] + error = variation_result['error'] + reasons = variation_result['reasons'] + + # Verify the variation and cmab_uuid + self.assertEqual(entities.Variation('111151', 'variation_1'), variation) + self.assertEqual('test-cmab-uuid-123', cmab_uuid) + self.assertStrictFalse(error) + self.assertIn('User "test_user" is in variation "variation_1" of experiment cmab_experiment.', reasons) + + # Verify bucketer was called with correct arguments + mock_bucket.assert_called_once_with( + self.project_config, + cmab_experiment, + "test_user", + "test_user" + ) + + # Verify CMAB service was called with correct arguments + mock_cmab_service.get_decision.assert_called_once_with( + self.project_config, + user, + '111150', # experiment id + [] # options (empty list as default) + ) + + # Verify logger was called + mock_logger.info.assert_any_call('User "test_user" is in variation ' + '"variation_1" of experiment cmab_experiment.') + + def test_get_variation_cmab_experiment_user_not_in_traffic_allocation(self): + """Test get_variation with CMAB experiment where user is not in traffic allocation.""" + + # Create a user context + user = optimizely_user_context.OptimizelyUserContext( + optimizely_client=None, + logger=None, + user_id="test_user", + user_attributes={} + ) + + # Create a CMAB experiment + cmab_experiment = entities.Experiment( + '111150', + 'cmab_experiment', + 'Running', + '111150', + [], # No audience IDs + {}, + [entities.Variation('111151', 'variation_1')], + [{'entityId': '111151', 'endOfRange': 10000}], + cmab={'trafficAllocation': 5000} + ) + + with mock.patch('optimizely.helpers.experiment.is_experiment_running', return_value=True), \ + mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions', return_value=[True, []]), \ + mock.patch.object(self.decision_service.bucketer, 'bucket_to_entity_id', + return_value=[None, []]) as mock_bucket, \ + mock.patch.object(self.decision_service, 'cmab_service') as mock_cmab_service, \ + mock.patch.object(self.decision_service, + 'logger') as mock_logger: + + # Call get_variation with the CMAB experiment + variation_result = self.decision_service.get_variation( + self.project_config, + cmab_experiment, + user, + None + ) + variation = variation_result['variation'] + cmab_uuid = variation_result['cmab_uuid'] + error = variation_result['error'] + reasons = variation_result['reasons'] + + # Verify we get no variation and CMAB service wasn't called + self.assertIsNone(variation) + self.assertIsNone(cmab_uuid) + self.assertStrictFalse(error) + self.assertIn('User "test_user" not in CMAB experiment "cmab_experiment" due to traffic allocation.', + reasons) + + # Verify bucketer was called with correct arguments + mock_bucket.assert_called_once_with( + self.project_config, + cmab_experiment, + "test_user", + "test_user" + ) + + # Verify CMAB service wasn't called since user is not in traffic allocation + mock_cmab_service.get_decision.assert_not_called() + + # Verify logger was called + mock_logger.info.assert_any_call('User "test_user" not in CMAB ' + 'experiment "cmab_experiment" due to traffic allocation.') + + def test_get_variation_cmab_experiment_service_error(self): + """Test get_variation with CMAB experiment when the CMAB service returns an error.""" + + # Create a user context + user = optimizely_user_context.OptimizelyUserContext( + optimizely_client=None, + logger=None, + user_id="test_user", + user_attributes={} + ) + + # Create a CMAB experiment + cmab_experiment = entities.Experiment( + '111150', + 'cmab_experiment', + 'Running', + '111150', + [], # No audience IDs + {}, + [entities.Variation('111151', 'variation_1')], + [{'entityId': '111151', 'endOfRange': 10000}], + cmab={'trafficAllocation': 5000} + ) + + with mock.patch('optimizely.helpers.experiment.is_experiment_running', return_value=True), \ + mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions', return_value=[True, []]), \ + mock.patch('optimizely.bucketer.Bucketer.bucket_to_entity_id', return_value=['$', []]), \ + mock.patch('optimizely.decision_service.DecisionService._get_decision_for_cmab_experiment', + return_value={'error': True, 'result': None, 'reasons': ['CMAB service error']}): + + # Call get_variation with the CMAB experiment + variation_result = self.decision_service.get_variation( + self.project_config, + cmab_experiment, + user, + None + ) + variation = variation_result['variation'] + cmab_uuid = variation_result['cmab_uuid'] + reasons = variation_result['reasons'] + error = variation_result['error'] + + # Verify we get no variation due to CMAB service error + self.assertIsNone(variation) + self.assertIsNone(cmab_uuid) + self.assertIn('CMAB service error', reasons) + self.assertStrictTrue(error) + + def test_get_variation_cmab_experiment_forced_variation(self): + """Test get_variation with CMAB experiment when user has a forced variation.""" + + # Create a user context + user = optimizely_user_context.OptimizelyUserContext( + optimizely_client=None, + logger=None, + user_id="test_user", + user_attributes={} + ) + + # Create a CMAB experiment + cmab_experiment = entities.Experiment( + '111150', + 'cmab_experiment', + 'Running', + '111150', + [], # No audience IDs + {}, + [ + entities.Variation('111151', 'variation_1'), + entities.Variation('111152', 'variation_2') + ], + [ + {'entityId': '111151', 'endOfRange': 5000}, + {'entityId': '111152', 'endOfRange': 10000} + ], + cmab={'trafficAllocation': 5000} + ) + + forced_variation = entities.Variation('111152', 'variation_2') + + with mock.patch('optimizely.decision_service.DecisionService.get_forced_variation', + return_value=[forced_variation, ['User is forced into variation']]), \ + mock.patch('optimizely.bucketer.Bucketer.bucket_to_entity_id') as mock_bucket, \ + mock.patch('optimizely.decision_service.DecisionService._get_decision_for_cmab_experiment' + ) as mock_cmab_decision: + + # Call get_variation with the CMAB experiment + variation_result = self.decision_service.get_variation( + self.project_config, + cmab_experiment, + user, + None + ) + variation = variation_result['variation'] + reasons = variation_result['reasons'] + cmab_uuid = variation_result['cmab_uuid'] + error = variation_result['error'] + + # Verify we get the forced variation + self.assertEqual(forced_variation, variation) + self.assertIsNone(cmab_uuid) + self.assertIn('User is forced into variation', reasons) + self.assertStrictFalse(error) + + # Verify CMAB-specific methods weren't called + mock_bucket.assert_not_called() + mock_cmab_decision.assert_not_called() + + def test_get_variation_cmab_experiment_with_whitelisted_variation(self): + """Test get_variation with CMAB experiment when user has a whitelisted variation.""" + + # Create a user context + user = optimizely_user_context.OptimizelyUserContext( + optimizely_client=None, + logger=None, + user_id="test_user", + user_attributes={} + ) + + # Create a CMAB experiment with forced variations + cmab_experiment = entities.Experiment( + '111150', + 'cmab_experiment', + 'Running', + '111150', + [], # No audience IDs + {'test_user': 'variation_2'}, + [ + entities.Variation('111151', 'variation_1'), + entities.Variation('111152', 'variation_2') + ], + [ + {'entityId': '111151', 'endOfRange': 5000}, + {'entityId': '111152', 'endOfRange': 10000} + ], + cmab={'trafficAllocation': 5000} + ) + + whitelisted_variation = entities.Variation('111152', 'variation_2') + + with mock.patch('optimizely.decision_service.DecisionService.get_forced_variation', + return_value=[None, []]), \ + mock.patch('optimizely.decision_service.DecisionService.get_whitelisted_variation', + return_value=[whitelisted_variation, ['User is whitelisted into variation']]), \ + mock.patch('optimizely.bucketer.Bucketer.bucket_to_entity_id') as mock_bucket, \ + mock.patch('optimizely.decision_service.DecisionService._get_decision_for_cmab_experiment' + ) as mock_cmab_decision: + + # Call get_variation with the CMAB experiment + variation_result = self.decision_service.get_variation( + self.project_config, + cmab_experiment, + user, + None + ) + variation = variation_result['variation'] + cmab_uuid = variation_result['cmab_uuid'] + reasons = variation_result['reasons'] + error = variation_result['error'] + + # Verify we get the whitelisted variation + self.assertEqual(whitelisted_variation, variation) + self.assertIsNone(cmab_uuid) + self.assertIn('User is whitelisted into variation', reasons) + self.assertStrictFalse(error) + + # Verify CMAB-specific methods weren't called + mock_bucket.assert_not_called() + mock_cmab_decision.assert_not_called() + class FeatureFlagDecisionTests(base.BaseTest): def setUp(self): @@ -779,7 +1100,7 @@ def test_get_variation_for_rollout__returns_none_if_no_experiments(self): ) self.assertEqual( - decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT, None), variation_received, ) @@ -810,6 +1131,7 @@ def test_get_variation_for_rollout__returns_decision_if_user_in_rollout(self): self.project_config.get_experiment_from_id("211127"), self.project_config.get_variation_from_id("211127", "211129"), enums.DecisionSources.ROLLOUT, + None ), variation_received, ) @@ -852,6 +1174,7 @@ def test_get_variation_for_rollout__calls_bucket_with_bucketing_id(self): self.project_config.get_experiment_from_id("211127"), self.project_config.get_variation_from_id("211127", "211129"), enums.DecisionSources.ROLLOUT, + None ), variation_received, ) @@ -892,7 +1215,7 @@ def test_get_variation_for_rollout__skips_to_everyone_else_rule(self): ) self.assertEqual( decision_service.Decision( - everyone_else_exp, variation_to_mock, enums.DecisionSources.ROLLOUT + everyone_else_exp, variation_to_mock, enums.DecisionSources.ROLLOUT, None ), variation_received, ) @@ -946,7 +1269,7 @@ def test_get_variation_for_rollout__returns_none_for_user_not_in_rollout(self): self.project_config, feature, user ) self.assertEqual( - decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT, None), variation_received, ) @@ -1013,17 +1336,18 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_experiment( ) decision_patch = mock.patch( "optimizely.decision_service.DecisionService.get_variation", - return_value=[expected_variation, []], + return_value={'variation': expected_variation, 'cmab_uuid': None, 'reasons': [], 'error': False}, ) with decision_patch as mock_decision, self.mock_decision_logger: - variation_received, _ = self.decision_service.get_variation_for_feature( + variation_received = self.decision_service.get_variation_for_feature( self.project_config, feature, user, options=None - ) + )['decision'] self.assertEqual( decision_service.Decision( expected_experiment, expected_variation, enums.DecisionSources.FEATURE_TEST, + None ), variation_received, ) @@ -1056,9 +1380,9 @@ 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( + variation_received = self.decision_service.get_variation_for_feature( self.project_config, feature, user, False - ) + )['decision'] self.assertEqual( expected_variation, variation_received, @@ -1096,14 +1420,15 @@ def test_get_variation_for_feature__returns_variation_if_user_not_in_experiment_ ) 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( + decision = self.decision_service.get_variation_for_feature( self.project_config, feature, user - ) + )['decision'] self.assertEqual( decision_service.Decision( expected_experiment, expected_variation, enums.DecisionSources.ROLLOUT, + None ), decision, ) @@ -1143,16 +1468,17 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_group(self) ) with mock.patch( "optimizely.decision_service.DecisionService.get_variation", - return_value=(expected_variation, []), + return_value={'variation': expected_variation, 'cmab_uuid': None, 'reasons': [], 'error': False}, ) as mock_decision: - variation_received, _ = self.decision_service.get_variation_for_feature( + variation_received = self.decision_service.get_variation_for_feature( self.project_config, feature, user, options=None - ) + )['decision'] self.assertEqual( decision_service.Decision( expected_experiment, expected_variation, enums.DecisionSources.FEATURE_TEST, + None ), variation_received, ) @@ -1177,13 +1503,13 @@ def test_get_variation_for_feature__returns_none_for_user_not_in_experiment(self with mock.patch( "optimizely.decision_service.DecisionService.get_variation", - return_value=[None, []], + return_value={'variation': None, 'cmab_uuid': None, 'reasons': [], 'error': False}, ) as mock_decision: - variation_received, _ = self.decision_service.get_variation_for_feature( + variation_received = self.decision_service.get_variation_for_feature( self.project_config, feature, user - ) + )['decision'] self.assertEqual( - decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT, None), variation_received, ) @@ -1209,13 +1535,13 @@ def test_get_variation_for_feature__returns_none_for_user_in_group_experiment_no feature = self.project_config.get_feature_from_key("test_feature_in_group") with mock.patch( "optimizely.decision_service.DecisionService.get_variation", - return_value=[None, []], + return_value={'variation': None, 'cmab_uuid': None, 'reasons': [], 'error': False}, ) as mock_decision: - variation_received, _ = self.decision_service.get_variation_for_feature( + variation_received = self.decision_service.get_variation_for_feature( self.project_config, feature, user, False - ) + )["decision"] self.assertEqual( - decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT, None), variation_received, ) @@ -1240,15 +1566,16 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_mutex_group with mock.patch( '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( + variation_received = self.decision_service.get_variation_for_feature( self.project_config, feature, user - ) + )['decision'] self.assertEqual( decision_service.Decision( expected_experiment, expected_variation, enums.DecisionSources.FEATURE_TEST, + None ), variation_received, ) @@ -1275,14 +1602,15 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_mutex_group with mock.patch( '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( + variation_received = self.decision_service.get_variation_for_feature( self.project_config, feature, user - ) + )['decision'] self.assertEqual( decision_service.Decision( expected_experiment, expected_variation, enums.DecisionSources.FEATURE_TEST, + None ), variation_received, ) @@ -1309,16 +1637,18 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_mutex_group '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( + decision_result = self.decision_service.get_variation_for_feature( self.project_config, feature, user ) + decision_received = decision_result['decision'] self.assertEqual( decision_service.Decision( expected_experiment, expected_variation, enums.DecisionSources.FEATURE_TEST, + None ), - variation_received, + decision_received, ) mock_config_logging.debug.assert_called_with('Assigned bucket 6500 to user with bucketing ID "test_user".') mock_generate_bucket_value.assert_called_with('test_user42224') @@ -1337,15 +1667,16 @@ def test_get_variation_for_feature__returns_variation_for_rollout_in_mutex_group with mock.patch( '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( + variation_received = self.decision_service.get_variation_for_feature( self.project_config, feature, user - ) + )['decision'] self.assertEqual( decision_service.Decision( None, None, enums.DecisionSources.ROLLOUT, + None ), variation_received, ) @@ -1372,14 +1703,15 @@ 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, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: - variation_received, _ = self.decision_service.get_variation_for_feature( + variation_received = self.decision_service.get_variation_for_feature( self.project_config, feature, user - ) + )['decision'] self.assertEqual( decision_service.Decision( expected_experiment, expected_variation, enums.DecisionSources.FEATURE_TEST, + None ), variation_received, ) @@ -1404,14 +1736,15 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_experiment_ with mock.patch( '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( + variation_received = self.decision_service.get_variation_for_feature( self.project_config, feature, user - ) + )['decision'] self.assertEqual( decision_service.Decision( expected_experiment, expected_variation, enums.DecisionSources.FEATURE_TEST, + None ), variation_received, ) @@ -1437,14 +1770,15 @@ 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, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: - variation_received, _ = self.decision_service.get_variation_for_feature( + variation_received = self.decision_service.get_variation_for_feature( self.project_config, feature, user - ) + )['decision'] self.assertEqual( decision_service.Decision( expected_experiment, expected_variation, enums.DecisionSources.FEATURE_TEST, + None ), variation_received, ) @@ -1465,14 +1799,15 @@ def test_get_variation_for_feature__returns_variation_for_rollout_in_experiment_ with mock.patch( '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( + variation_received = self.decision_service.get_variation_for_feature( self.project_config, feature, user - ) + )['decision'] self.assertEqual( decision_service.Decision( None, None, enums.DecisionSources.ROLLOUT, + None ), variation_received, ) @@ -1499,14 +1834,15 @@ def test_get_variation_for_feature__returns_variation_for_rollout_in_mutex_group with mock.patch( '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( + variation_received = self.decision_service.get_variation_for_feature( self.project_config, feature, user - ) + )['decision'] self.assertEqual( decision_service.Decision( expected_experiment, expected_variation, enums.DecisionSources.ROLLOUT, + None ), variation_received, ) @@ -1535,21 +1871,15 @@ 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, \ mock.patch.object(self.project_config, 'logger') as mock_config_logging: - variation_received, _ = self.decision_service.get_variation_for_feature( + variation_received = self.decision_service.get_variation_for_feature( self.project_config, feature, user - ) - print(f"variation received is: {variation_received}") - x = decision_service.Decision( - expected_experiment, - expected_variation, - enums.DecisionSources.ROLLOUT, - ) - print(f"need to be:{x}") + )['decision'] self.assertEqual( decision_service.Decision( expected_experiment, expected_variation, enums.DecisionSources.ROLLOUT, + None ), variation_received, ) diff --git a/tests/test_event_factory.py b/tests/test_event_factory.py index adbebd35c..59edd7c3b 100644 --- a/tests/test_event_factory.py +++ b/tests/test_event_factory.py @@ -113,6 +113,7 @@ def test_create_impression_event(self): False, 'test_user', None, + None ) log_event = EventFactory.create_log_event(event_obj, self.logger) @@ -177,6 +178,7 @@ def test_create_impression_event__with_attributes(self): True, 'test_user', {'test_attribute': 'test_value'}, + None ) log_event = EventFactory.create_log_event(event_obj, self.logger) @@ -239,6 +241,7 @@ def test_create_impression_event_when_attribute_is_not_in_datafile(self): True, 'test_user', {'do_you_know_me': 'test_value'}, + None ) log_event = EventFactory.create_log_event(event_obj, self.logger) @@ -394,6 +397,7 @@ def test_create_impression_event__with_user_agent_when_bot_filtering_is_enabled( False, 'test_user', {'$opt_user_agent': 'Edge'}, + None ) log_event = EventFactory.create_log_event(event_obj, self.logger) @@ -466,6 +470,7 @@ def test_create_impression_event__with_empty_attributes_when_bot_filtering_is_en False, 'test_user', None, + None ) log_event = EventFactory.create_log_event(event_obj, self.logger) @@ -544,6 +549,7 @@ def test_create_impression_event__with_user_agent_when_bot_filtering_is_disabled True, 'test_user', {'$opt_user_agent': 'Chrome'}, + None ) log_event = EventFactory.create_log_event(event_obj, self.logger) @@ -920,3 +926,136 @@ def test_create_conversion_event__when_event_is_used_in_multiple_experiments(sel self._validate_event_object( log_event, EventFactory.EVENT_ENDPOINT, expected_params, EventFactory.HTTP_VERB, EventFactory.HTTP_HEADERS, ) + + def test_create_impression_event_with_cmab_uuid(self): + """ Test that create_impression_event creates LogEvent object with CMAB UUID in metadata. """ + + expected_params = { + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [ + { + 'visitor_id': 'test_user', + 'attributes': [], + 'snapshots': [ + { + 'decisions': [ + {'variation_id': '111129', 'experiment_id': '111127', 'campaign_id': '111182', + 'metadata': {'flag_key': '', + 'rule_key': 'rule_key', + 'rule_type': 'experiment', + 'variation_key': 'variation', + 'enabled': False, + 'cmab_uuid': 'test-cmab-uuid-123' + } + } + ], + 'events': [ + { + 'timestamp': 42123, + 'entity_id': '111182', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'campaign_activated', + } + ], + } + ], + } + ], + 'client_name': 'python-sdk', + 'client_version': version.__version__, + 'enrich_decisions': True, + 'anonymize_ip': False, + 'revision': '42', + } + + with mock.patch('time.time', return_value=42.123), mock.patch( + 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' + ): + event_obj = UserEventFactory.create_impression_event( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), + '111129', + '', + 'rule_key', + 'experiment', + False, + 'test_user', + None, + 'test-cmab-uuid-123' # cmab_uuid parameter + ) + + log_event = EventFactory.create_log_event(event_obj, self.logger) + + self._validate_event_object( + log_event, EventFactory.EVENT_ENDPOINT, expected_params, EventFactory.HTTP_VERB, EventFactory.HTTP_HEADERS, + ) + + def test_create_impression_event_without_cmab_uuid(self): + """ Test that create_impression_event creates LogEvent object without CMAB UUID when not provided. """ + + expected_params = { + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [ + { + 'visitor_id': 'test_user', + 'attributes': [], + 'snapshots': [ + { + 'decisions': [ + { + 'variation_id': '111129', 'experiment_id': '111127', 'campaign_id': '111182', + 'metadata': { + 'flag_key': '', + 'rule_key': 'rule_key', + 'rule_type': 'experiment', + 'variation_key': 'variation', + 'enabled': False + } + } + ], + 'events': [ + { + 'timestamp': 42123, + 'entity_id': '111182', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'campaign_activated', + } + ], + } + ], + } + ], + 'client_name': 'python-sdk', + 'client_version': version.__version__, + 'enrich_decisions': True, + 'anonymize_ip': False, + 'revision': '42', + } + + with mock.patch('time.time', return_value=42.123), mock.patch( + 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' + ): + event_obj = UserEventFactory.create_impression_event( + self.project_config, + self.project_config.get_experiment_from_key('test_experiment'), + '111129', + '', + 'rule_key', + 'experiment', + False, + 'test_user', + None, + None # No cmab_uuid + ) + + log_event = EventFactory.create_log_event(event_obj, self.logger) + + # Verify no cmab_uuid in metadata + metadata = log_event.params['visitors'][0]['snapshots'][0]['decisions'][0]['metadata'] + self.assertNotIn('cmab_uuid', metadata) + + self._validate_event_object( + log_event, EventFactory.EVENT_ENDPOINT, expected_params, EventFactory.HTTP_VERB, EventFactory.HTTP_HEADERS, + ) diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index 1f4293cdd..2d51cda69 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -319,10 +319,15 @@ def test_invalid_json_raises_schema_validation_off(self): def test_activate(self): """ Test that activate calls process with right params and returns expected variation. """ - + variation_result = { + 'variation': self.project_config.get_variation_from_id('test_experiment', '111129'), + 'cmab_uuid': None, + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + return_value=variation_result, ) as mock_decision, mock.patch('time.time', return_value=42), mock.patch( 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch( @@ -402,9 +407,15 @@ def on_activate(experiment, user_id, attributes, variation, event): notification_id = self.optimizely.notification_center.add_notification_listener( enums.NotificationTypes.ACTIVATE, on_activate ) + variation_result = { + 'variation': self.project_config.get_variation_from_id('test_experiment', '111129'), + 'reasons': [], + 'cmab_uuid': None, + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + return_value=variation_result, ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process'): self.assertEqual('variation', self.optimizely.activate('test_experiment', 'test_user')) @@ -462,11 +473,15 @@ 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'), []) - + variation_result = { + 'variation': self.project_config.get_variation_from_id('test_experiment', '111129'), + 'cmab_uuid': None, + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=variation, + return_value=variation_result, ), mock.patch('optimizely.event.event_processor.BatchEventProcessor.process') as mock_process, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast: @@ -483,7 +498,7 @@ def on_activate(event_key, user_id, attributes, event_tags, event): 'ab-test', 'test_user', {}, - {'experiment_key': 'test_experiment', 'variation_key': variation[0].key}, + {'experiment_key': 'test_experiment', 'variation_key': variation_result['variation'].key}, ), mock.call( enums.NotificationTypes.ACTIVATE, @@ -503,11 +518,15 @@ 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'), []) - + variation_result = { + 'cmab_uuid': None, + 'reasons': [], + 'error': False, + 'variation': self.project_config.get_variation_from_id('test_experiment', '111129') + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=variation, + return_value=variation_result, ), mock.patch('optimizely.event.event_processor.BatchEventProcessor.process') as mock_process, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast: @@ -526,7 +545,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[0].key}, + {'experiment_key': 'test_experiment', 'variation_key': variation_result['variation'].key}, ), mock.call( enums.NotificationTypes.ACTIVATE, @@ -552,9 +571,14 @@ def on_activate(event_key, user_id, attributes, event_tags, event): def test_decision_listener__user_not_in_experiment(self): """ Test that activate calls broadcast decision with variation_key 'None' \ when user not in experiment. """ - + variation_result = { + 'variation': None, + 'error': False, + 'cmab_uuid': None, + 'reasons': [] + } with mock.patch('optimizely.decision_service.DecisionService.get_variation', - return_value=(None, []), ), mock.patch( + return_value=variation_result), mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' @@ -667,11 +691,15 @@ 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') - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=( - decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=get_variation_for_feature_return_value, ) as mock_decision, mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process'): self.assertTrue(opt_obj.is_feature_enabled('test_feature_in_experiment', 'test_user')) @@ -696,10 +724,15 @@ 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') + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.ROLLOUT, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=(get_variation_for_feature_return_value), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process: @@ -715,10 +748,15 @@ def on_activate(experiment, user_id, attributes, variation, event): def test_activate__with_attributes__audience_match(self): """ Test that activate calls process with right params and returns expected variation when attributes are provided and audience conditions are met. """ - + variation_result = { + 'cmab_uuid': None, + 'reasons': [], + 'error': False, + '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'), []), + return_value=variation_result, ) as mock_get_variation, mock.patch('time.time', return_value=42), mock.patch( 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch( @@ -1060,10 +1098,15 @@ def test_activate__with_attributes__audience_match__forced_bucketing(self): def test_activate__with_attributes__audience_match__bucketing_id_provided(self): """ Test that activate calls process with right params and returns expected variation when attributes (including bucketing ID) are provided and audience conditions are met. """ - + variation_result = { + 'cmab_uuid': None, + 'error': False, + 'reasons': [], + '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'), []), + return_value=variation_result, ) as mock_get_variation, mock.patch('time.time', return_value=42), mock.patch( 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch( @@ -1799,10 +1842,15 @@ def test_track__invalid_user_id(self): def test_get_variation(self): """ Test that get_variation returns valid variation and broadcasts decision with proper parameters. """ - + variation_result = { + 'variation': self.project_config.get_variation_from_id('test_experiment', '111129'), + 'reasons': [], + 'error': False, + 'cmab_uuid': None + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + return_value=variation_result, ), mock.patch('optimizely.notification_center.NotificationCenter.send_notifications') as mock_broadcast: variation = self.optimizely.get_variation('test_experiment', 'test_user') self.assertEqual( @@ -1821,10 +1869,15 @@ def test_get_variation(self): def test_get_variation_lookup_and_save_is_called(self): """ Test that lookup is called, get_variation returns valid variation and then save is called""" - + variation_result = { + 'variation': self.project_config.get_variation_from_id('test_experiment', '111129'), + 'cmab_uuid': None, + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + return_value=variation_result, ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast, mock.patch( @@ -1854,10 +1907,15 @@ def test_get_variation_with_experiment_in_feature(self): opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) project_config = opt_obj.config_manager.get_config() - + variation_result = { + 'error': False, + 'reasons': [], + 'variation': project_config.get_variation_from_id('test_experiment', '111129'), + 'cmab_uuid': None + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=(project_config.get_variation_from_id('test_experiment', '111129'), []), + return_value=variation_result, ), mock.patch('optimizely.notification_center.NotificationCenter.send_notifications') as mock_broadcast: variation = opt_obj.get_variation('test_experiment', 'test_user') self.assertEqual('variation', variation) @@ -1874,9 +1932,14 @@ def test_get_variation_with_experiment_in_feature(self): def test_get_variation__returns_none(self): """ Test that get_variation returns no variation and broadcasts decision with proper parameters. """ - + variation_result = { + 'variation': None, + 'reasons': [], + 'cmab_uuid': None, + 'error': False + } with mock.patch('optimizely.decision_service.DecisionService.get_variation', - return_value=(None, []), ), mock.patch( + return_value=variation_result, ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast: self.assertEqual( @@ -2028,14 +2091,18 @@ def test_is_feature_enabled__returns_true_for_feature_experiment_if_feature_enab mock_experiment = project_config.get_experiment_from_key('test_experiment') mock_variation = project_config.get_variation_from_id('test_experiment', '111129') - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None), + 'reasons': [], + 'error': False + } # Assert that featureEnabled property is True self.assertTrue(mock_variation.featureEnabled) with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=(get_variation_for_feature_return_value), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process, mock.patch( @@ -2128,14 +2195,18 @@ def test_is_feature_enabled__returns_false_for_feature_experiment_if_feature_dis mock_experiment = project_config.get_experiment_from_key('test_experiment') mock_variation = project_config.get_variation_from_id('test_experiment', '111128') - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None), + 'reasons': [], + 'error': False + } # Assert that featureEnabled property is False 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), []), + return_value=get_variation_for_feature_return_value, ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process, mock.patch( @@ -2228,14 +2299,18 @@ def test_is_feature_enabled__returns_true_for_feature_rollout_if_feature_enabled mock_experiment = project_config.get_experiment_from_key('test_experiment') mock_variation = project_config.get_variation_from_id('test_experiment', '111129') - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.ROLLOUT, None), + 'reasons': [], + 'error': False + } # Assert that featureEnabled property is True self.assertTrue(mock_variation.featureEnabled) with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=(get_variation_for_feature_return_value), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process, mock.patch( @@ -2278,14 +2353,18 @@ def test_is_feature_enabled__returns_true_for_feature_rollout_if_feature_enabled mock_experiment = project_config.get_experiment_from_key('test_experiment') mock_variation = project_config.get_variation_from_id('test_experiment', '111129') - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.ROLLOUT, None), + 'reasons': [], + 'error': False + } # Assert that featureEnabled property is True self.assertTrue(mock_variation.featureEnabled) with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=(get_variation_for_feature_return_value), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process, mock.patch( @@ -2383,11 +2462,15 @@ def test_is_feature_enabled__returns_false_for_feature_rollout_if_feature_disabl # Set featureEnabled property to False mock_variation.featureEnabled = False - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.ROLLOUT, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process, mock.patch( @@ -2427,9 +2510,15 @@ def test_is_feature_enabled__returns_false_when_user_is_not_bucketed_into_any_va project_config = opt_obj.config_manager.get_config() feature = project_config.get_feature_from_key('test_feature_in_experiment') + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(None, None, + enums.DecisionSources.ROLLOUT, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process, mock.patch( @@ -2470,10 +2559,15 @@ def test_is_feature_enabled__returns_false_when_variation_is_nil(self, ): opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) project_config = opt_obj.config_manager.get_config() feature = project_config.get_feature_from_key('test_feature_in_experiment_and_rollout') - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(None, None, + enums.DecisionSources.ROLLOUT, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process, mock.patch( @@ -2577,19 +2671,25 @@ def test_get_enabled_features__broadcasts_decision_for_each_feature(self): def side_effect(*args, **kwargs): feature = args[1] - response = None + response = { + 'decision': None, + 'reasons': [], + 'error': False + } if feature.key == 'test_feature_in_experiment': - response = decision_service.Decision(mock_experiment, mock_variation, - enums.DecisionSources.FEATURE_TEST) + response['decision'] = decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None) elif feature.key == 'test_feature_in_rollout': - response = decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.ROLLOUT) + response['decision'] = decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.ROLLOUT, None) elif feature.key == 'test_feature_in_experiment_and_rollout': - response = decision_service.Decision( - mock_experiment, mock_variation_2, enums.DecisionSources.FEATURE_TEST, ) + response['decision'] = decision_service.Decision( + mock_experiment, mock_variation_2, enums.DecisionSources.FEATURE_TEST, None) else: - response = decision_service.Decision(mock_experiment, mock_variation_2, enums.DecisionSources.ROLLOUT) + response['decision'] = decision_service.Decision(mock_experiment, mock_variation_2, + enums.DecisionSources.ROLLOUT, None) - return (response, []) + return response with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', side_effect=side_effect, @@ -2711,10 +2811,15 @@ def test_get_feature_variable_boolean(self): opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('test_experiment') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2749,10 +2854,15 @@ def test_get_feature_variable_double(self): opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('test_experiment') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2787,10 +2897,15 @@ def test_get_feature_variable_integer(self): opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('test_experiment') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2825,10 +2940,15 @@ def test_get_feature_variable_string(self): opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('test_experiment') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2864,10 +2984,15 @@ def test_get_feature_variable_json(self): opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('test_experiment') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2911,10 +3036,15 @@ def test_get_all_feature_variables(self): 'object': {'test': 123}, 'true_object': {'true_test': 1.4}, 'variable_without_usage': 45} + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=(get_variation_for_feature_return_value), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2967,11 +3097,16 @@ def test_get_feature_variable(self): opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('test_experiment') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None), + 'reasons': [], + 'error': False + } # Boolean with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=(get_variation_for_feature_return_value), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2999,8 +3134,7 @@ def test_get_feature_variable(self): # Double with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=(get_variation_for_feature_return_value), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3030,8 +3164,7 @@ def test_get_feature_variable(self): # Integer with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=(get_variation_for_feature_return_value), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3061,8 +3194,7 @@ def test_get_feature_variable(self): # String with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=(get_variation_for_feature_return_value), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3093,8 +3225,7 @@ def test_get_feature_variable(self): # JSON with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=(get_variation_for_feature_return_value), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3130,11 +3261,15 @@ def test_get_feature_variable_boolean_for_feature_in_rollout(self): mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('211127') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('211127', '211129') user_attributes = {'test_attribute': 'test_value'} - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.ROLLOUT, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3172,11 +3307,15 @@ def test_get_feature_variable_double_for_feature_in_rollout(self): mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('211127') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('211127', '211129') user_attributes = {'test_attribute': 'test_value'} - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.ROLLOUT, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3214,11 +3353,15 @@ def test_get_feature_variable_integer_for_feature_in_rollout(self): mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('211127') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('211127', '211129') user_attributes = {'test_attribute': 'test_value'} - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.ROLLOUT, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3256,11 +3399,15 @@ def test_get_feature_variable_string_for_feature_in_rollout(self): mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('211127') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('211127', '211129') user_attributes = {'test_attribute': 'test_value'} - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.ROLLOUT, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3298,11 +3445,15 @@ def test_get_feature_variable_json_for_feature_in_rollout(self): mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('211127') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('211127', '211129') user_attributes = {'test_attribute': 'test_value'} - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.ROLLOUT, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3340,11 +3491,15 @@ def test_get_all_feature_variables_for_feature_in_rollout(self): mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('211127') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('211127', '211129') user_attributes = {'test_attribute': 'test_value'} - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.ROLLOUT, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=(get_variation_for_feature_return_value), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3397,12 +3552,16 @@ def test_get_feature_variable_for_feature_in_rollout(self): mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('211127') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('211127', '211129') user_attributes = {'test_attribute': 'test_value'} - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.ROLLOUT, None), + 'reasons': [], + 'error': False + } # Boolean with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3434,8 +3593,7 @@ def test_get_feature_variable_for_feature_in_rollout(self): # Double with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3467,8 +3625,7 @@ def test_get_feature_variable_for_feature_in_rollout(self): # Integer with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3500,8 +3657,7 @@ def test_get_feature_variable_for_feature_in_rollout(self): # String with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3534,8 +3690,7 @@ def test_get_feature_variable_for_feature_in_rollout(self): # JSON with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3571,15 +3726,19 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('test_experiment') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None), + 'reasons': [], + 'error': False + } # Empty variable usage map for the mocked variation opt_obj.config_manager.get_config().variation_variable_usage_map['111129'] = None # Boolean with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=get_variation_for_feature_return_value, ): self.assertTrue( opt_obj.get_feature_variable_boolean('test_feature_in_experiment', 'is_working', 'test_user') @@ -3588,8 +3747,7 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va # Double with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=get_variation_for_feature_return_value, ): self.assertEqual( 10.99, opt_obj.get_feature_variable_double('test_feature_in_experiment', 'cost', 'test_user'), @@ -3598,8 +3756,7 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va # Integer with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=get_variation_for_feature_return_value, ): self.assertEqual( 999, opt_obj.get_feature_variable_integer('test_feature_in_experiment', 'count', 'test_user'), @@ -3608,8 +3765,7 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va # String with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=get_variation_for_feature_return_value, ): self.assertEqual( 'devel', opt_obj.get_feature_variable_string('test_feature_in_experiment', 'environment', 'test_user'), @@ -3618,8 +3774,7 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va # JSON with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=get_variation_for_feature_return_value, ): self.assertEqual( {"test": 12}, opt_obj.get_feature_variable_json('test_feature_in_experiment', 'object', 'test_user'), @@ -3628,15 +3783,13 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va # Non-typed with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=get_variation_for_feature_return_value, ): self.assertTrue(opt_obj.get_feature_variable('test_feature_in_experiment', 'is_working', 'test_user')) with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=get_variation_for_feature_return_value, ): self.assertEqual( 10.99, opt_obj.get_feature_variable('test_feature_in_experiment', 'cost', 'test_user'), @@ -3644,8 +3797,7 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=get_variation_for_feature_return_value, ): self.assertEqual( 999, opt_obj.get_feature_variable('test_feature_in_experiment', 'count', 'test_user'), @@ -3653,8 +3805,7 @@ def test_get_feature_variable__returns_default_value_if_variable_usage_not_in_va with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=get_variation_for_feature_return_value, ): self.assertEqual( 'devel', opt_obj.get_feature_variable('test_feature_in_experiment', 'environment', 'test_user'), @@ -3665,11 +3816,16 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): and broadcasts decision with proper parameters. """ opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(None, None, + enums.DecisionSources.ROLLOUT, None), + 'reasons': [], + 'error': False + } # Boolean with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3703,7 +3859,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # Double with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3737,7 +3893,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # Integer with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3772,7 +3928,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # String with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3806,7 +3962,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # JSON with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3840,7 +3996,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # Non-typed with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3871,7 +4027,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3904,7 +4060,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3937,7 +4093,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -4245,12 +4401,16 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('test_experiment') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111128') - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None), + 'reasons': [], + 'error': False + } # Boolean with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=(get_variation_for_feature_return_value), ), 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') @@ -4264,8 +4424,7 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self # Double with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=(get_variation_for_feature_return_value), ), 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'), @@ -4279,8 +4438,7 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self # Integer with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=(get_variation_for_feature_return_value), ), 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'), @@ -4294,8 +4452,7 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self # String with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=(get_variation_for_feature_return_value), ), 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'), @@ -4309,8 +4466,7 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self # JSON with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=(get_variation_for_feature_return_value), ), 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'), @@ -4324,8 +4480,7 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self # Non-typed with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=(get_variation_for_feature_return_value), ), 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')) @@ -4336,8 +4491,7 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=(get_variation_for_feature_return_value), ), 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'), @@ -4350,8 +4504,7 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=(get_variation_for_feature_return_value), ), 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'), @@ -4364,8 +4517,7 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled(self with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=(get_variation_for_feature_return_value), ), 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'), @@ -4382,12 +4534,16 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('211127') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('211127', '211229') - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.ROLLOUT, None), + 'reasons': [], + 'error': False + } # Boolean with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), 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')) @@ -4399,8 +4555,7 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r # Double with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), 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'), @@ -4414,8 +4569,7 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r # Integer with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), 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'), @@ -4429,8 +4583,7 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r # String with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), 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'), @@ -4443,8 +4596,7 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r # JSON with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), 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'), @@ -4457,8 +4609,7 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r # Non-typed with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), 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')) @@ -4469,8 +4620,7 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), 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'), @@ -4483,8 +4633,7 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), 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'), @@ -4497,8 +4646,7 @@ def test_get_feature_variable__returns_default_value_if_feature_not_enabled_in_r with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + return_value=get_variation_for_feature_return_value, ), 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'), @@ -4517,7 +4665,7 @@ def test_get_feature_variable__returns_none_if_type_mismatch(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), []), + mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ), 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( @@ -4535,10 +4683,15 @@ def test_get_feature_variable__returns_none_if_unable_to_cast(self): opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features)) mock_experiment = opt_obj.config_manager.get_config().get_experiment_from_key('test_experiment') mock_variation = opt_obj.config_manager.get_config().get_variation_from_id('test_experiment', '111129') + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + return_value=get_variation_for_feature_return_value, ), mock.patch( 'optimizely.project_config.ProjectConfig.get_typecast_value', side_effect=ValueError(), ), mock.patch.object( @@ -4737,6 +4890,57 @@ def test_odp_events_not_sent_with_legacy_apis(self): client.close() + def test_get_variation_with_cmab_uuid(self): + """ Test that get_variation works correctly with CMAB UUID. """ + expected_cmab_uuid = "get-variation-cmab-uuid" + variation_result = { + 'variation': self.project_config.get_variation_from_id('test_experiment', '111129'), + 'cmab_uuid': expected_cmab_uuid, + 'reasons': [], + 'error': False + } + + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation', + return_value=variation_result, + ), mock.patch('optimizely.notification_center.NotificationCenter.send_notifications') as mock_broadcast: + variation = self.optimizely.get_variation('test_experiment', 'test_user') + self.assertEqual('variation', variation) + + # Verify decision notification is sent with correct parameters + mock_broadcast.assert_any_call( + enums.NotificationTypes.DECISION, + 'ab-test', + 'test_user', + {}, + {'experiment_key': 'test_experiment', 'variation_key': 'variation'}, + ) + + def test_get_variation_without_cmab_uuid(self): + """ Test that get_variation works correctly when CMAB UUID is None. """ + variation_result = { + 'variation': self.project_config.get_variation_from_id('test_experiment', '111129'), + 'cmab_uuid': None, + 'reasons': [], + 'error': False + } + + with mock.patch( + 'optimizely.decision_service.DecisionService.get_variation', + return_value=variation_result, + ), mock.patch('optimizely.notification_center.NotificationCenter.send_notifications') as mock_broadcast: + variation = self.optimizely.get_variation('test_experiment', 'test_user') + self.assertEqual('variation', variation) + + # Verify decision notification is sent correctly + mock_broadcast.assert_any_call( + enums.NotificationTypes.DECISION, + 'ab-test', + 'test_user', + {}, + {'experiment_key': 'test_experiment', 'variation_key': 'variation'}, + ) + class OptimizelyWithExceptionTest(base.BaseTest): def setUp(self): @@ -4806,10 +5010,15 @@ def test_activate(self): variation_key = 'variation' experiment_key = 'test_experiment' user_id = 'test_user' - + variation_result = { + 'variation': self.project_config.get_variation_from_id('test_experiment', '111129'), + 'reasons': [], + 'cmab_uuid': None, + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + return_value=variation_result, ), mock.patch('time.time', return_value=42), mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ), mock.patch.object( @@ -4947,10 +5156,15 @@ def test_activate__empty_user_id(self): variation_key = 'variation' experiment_key = 'test_experiment' user_id = '' - + variation_result = { + 'cmab_uuid': None, + 'reasons': [], + 'error': False, + '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'), []), + return_value=variation_result ), mock.patch('time.time', return_value=42), mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ), mock.patch.object( @@ -5557,3 +5771,33 @@ def test_send_odp_event__default_type_when_empty_string(self): mock_send_event.assert_called_with('fullstack', 'great', {'amazing': 'fantastic'}, {}) mock_logger.error.assert_not_called() + + def test_decide_returns_error_decision_when_decision_service_fails(self): + """Test that decide returns error decision when CMAB decision service fails.""" + import copy + config_dict = copy.deepcopy(self.config_dict_with_features) + config_dict['experiments'][0]['cmab'] = {'attributeIds': ['808797688', '808797689'], 'trafficAllocation': 4000} + config_dict['experiments'][0]['trafficAllocation'] = [] + opt_obj = optimizely.Optimizely(json.dumps(config_dict)) + user_context = opt_obj.create_user_context('test_user') + + # Mock decision service to return an error from CMAB + error_decision_result = { + 'decision': decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT, None), + 'reasons': ['CMAB service failed to fetch decision'], + 'error': True + } + + with mock.patch.object( + opt_obj.decision_service, 'get_variations_for_feature_list', + return_value=[error_decision_result] + ): + # Call decide + decision = user_context.decide('test_feature_in_experiment') + print(decision.__dict__) + # Verify the decision contains the error information + self.assertFalse(decision.enabled) + self.assertIsNone(decision.variation_key) + self.assertIsNone(decision.rule_key) + self.assertEqual(decision.flag_key, 'test_feature_in_experiment') + self.assertIn('CMAB service failed to fetch decision', decision.reasons) diff --git a/tests/test_user_context.py b/tests/test_user_context.py index 6705e4142..3ae9be0dc 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -226,19 +226,15 @@ def test_decide__feature_test(self): mock_experiment = project_config.get_experiment_from_key('test_experiment') mock_variation = project_config.get_variation_from_id('test_experiment', '111129') - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variations_for_feature_list', - return_value=[ - ( - decision_service.Decision( - mock_experiment, - mock_variation, - enums.DecisionSources.FEATURE_TEST - ), - [] - ) - ] + return_value=[get_variation_for_feature_return_value] ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision, mock.patch( @@ -301,7 +297,8 @@ def test_decide__feature_test(self): 'feature-test', expected.enabled, 'test_user', - {'browser': 'chrome'} + {'browser': 'chrome'}, + None ) def test_decide__feature_test__send_flag_decision_false(self): @@ -311,19 +308,15 @@ def test_decide__feature_test__send_flag_decision_false(self): mock_experiment = project_config.get_experiment_from_key('test_experiment') mock_variation = project_config.get_variation_from_id('test_experiment', '111129') - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variations_for_feature_list', - return_value=[ - ( - decision_service.Decision( - mock_experiment, - mock_variation, - enums.DecisionSources.FEATURE_TEST - ), - [] - ) - ] + return_value=[get_variation_for_feature_return_value] ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision, mock.patch( @@ -408,7 +401,8 @@ def test_decide_feature_rollout(self): 'rollout', expected.enabled, 'test_user', - user_attributes + user_attributes, + None ) # assert notification count @@ -500,19 +494,15 @@ def test_decide_feature_null_variation(self): mock_experiment = None mock_variation = None - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.ROLLOUT, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variations_for_feature_list', - return_value=[ - ( - decision_service.Decision( - mock_experiment, - mock_variation, - enums.DecisionSources.ROLLOUT - ), - [] - ) - ] + return_value=[get_variation_for_feature_return_value] ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision, mock.patch( @@ -575,7 +565,8 @@ def test_decide_feature_null_variation(self): 'rollout', expected.enabled, 'test_user', - {'browser': 'chrome'} + {'browser': 'chrome'}, + None ) def test_decide_feature_null_variation__send_flag_decision_false(self): @@ -585,19 +576,15 @@ def test_decide_feature_null_variation__send_flag_decision_false(self): mock_experiment = None mock_variation = None - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.ROLLOUT, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variations_for_feature_list', - return_value=[ - ( - decision_service.Decision( - mock_experiment, - mock_variation, - enums.DecisionSources.ROLLOUT - ), - [] - ) - ] + return_value=[get_variation_for_feature_return_value] ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision, mock.patch( @@ -656,19 +643,15 @@ def test_decide__option__disable_decision_event(self): mock_experiment = project_config.get_experiment_from_key('test_experiment') mock_variation = project_config.get_variation_from_id('test_experiment', '111129') - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variations_for_feature_list', - return_value=[ - ( - decision_service.Decision( - mock_experiment, - mock_variation, - enums.DecisionSources.FEATURE_TEST - ), - [] - ) - ] + return_value=[get_variation_for_feature_return_value] ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision, mock.patch( @@ -730,19 +713,15 @@ def test_decide__default_option__disable_decision_event(self): mock_experiment = project_config.get_experiment_from_key('test_experiment') mock_variation = project_config.get_variation_from_id('test_experiment', '111129') - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variations_for_feature_list', - return_value=[ - ( - decision_service.Decision( - mock_experiment, - mock_variation, - enums.DecisionSources.FEATURE_TEST - ), - [] - ) - ] + return_value=[get_variation_for_feature_return_value] ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision, mock.patch( @@ -801,19 +780,15 @@ def test_decide__option__exclude_variables(self): mock_experiment = project_config.get_experiment_from_key('test_experiment') mock_variation = project_config.get_variation_from_id('test_experiment', '111129') - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variations_for_feature_list', - return_value=[ - ( - decision_service.Decision( - mock_experiment, - mock_variation, - enums.DecisionSources.FEATURE_TEST - ), - [] - ) - ] + return_value=[get_variation_for_feature_return_value] ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision, mock.patch( @@ -868,7 +843,8 @@ def test_decide__option__exclude_variables(self): 'feature-test', expected.enabled, 'test_user', - {'browser': 'chrome'} + {'browser': 'chrome'}, + None ) def test_decide__option__include_reasons__feature_test(self): @@ -907,19 +883,15 @@ def test_decide__option__enabled_flags_only(self): expected_experiment = project_config.get_experiment_from_key('211127') expected_var = project_config.get_variation_from_key('211127', '211229') - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(expected_experiment, expected_var, + enums.DecisionSources.ROLLOUT, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variations_for_feature_list', - return_value=[ - ( - decision_service.Decision( - expected_experiment, - expected_var, - enums.DecisionSources.ROLLOUT - ), - [] - ) - ] + return_value=[get_variation_for_feature_return_value] ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision, mock.patch( @@ -984,7 +956,8 @@ def test_decide__option__enabled_flags_only(self): 'rollout', expected.enabled, 'test_user', - user_attributes + user_attributes, + None ) def test_decide__default_options__with__options(self): @@ -996,19 +969,15 @@ def test_decide__default_options__with__options(self): mock_experiment = project_config.get_experiment_from_key('test_experiment') mock_variation = project_config.get_variation_from_id('test_experiment', '111129') - + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variations_for_feature_list', - return_value=[ - ( - decision_service.Decision( - mock_experiment, - mock_variation, - enums.DecisionSources.FEATURE_TEST - ), - [] - ) - ] + return_value=[get_variation_for_feature_return_value] ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision, mock.patch( @@ -1151,8 +1120,12 @@ def test_decide_for_keys__default_options__with__options(self): mock_decision.experiment = mock.MagicMock(key='test_experiment') mock_decision.variation = mock.MagicMock(key='variation') mock_decision.source = enums.DecisionSources.FEATURE_TEST - - mock_get_variations.return_value = [(mock_decision, [])] + get_variation_for_feature_return_value = { + 'decision': mock_decision, + 'reasons': [], + 'error': False + } + mock_get_variations.return_value = [get_variation_for_feature_return_value] user_context.decide_for_keys(flags, options) @@ -1416,18 +1389,15 @@ def test_decide_experiment(self): mock_experiment = project_config.get_experiment_from_key('test_experiment') mock_variation = project_config.get_variation_from_id('test_experiment', '111129') + get_variation_for_feature_return_value = { + 'decision': decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None), + 'reasons': [], + 'error': False + } with mock.patch( 'optimizely.decision_service.DecisionService.get_variations_for_feature_list', - return_value=[ - ( - decision_service.Decision( - mock_experiment, - mock_variation, - enums.DecisionSources.FEATURE_TEST - ), - [] - ), - ] + return_value=[get_variation_for_feature_return_value] ): user_context = opt_obj.create_user_context('test_user') decision = user_context.decide('test_feature_in_experiment', [DecideOption.DISABLE_DECISION_EVENT]) @@ -1546,7 +1516,8 @@ def test_should_return_valid_decision_after_setting_and_removing_forced_decision 'feature-test', expected.enabled, 'test_user', - {} + {}, + None ) self.assertTrue('User "test_user" is in variation "control" of experiment test_experiment.' diff --git a/tests/test_user_event_factory.py b/tests/test_user_event_factory.py index 009ef05dd..77f985d8e 100644 --- a/tests/test_user_event_factory.py +++ b/tests/test_user_event_factory.py @@ -29,7 +29,7 @@ def test_impression_event(self): user_id = 'test_user' impression_event = UserEventFactory.create_impression_event(project_config, experiment, '111128', '', - 'rule_key', 'rule_type', True, user_id, None) + 'rule_key', 'rule_type', True, user_id, None, None) self.assertEqual(self.project_config.project_id, impression_event.event_context.project_id) self.assertEqual(self.project_config.revision, impression_event.event_context.revision) @@ -51,7 +51,7 @@ def test_impression_event__with_attributes(self): user_attributes = {'test_attribute': 'test_value', 'boolean_key': True} impression_event = UserEventFactory.create_impression_event( - project_config, experiment, '111128', '', 'rule_key', 'rule_type', True, user_id, user_attributes + project_config, experiment, '111128', '', 'rule_key', 'rule_type', True, user_id, user_attributes, None ) expected_attrs = EventFactory.build_attribute_list(user_attributes, project_config) @@ -121,3 +121,119 @@ def test_conversion_event__with_event_tags(self): [x.__dict__ for x in expected_attrs], [x.__dict__ for x in conversion_event.visitor_attributes], ) self.assertEqual(event_tags, conversion_event.event_tags) + + def test_create_impression_user_event_with_cmab_uuid(self): + project_config = self.project_config + experiment = self.project_config.get_experiment_from_key('test_experiment') + variation = self.project_config.get_variation_from_id(experiment.key, '111128') + user_id = 'test_user' + cmab_uuid = '123e4567-e89b-12d3-a456-426614174000' + + impression_event = UserEventFactory.create_impression_event( + project_config, experiment, '111128', '', 'rule_key', 'rule_type', True, user_id, None, cmab_uuid + ) + + # Verify basic impression event properties + self.assertEqual(self.project_config.project_id, impression_event.event_context.project_id) + self.assertEqual(self.project_config.revision, impression_event.event_context.revision) + self.assertEqual(self.project_config.account_id, impression_event.event_context.account_id) + self.assertEqual( + self.project_config.anonymize_ip, impression_event.event_context.anonymize_ip, + ) + self.assertEqual(self.project_config.bot_filtering, impression_event.bot_filtering) + self.assertEqual(experiment, impression_event.experiment) + self.assertEqual(variation, impression_event.variation) + self.assertEqual(user_id, impression_event.user_id) + + # Verify CMAB UUID is properly set + self.assertEqual(cmab_uuid, impression_event.cmab_uuid) + + # Test that the CMAB UUID is included in the event payload when creating a log event + from optimizely.event.event_factory import EventFactory + log_event = EventFactory.create_log_event(impression_event, self.logger) + + self.assertIsNotNone(log_event) + event_params = log_event.params + + # Verify the event structure contains the CMAB UUID in metadata + self.assertIn('visitors', event_params) + self.assertEqual(len(event_params['visitors']), 1) + + visitor = event_params['visitors'][0] + self.assertIn('snapshots', visitor) + self.assertEqual(len(visitor['snapshots']), 1) + + snapshot = visitor['snapshots'][0] + self.assertIn('decisions', snapshot) + self.assertEqual(len(snapshot['decisions']), 1) + + decision = snapshot['decisions'][0] + self.assertIn('metadata', decision) + + metadata = decision['metadata'] + self.assertIn('cmab_uuid', metadata) + self.assertEqual(cmab_uuid, metadata['cmab_uuid']) + + # Verify other metadata fields are present + self.assertEqual('rule_key', metadata['rule_key']) + self.assertEqual('rule_type', metadata['rule_type']) + self.assertEqual(True, metadata['enabled']) + self.assertEqual(variation.key, metadata['variation_key']) + + def test_create_impression_user_event_without_cmab_uuid(self): + project_config = self.project_config + experiment = self.project_config.get_experiment_from_key('test_experiment') + variation = self.project_config.get_variation_from_id(experiment.key, '111128') + user_id = 'test_user' + + impression_event = UserEventFactory.create_impression_event( + project_config, experiment, '111128', '', 'rule_key', 'rule_type', True, user_id, None, None + ) + + # Verify basic impression event properties + self.assertEqual(self.project_config.project_id, impression_event.event_context.project_id) + self.assertEqual(self.project_config.revision, impression_event.event_context.revision) + self.assertEqual(self.project_config.account_id, impression_event.event_context.account_id) + self.assertEqual( + self.project_config.anonymize_ip, impression_event.event_context.anonymize_ip, + ) + self.assertEqual(self.project_config.bot_filtering, impression_event.bot_filtering) + self.assertEqual(experiment, impression_event.experiment) + self.assertEqual(variation, impression_event.variation) + self.assertEqual(user_id, impression_event.user_id) + + # Verify CMAB UUID is None when not provided + self.assertIsNone(impression_event.cmab_uuid) + + # Test that the CMAB UUID is not included in the event payload when creating a log event + from optimizely.event.event_factory import EventFactory + log_event = EventFactory.create_log_event(impression_event, self.logger) + + self.assertIsNotNone(log_event) + event_params = log_event.params + + # Verify the event structure does not contain CMAB UUID in metadata + self.assertIn('visitors', event_params) + self.assertEqual(len(event_params['visitors']), 1) + + visitor = event_params['visitors'][0] + self.assertIn('snapshots', visitor) + self.assertEqual(len(visitor['snapshots']), 1) + + snapshot = visitor['snapshots'][0] + self.assertIn('decisions', snapshot) + self.assertEqual(len(snapshot['decisions']), 1) + + decision = snapshot['decisions'][0] + self.assertIn('metadata', decision) + + metadata = decision['metadata'] + + # Verify CMAB UUID is not present in metadata when not provided + self.assertNotIn('cmab_uuid', metadata) + + # Verify other metadata fields are still present + self.assertEqual('rule_key', metadata['rule_key']) + self.assertEqual('rule_type', metadata['rule_type']) + self.assertEqual(True, metadata['enabled']) + self.assertEqual(variation.key, metadata['variation_key'])