From 88b4f1e4ca833d2e1534fcf52f488814882f7fc3 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 4 Jun 2025 08:27:56 +0600 Subject: [PATCH 01/38] update: integrate CMAB components into OptimizelyFactory --- optimizely/optimizely_factory.py | 49 ++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/optimizely/optimizely_factory.py b/optimizely/optimizely_factory.py index ae4669796..74d35f5c4 100644 --- a/optimizely/optimizely_factory.py +++ b/optimizely/optimizely_factory.py @@ -22,11 +22,18 @@ from .event_dispatcher import EventDispatcher, CustomEventDispatcher from .notification_center import NotificationCenter from .optimizely import Optimizely +from .cmab.cmab_client import DefaultCmabClient, CmabRetryConfig +from .cmab.cmab_service import DefaultCmabService, CmabCacheValue +from .odp.lru_cache import LRUCache if TYPE_CHECKING: # prevent circular dependenacy by skipping import at runtime from .user_profile import UserProfileService +# Default constants for CMAB cache +DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 * 1000 # 30 minutes in milliseconds +DEFAULT_CMAB_CACHE_SIZE = 1000 + class OptimizelyFactory: """ Optimizely factory to provides basic utility to instantiate the Optimizely @@ -36,6 +43,8 @@ class OptimizelyFactory: max_event_flush_interval: Optional[int] = None polling_interval: Optional[float] = None blocking_timeout: Optional[int] = None + cmab_cache_size: int = DEFAULT_CMAB_CACHE_SIZE + cmab_cache_timeout: int = DEFAULT_CMAB_CACHE_TIMEOUT @staticmethod def set_batch_size(batch_size: int) -> int: @@ -104,16 +113,36 @@ def default_instance(sdk_key: str, datafile: Optional[str] = None) -> Optimizely notification_center=notification_center, ) + # Initialize CMAB components + cmab_client = DefaultCmabClient( + retry_config=CmabRetryConfig(), + logger=logger + ) + cmab_cache: LRUCache[str, CmabCacheValue] = LRUCache(OptimizelyFactory.cmab_cache_size, + OptimizelyFactory.cmab_cache_timeout) + cmab_service = DefaultCmabService( + cmab_cache=cmab_cache, + cmab_client=cmab_client, + logger=logger + ) + optimizely = Optimizely( datafile, None, logger, error_handler, None, None, sdk_key, config_manager, notification_center, - event_processor + event_processor, cmab_service=cmab_service ) return optimizely @staticmethod def default_instance_with_config_manager(config_manager: BaseConfigManager) -> Optimizely: + # Initialize CMAB components + cmab_client = DefaultCmabClient(retry_config=CmabRetryConfig()) + cmab_cache: LRUCache[str, CmabCacheValue] = LRUCache(OptimizelyFactory.cmab_cache_size, + OptimizelyFactory.cmab_cache_timeout) + cmab_service = DefaultCmabService(cmab_cache=cmab_cache, cmab_client=cmab_client) + return Optimizely( - config_manager=config_manager + config_manager=config_manager, + cmab_service=cmab_service ) @staticmethod @@ -174,7 +203,21 @@ def custom_instance( notification_center=notification_center, ) + # Initialize CMAB components + cmab_client = DefaultCmabClient( + retry_config=CmabRetryConfig(), + logger=logger + ) + cmab_cache: LRUCache[str, CmabCacheValue] = LRUCache(OptimizelyFactory.cmab_cache_size, + OptimizelyFactory.cmab_cache_timeout) + cmab_service = DefaultCmabService( + cmab_cache=cmab_cache, + cmab_client=cmab_client, + logger=logger + ) + return Optimizely( datafile, event_dispatcher, logger, error_handler, skip_json_validation, user_profile_service, - sdk_key, config_manager, notification_center, event_processor, settings=settings + sdk_key, config_manager, notification_center, event_processor, settings=settings, + cmab_service=cmab_service ) From 2563c7bde057c4fed1f539badba402c04bf1bb29 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 4 Jun 2025 08:35:15 +0600 Subject: [PATCH 02/38] update: add cmab_service parameter to Optimizely constructor for CMAB support --- optimizely/optimizely.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index af4422244..954202479 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -44,6 +44,7 @@ from .optimizely_config import OptimizelyConfig, OptimizelyConfigService from .optimizely_user_context import OptimizelyUserContext, UserAttributes from .project_config import ProjectConfig +from .cmab.cmab_service import DefaultCmabService if TYPE_CHECKING: # prevent circular dependency by skipping import at runtime @@ -69,7 +70,8 @@ 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, + cmab_service: Optional[DefaultCmabService] = None ) -> None: """ Optimizely init method for managing Custom projects. @@ -98,6 +100,7 @@ def __init__( default_decide_options: Optional list of decide options used with the decide APIs. event_processor_options: Optional dict of options to be passed to the default batch event processor. settings: Optional instance of OptimizelySdkSettings for sdk configuration. + cmab_service: Optional instance of DefaultCmabService for Contextual Multi-Armed Bandit (CMAB) support. """ self.logger_name = '.'.join([__name__, self.__class__.__name__]) self.is_valid = True @@ -169,7 +172,10 @@ 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) + if cmab_service: + cmab_service.logger = self.logger + self.cmab_service = cmab_service + self.decision_service = decision_service.DecisionService(self.logger, user_profile_service, cmab_service) self.user_profile_service = user_profile_service def _validate_instantiation_options(self) -> None: From fac89468990d3f92bfc8ca678292f0dac2f46ca9 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 4 Jun 2025 08:36:03 +0600 Subject: [PATCH 03/38] update: add docstring to DefaultCmabService class for improved documentation --- optimizely/cmab/cmab_service.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 From f74bc8ca805cf964d63c340795a2e20f3c0a16b7 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 13 Jun 2025 21:52:49 +0600 Subject: [PATCH 04/38] update: implement CMAB support in bucketer and decision service, revert OptimizelyFactory --- optimizely/bucketer.py | 52 +++++++++++++++++ optimizely/decision_service.py | 99 ++++++++++++++++++++++++++++++-- optimizely/optimizely.py | 28 ++++++--- optimizely/optimizely_factory.py | 51 ++-------------- 4 files changed, 171 insertions(+), 59 deletions(-) diff --git a/optimizely/bucketer.py b/optimizely/bucketer.py index 38da3798e..8e8982d09 100644 --- a/optimizely/bucketer.py +++ b/optimizely/bucketer.py @@ -164,3 +164,55 @@ def bucket( decide_reasons.append(message) return None, decide_reasons + + def bucket_to_entity_id( + self, + bucketing_id: str, + experiment: Experiment, + traffic_allocations: list, + parent_id: Optional[str] = None + ) -> tuple[Optional[str], list[str]]: + """ + Buckets the user and returns the entity ID (for CMAB experiments). + Args: + bucketing_id: The bucketing ID string for the user. + experiment: The experiment object (for group/groupPolicy logic if needed). + traffic_allocations: List of traffic allocation dicts (should have 'entity_id' and 'end_of_range' keys). + parent_id: (optional) Used for mutex group support; if not supplied, experiment.id is used. + + Returns: + Tuple of (entity_id or None, list of decide reasons). + """ + decide_reasons = [] + + # If experiment is in a mutually exclusive group with random policy, check group bucketing first + group_id = getattr(experiment, 'groupId', None) + group_policy = getattr(experiment, 'groupPolicy', None) + if group_id and group_policy == 'random': + bucketing_key = f"{bucketing_id}{group_id}" + bucket_number = self._generate_bucket_value(bucketing_key) + # Group traffic allocation would need to be passed in or found here + # For now, skipping group-level allocation (you can extend this for mutex groups) + decide_reasons.append(f'Checked mutex group allocation for group "{group_id}".') + + # Main bucketing for experiment or CMAB dummy entity + parent_id = parent_id or experiment.id + bucketing_key = f"{bucketing_id}{parent_id}" + bucket_number = self._generate_bucket_value(bucketing_key) + decide_reasons.append( + f'Assigned bucket {bucket_number} to bucketing ID "{bucketing_id}" for parent "{parent_id}".' + ) + + for allocation in traffic_allocations: + end_of_range = allocation.get("end_of_range") or allocation.get("endOfRange") + entity_id = allocation.get("entity_id") or allocation.get("entityId") + if end_of_range is not None and bucket_number < end_of_range: + decide_reasons.append( + f'User with bucketing ID "{bucketing_id}" bucketed into entity "{entity_id}".' + ) + return entity_id, decide_reasons + + decide_reasons.append( + f'User with bucketing ID "{bucketing_id}" not bucketed into any entity.' + ) + return None, decide_reasons diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index df85464e6..0f0b2a524 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,32 @@ from .logger import Logger +class CmabDecisionResult(TypedDict): + error: bool + result: Optional[CmabDecision] + reasons: List[str] + + class Decision(NamedTuple): """Named tuple containing selected experiment, variation and source. 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 # Map of user IDs to another map of experiments to variations. # This contains all the forced variations set by the user @@ -76,6 +89,48 @@ 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, + 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. + options: Optional sequence of decide options. + + Returns: + A dictionary containing: + - "error": Boolean indicating if there was an error. + - "result": The CmabDecision result or empty dict if error. + - "reasons": List of strings with reasons or error messages. + """ + 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": [], + } + except Exception as e: + error_message = Errors.CMAB_FETCH_FAILED.format(str(e)) + if self.logger: + self.logger.error(error_message) + 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] @@ -313,7 +368,7 @@ def get_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, @@ -330,8 +385,42 @@ def get_variation( # 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 + + if experiment.cmab: + CMAB_DUMMY_ENTITY_ID = "$" + # Build the CMAB-specific traffic allocation + cmab_traffic_allocation = [{ + "entity_id": CMAB_DUMMY_ENTITY_ID, + "end_of_range": experiment.cmab['trafficAllocation'] + }] + + # Check if user is in CMAB traffic allocation + bucketed_entity_id, bucket_reasons = self.bucketer.bucket_to_entity_id( + bucketing_id, experiment, cmab_traffic_allocation + ) + decide_reasons += bucket_reasons + if bucketed_entity_id != CMAB_DUMMY_ENTITY_ID: + message = f'User "{user_id}" not in CMAB experiment "{experiment.key}" due to traffic allocation.' + self.logger.info(message) + decide_reasons.append(message) + return None, decide_reasons + + # User is in CMAB allocation, proceed to CMAB decision + decision_variation_value = self._get_decision_for_cmab_experiment(project_config, + experiment, + user_context, + options) + decide_reasons += decision_variation_value.get('reasons', []) + cmab_decision = decision_variation_value.get('result') + if not cmab_decision: + return None, decide_reasons + variation_id = cmab_decision['variation_id'] + variation = project_config.get_variation_from_id(experiment_key=experiment.key, variation_id=variation_id) + 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) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 954202479..7777a7893 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -44,13 +44,18 @@ from .optimizely_config import OptimizelyConfig, OptimizelyConfigService from .optimizely_user_context import OptimizelyUserContext, UserAttributes from .project_config import ProjectConfig -from .cmab.cmab_service import DefaultCmabService +from .cmab.cmab_client import DefaultCmabClient, CmabRetryConfig +from .cmab.cmab_service import DefaultCmabService, CmabCacheValue +from .odp.lru_cache import LRUCache 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. """ @@ -71,7 +76,6 @@ def __init__( default_decide_options: Optional[list[str]] = None, event_processor_options: Optional[dict[str, Any]] = None, settings: Optional[OptimizelySdkSettings] = None, - cmab_service: Optional[DefaultCmabService] = None ) -> None: """ Optimizely init method for managing Custom projects. @@ -100,7 +104,6 @@ def __init__( default_decide_options: Optional list of decide options used with the decide APIs. event_processor_options: Optional dict of options to be passed to the default batch event processor. settings: Optional instance of OptimizelySdkSettings for sdk configuration. - cmab_service: Optional instance of DefaultCmabService for Contextual Multi-Armed Bandit (CMAB) support. """ self.logger_name = '.'.join([__name__, self.__class__.__name__]) self.is_valid = True @@ -172,10 +175,21 @@ def __init__( self._setup_odp(self.config_manager.get_sdk_key()) self.event_builder = event_builder.EventBuilder() - if cmab_service: - cmab_service.logger = self.logger - self.cmab_service = cmab_service - self.decision_service = decision_service.DecisionService(self.logger, user_profile_service, cmab_service) + + # Initialize CMAB components + + self.cmab_client = DefaultCmabClient( + retry_config=CmabRetryConfig(), + logger=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: diff --git a/optimizely/optimizely_factory.py b/optimizely/optimizely_factory.py index 74d35f5c4..2b01c57a7 100644 --- a/optimizely/optimizely_factory.py +++ b/optimizely/optimizely_factory.py @@ -22,18 +22,11 @@ from .event_dispatcher import EventDispatcher, CustomEventDispatcher from .notification_center import NotificationCenter from .optimizely import Optimizely -from .cmab.cmab_client import DefaultCmabClient, CmabRetryConfig -from .cmab.cmab_service import DefaultCmabService, CmabCacheValue -from .odp.lru_cache import LRUCache if TYPE_CHECKING: # prevent circular dependenacy by skipping import at runtime from .user_profile import UserProfileService -# Default constants for CMAB cache -DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 * 1000 # 30 minutes in milliseconds -DEFAULT_CMAB_CACHE_SIZE = 1000 - class OptimizelyFactory: """ Optimizely factory to provides basic utility to instantiate the Optimizely @@ -43,8 +36,6 @@ class OptimizelyFactory: max_event_flush_interval: Optional[int] = None polling_interval: Optional[float] = None blocking_timeout: Optional[int] = None - cmab_cache_size: int = DEFAULT_CMAB_CACHE_SIZE - cmab_cache_timeout: int = DEFAULT_CMAB_CACHE_TIMEOUT @staticmethod def set_batch_size(batch_size: int) -> int: @@ -113,36 +104,16 @@ def default_instance(sdk_key: str, datafile: Optional[str] = None) -> Optimizely notification_center=notification_center, ) - # Initialize CMAB components - cmab_client = DefaultCmabClient( - retry_config=CmabRetryConfig(), - logger=logger - ) - cmab_cache: LRUCache[str, CmabCacheValue] = LRUCache(OptimizelyFactory.cmab_cache_size, - OptimizelyFactory.cmab_cache_timeout) - cmab_service = DefaultCmabService( - cmab_cache=cmab_cache, - cmab_client=cmab_client, - logger=logger - ) - optimizely = Optimizely( datafile, None, logger, error_handler, None, None, sdk_key, config_manager, notification_center, - event_processor, cmab_service=cmab_service + event_processor ) return optimizely @staticmethod def default_instance_with_config_manager(config_manager: BaseConfigManager) -> Optimizely: - # Initialize CMAB components - cmab_client = DefaultCmabClient(retry_config=CmabRetryConfig()) - cmab_cache: LRUCache[str, CmabCacheValue] = LRUCache(OptimizelyFactory.cmab_cache_size, - OptimizelyFactory.cmab_cache_timeout) - cmab_service = DefaultCmabService(cmab_cache=cmab_cache, cmab_client=cmab_client) - return Optimizely( - config_manager=config_manager, - cmab_service=cmab_service + config_manager=config_manager ) @staticmethod @@ -203,21 +174,7 @@ def custom_instance( notification_center=notification_center, ) - # Initialize CMAB components - cmab_client = DefaultCmabClient( - retry_config=CmabRetryConfig(), - logger=logger - ) - cmab_cache: LRUCache[str, CmabCacheValue] = LRUCache(OptimizelyFactory.cmab_cache_size, - OptimizelyFactory.cmab_cache_timeout) - cmab_service = DefaultCmabService( - cmab_cache=cmab_cache, - cmab_client=cmab_client, - logger=logger - ) - return Optimizely( datafile, event_dispatcher, logger, error_handler, skip_json_validation, user_profile_service, - sdk_key, config_manager, notification_center, event_processor, settings=settings, - cmab_service=cmab_service - ) + sdk_key, config_manager, notification_center, event_processor, settings=settings + ) \ No newline at end of file From 6d1f73db6b3c6fd300c4b2bae992730b31bde878 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 13 Jun 2025 21:55:07 +0600 Subject: [PATCH 05/38] linting fix --- optimizely/optimizely.py | 6 ++---- optimizely/optimizely_factory.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 7777a7893..62cb8e74c 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -46,7 +46,6 @@ from .project_config import ProjectConfig from .cmab.cmab_client import DefaultCmabClient, CmabRetryConfig from .cmab.cmab_service import DefaultCmabService, CmabCacheValue -from .odp.lru_cache import LRUCache if TYPE_CHECKING: # prevent circular dependency by skipping import at runtime @@ -57,6 +56,7 @@ DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 * 1000 # 30 minutes in milliseconds DEFAULT_CMAB_CACHE_SIZE = 1000 + class Optimizely: """ Class encapsulating all SDK functionality. """ @@ -177,13 +177,11 @@ def __init__( self.event_builder = event_builder.EventBuilder() # Initialize CMAB components - self.cmab_client = DefaultCmabClient( retry_config=CmabRetryConfig(), logger=logger ) - self.cmab_cache: LRUCache[str, CmabCacheValue] = LRUCache(DEFAULT_CMAB_CACHE_SIZE, - DEFAULT_CMAB_CACHE_TIMEOUT) + 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, diff --git a/optimizely/optimizely_factory.py b/optimizely/optimizely_factory.py index 2b01c57a7..ae4669796 100644 --- a/optimizely/optimizely_factory.py +++ b/optimizely/optimizely_factory.py @@ -177,4 +177,4 @@ def custom_instance( return Optimizely( datafile, event_dispatcher, logger, error_handler, skip_json_validation, user_profile_service, sdk_key, config_manager, notification_center, event_processor, settings=settings - ) \ No newline at end of file + ) From 91d53b69efabb6a8d3d62d1a589c41fb8a4bacbb Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Mon, 16 Jun 2025 14:03:38 +0600 Subject: [PATCH 06/38] update: add cmab_uuid handling in DecisionService and related tests --- optimizely/decision_service.py | 49 ++++++++++++++++-------------- optimizely/optimizely.py | 8 ++--- tests/test_decision_service.py | 54 +++++++++++++++++++--------------- 3 files changed, 61 insertions(+), 50 deletions(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index 0f0b2a524..2b413429a 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -44,7 +44,7 @@ class Decision(NamedTuple): experiment: Optional[entities.Experiment] variation: Optional[entities.Variation] source: Optional[str] - # cmab_uuid: Optional[str] + cmab_uuid: Optional[str] class DecisionService: @@ -58,6 +58,7 @@ def __init__(self, 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 @@ -305,7 +306,7 @@ def get_variation( user_profile_tracker: Optional[UserProfileTracker], reasons: list[str] = [], options: Optional[Sequence[str]] = None - ) -> tuple[Optional[entities.Variation], list[str]]: + ) -> tuple[Optional[entities.Variation], list[str], Optional[str]]: """ Top-level function to help determine variation user should be put in. First, check if experiment is running. @@ -323,11 +324,12 @@ def get_variation( 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. + Variation user should see. None if user is not in experiment or experiment is not running, + an array of log messages representing decision making + and a cmab_uuid if experiment is cmab-experiment """ user_id = user_context.user_id - + cmab_uuid = None if options: ignore_user_profile = OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE in options else: @@ -341,20 +343,20 @@ def get_variation( message = f'Experiment "{experiment.key}" is not running.' self.logger.info(message) decide_reasons.append(message) - return None, decide_reasons + return None, decide_reasons, cmab_uuid # 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 variation, decide_reasons, cmab_uuid # 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 variation, decide_reasons, cmab_uuid # 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: @@ -364,7 +366,7 @@ 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 variation, decide_reasons, cmab_uuid else: self.logger.warning('User profile has invalid format.') @@ -380,7 +382,7 @@ 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 None, decide_reasons, cmab_uuid # Determine bucketing ID to be used bucketing_id, bucketing_id_reasons = self._get_bucketing_id(user_id, user_context.get_user_attributes()) @@ -403,7 +405,7 @@ def get_variation( message = f'User "{user_id}" not in CMAB experiment "{experiment.key}" due to traffic allocation.' self.logger.info(message) decide_reasons.append(message) - return None, decide_reasons + return None, decide_reasons, cmab_uuid # User is in CMAB allocation, proceed to CMAB decision decision_variation_value = self._get_decision_for_cmab_experiment(project_config, @@ -413,8 +415,9 @@ def get_variation( decide_reasons += decision_variation_value.get('reasons', []) cmab_decision = decision_variation_value.get('result') if not cmab_decision: - return None, decide_reasons + return None, decide_reasons, cmab_uuid variation_id = cmab_decision['variation_id'] + cmab_uuid = cmab_decision['cmab_uuid'] variation = project_config.get_variation_from_id(experiment_key=experiment.key, variation_id=variation_id) else: # Bucket the user @@ -431,11 +434,11 @@ 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 variation, decide_reasons, cmab_uuid message = f'User "{user_id}" is in no variation.' self.logger.info(message) decide_reasons.append(message) - return None, decide_reasons + return None, decide_reasons, cmab_uuid def get_variation_for_rollout( self, project_config: ProjectConfig, feature: entities.FeatureFlag, user_context: OptimizelyUserContext @@ -459,7 +462,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) @@ -467,7 +470,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) @@ -475,7 +478,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): @@ -490,7 +493,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 @@ -524,7 +527,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 @@ -544,7 +547,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, @@ -680,8 +683,9 @@ def get_variations_for_feature_list( if forced_decision_variation: decision_variation = forced_decision_variation + cmab_uuid = None else: - decision_variation, variation_reasons = self.get_variation( + decision_variation, variation_reasons, cmab_uuid = self.get_variation( project_config, experiment, user_context, user_profile_tracker, feature_reasons, options ) feature_reasons.extend(variation_reasons) @@ -691,7 +695,8 @@ def get_variations_for_feature_list( 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) + decision = Decision(experiment, decision_variation, + enums.DecisionSources.FEATURE_TEST, cmab_uuid) decisions.append((decision, feature_reasons)) experiment_decision_found = True # Mark that a decision was found break # Stop after the first successful experiment decision diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 62cb8e74c..f66d9dc45 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -652,10 +652,8 @@ 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, _, _ = self.decision_service.get_variation(project_config, experiment, + user_context, user_profile_tracker) user_profile_tracker.save_user_profile() if variation: variation_key = variation.key @@ -1356,7 +1354,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) diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py index 6c5862a53..fcf70d965 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -457,7 +457,7 @@ 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, _, _ = self.decision_service.get_variation( self.project_config, experiment, user, None ) self.assertIsNone( @@ -500,7 +500,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( + variation, _, _ = self.decision_service.get_variation( self.project_config, experiment, user, @@ -535,7 +535,7 @@ 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 ) self.assertEqual( @@ -573,7 +573,7 @@ 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 ) self.assertEqual( @@ -619,7 +619,7 @@ 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 ) self.assertEqual( @@ -669,7 +669,7 @@ 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 ) self.assertIsNone( @@ -719,7 +719,7 @@ 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, @@ -779,7 +779,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 +810,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 +853,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 +894,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 +948,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,7 +1015,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_experiment( ) decision_patch = mock.patch( "optimizely.decision_service.DecisionService.get_variation", - return_value=[expected_variation, []], + return_value=[expected_variation, [], None], ) with decision_patch as mock_decision, self.mock_decision_logger: variation_received, _ = self.decision_service.get_variation_for_feature( @@ -1024,6 +1026,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_experiment( expected_experiment, expected_variation, enums.DecisionSources.FEATURE_TEST, + None ), variation_received, ) @@ -1104,6 +1107,7 @@ def test_get_variation_for_feature__returns_variation_if_user_not_in_experiment_ expected_experiment, expected_variation, enums.DecisionSources.ROLLOUT, + None ), decision, ) @@ -1143,7 +1147,7 @@ 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=(expected_variation, [], None), ) as mock_decision: variation_received, _ = self.decision_service.get_variation_for_feature( self.project_config, feature, user, options=None @@ -1153,6 +1157,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_group(self) expected_experiment, expected_variation, enums.DecisionSources.FEATURE_TEST, + None ), variation_received, ) @@ -1177,13 +1182,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=[None, [], None], ) as mock_decision: variation_received, _ = self.decision_service.get_variation_for_feature( 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, ) @@ -1209,13 +1214,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=[None, [], None], ) as mock_decision: variation_received, _ = self.decision_service.get_variation_for_feature( self.project_config, feature, user, False ) self.assertEqual( - decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), + decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT, None), variation_received, ) @@ -1249,6 +1254,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_mutex_group expected_experiment, expected_variation, enums.DecisionSources.FEATURE_TEST, + None ), variation_received, ) @@ -1283,6 +1289,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_mutex_group expected_experiment, expected_variation, enums.DecisionSources.FEATURE_TEST, + None ), variation_received, ) @@ -1317,6 +1324,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_mutex_group expected_experiment, expected_variation, enums.DecisionSources.FEATURE_TEST, + None ), variation_received, ) @@ -1346,6 +1354,7 @@ def test_get_variation_for_feature__returns_variation_for_rollout_in_mutex_group None, None, enums.DecisionSources.ROLLOUT, + None ), variation_received, ) @@ -1380,6 +1389,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_experiment_ expected_experiment, expected_variation, enums.DecisionSources.FEATURE_TEST, + None ), variation_received, ) @@ -1412,6 +1422,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_experiment_ expected_experiment, expected_variation, enums.DecisionSources.FEATURE_TEST, + None ), variation_received, ) @@ -1445,6 +1456,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_experiment_ expected_experiment, expected_variation, enums.DecisionSources.FEATURE_TEST, + None ), variation_received, ) @@ -1473,6 +1485,7 @@ def test_get_variation_for_feature__returns_variation_for_rollout_in_experiment_ None, None, enums.DecisionSources.ROLLOUT, + None ), variation_received, ) @@ -1507,6 +1520,7 @@ def test_get_variation_for_feature__returns_variation_for_rollout_in_mutex_group expected_experiment, expected_variation, enums.DecisionSources.ROLLOUT, + None ), variation_received, ) @@ -1538,18 +1552,12 @@ def test_get_variation_for_feature_returns_rollout_in_experiment_bucket_range_25 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}") self.assertEqual( decision_service.Decision( expected_experiment, expected_variation, enums.DecisionSources.ROLLOUT, + None ), variation_received, ) From 3eb755fe43b62a58a5cc1f7499eb1517a01a4cde Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Mon, 16 Jun 2025 20:45:28 +0600 Subject: [PATCH 07/38] - updated function bucket_to_entity_id - test_optimizely.py fixed to expect new Decision objects --- optimizely/bucketer.py | 64 +++++++----- optimizely/decision_service.py | 7 +- tests/test_optimizely.py | 178 +++++++++++++++++---------------- 3 files changed, 133 insertions(+), 116 deletions(-) diff --git a/optimizely/bucketer.py b/optimizely/bucketer.py index 8e8982d09..66d5f61a0 100644 --- a/optimizely/bucketer.py +++ b/optimizely/bucketer.py @@ -28,7 +28,7 @@ if TYPE_CHECKING: # prevent circular dependenacy by skipping import at runtime from .project_config import ProjectConfig - from .entities import Experiment, Variation + from .entities import Experiment, Variation, Group from .helpers.types import TrafficAllocation @@ -170,7 +170,7 @@ def bucket_to_entity_id( bucketing_id: str, experiment: Experiment, traffic_allocations: list, - parent_id: Optional[str] = None + group: Optional[Group] = None ) -> tuple[Optional[str], list[str]]: """ Buckets the user and returns the entity ID (for CMAB experiments). @@ -178,41 +178,53 @@ def bucket_to_entity_id( bucketing_id: The bucketing ID string for the user. experiment: The experiment object (for group/groupPolicy logic if needed). traffic_allocations: List of traffic allocation dicts (should have 'entity_id' and 'end_of_range' keys). - parent_id: (optional) Used for mutex group support; if not supplied, experiment.id is used. + group: (optional) Group object for mutex group support. Returns: Tuple of (entity_id or None, list of decide reasons). """ decide_reasons = [] - # If experiment is in a mutually exclusive group with random policy, check group bucketing first group_id = getattr(experiment, 'groupId', None) - group_policy = getattr(experiment, 'groupPolicy', None) - if group_id and group_policy == 'random': - bucketing_key = f"{bucketing_id}{group_id}" - bucket_number = self._generate_bucket_value(bucketing_key) - # Group traffic allocation would need to be passed in or found here - # For now, skipping group-level allocation (you can extend this for mutex groups) - decide_reasons.append(f'Checked mutex group allocation for group "{group_id}".') - - # Main bucketing for experiment or CMAB dummy entity - parent_id = parent_id or experiment.id - bucketing_key = f"{bucketing_id}{parent_id}" - bucket_number = self._generate_bucket_value(bucketing_key) - decide_reasons.append( - f'Assigned bucket {bucket_number} to bucketing ID "{bucketing_id}" for parent "{parent_id}".' - ) + if group_id and group and getattr(group, 'policy', None) == 'random': + bucket_key = bucketing_id + group_id + bucket_val = self._generate_bucket_value(bucket_key) + decide_reasons.append(f'Generated group bucket value {bucket_val} for key "{bucket_key}".') + + matched = False + for allocation in group.trafficAllocation: + end_of_range = allocation.get("endOfRange", 0) + entity_id = allocation.get("entityId") + if bucket_val < end_of_range: + matched = True + if entity_id != experiment.id: + decide_reasons.append( + f'User not bucketed into experiment "{experiment.id}" (got "{entity_id}").' + ) + return None, decide_reasons + decide_reasons.append( + f'User is bucketed into experiment "{experiment.id}" within group "{group_id}".' + ) + break + if not matched: + decide_reasons.append( + f'User not bucketed into any experiment in group "{group_id}".' + ) + return None, decide_reasons + + # Main experiment bucketing + bucket_key = bucketing_id + experiment.id + bucket_val = self._generate_bucket_value(bucket_key) + decide_reasons.append(f'Generated experiment bucket value {bucket_val} for key "{bucket_key}".') for allocation in traffic_allocations: - end_of_range = allocation.get("end_of_range") or allocation.get("endOfRange") - entity_id = allocation.get("entity_id") or allocation.get("entityId") - if end_of_range is not None and bucket_number < end_of_range: + end_of_range = allocation.get("end_of_range", 0) + entity_id = allocation.get("entity_id") + if bucket_val < end_of_range: decide_reasons.append( - f'User with bucketing ID "{bucketing_id}" bucketed into entity "{entity_id}".' + f'User bucketed into entity id "{entity_id}".' ) return entity_id, decide_reasons - decide_reasons.append( - f'User with bucketing ID "{bucketing_id}" not bucketed into any entity.' - ) + decide_reasons.append('User not bucketed into any entity id.') return None, decide_reasons diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index 2b413429a..d160348df 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -39,7 +39,7 @@ class CmabDecisionResult(TypedDict): 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] @@ -397,8 +397,11 @@ def get_variation( }] # Check if user is in CMAB traffic allocation + group = None + if experiment.groupId: + group = project_config.get_group(group_id=experiment.groupId) bucketed_entity_id, bucket_reasons = self.bucketer.bucket_to_entity_id( - bucketing_id, experiment, cmab_traffic_allocation + bucketing_id, experiment, cmab_traffic_allocation, group ) decide_reasons += bucket_reasons if bucketed_entity_id != CMAB_DUMMY_ENTITY_ID: diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index 1f4293cdd..a6ab34c9e 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -322,7 +322,7 @@ def test_activate(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), [], None), ) as mock_decision, mock.patch('time.time', return_value=42), mock.patch( 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch( @@ -404,7 +404,7 @@ def on_activate(experiment, user_id, attributes, variation, event): ) with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), [], None), ), mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process'): self.assertEqual('variation', self.optimizely.activate('test_experiment', 'test_user')) @@ -462,11 +462,11 @@ def on_activate(event_key, user_id, attributes, event_tags, event): pass self.optimizely.notification_center.add_notification_listener(enums.NotificationTypes.ACTIVATE, on_activate) - variation = (self.project_config.get_variation_from_id('test_experiment', '111129'), []) + return_tuple = (self.project_config.get_variation_from_id('test_experiment', '111129'), [], None) with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=variation, + return_value=return_tuple, ), mock.patch('optimizely.event.event_processor.BatchEventProcessor.process') as mock_process, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast: @@ -483,7 +483,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': return_tuple[0].key}, ), mock.call( enums.NotificationTypes.ACTIVATE, @@ -503,7 +503,7 @@ 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 = (self.project_config.get_variation_from_id('test_experiment', '111129'), [], None) with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', @@ -554,7 +554,7 @@ def test_decision_listener__user_not_in_experiment(self): when user not in experiment. """ with mock.patch('optimizely.decision_service.DecisionService.get_variation', - return_value=(None, []), ), mock.patch( + return_value=(None, [], None), ), mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' @@ -671,7 +671,7 @@ def on_activate(experiment, user_id, attributes, variation, event): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', return_value=( - decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST), []), + decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ) as mock_decision, mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process'): self.assertTrue(opt_obj.is_feature_enabled('test_feature_in_experiment', 'test_user')) @@ -699,7 +699,7 @@ def on_activate(experiment, user_id, attributes, variation, event): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process: @@ -718,7 +718,7 @@ def test_activate__with_attributes__audience_match(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), [], None), ) as mock_get_variation, mock.patch('time.time', return_value=42), mock.patch( 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch( @@ -1063,7 +1063,7 @@ def test_activate__with_attributes__audience_match__bucketing_id_provided(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), [], None), ) as mock_get_variation, mock.patch('time.time', return_value=42), mock.patch( 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' ), mock.patch( @@ -1802,7 +1802,7 @@ def test_get_variation(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), [], None), ), mock.patch('optimizely.notification_center.NotificationCenter.send_notifications') as mock_broadcast: variation = self.optimizely.get_variation('test_experiment', 'test_user') self.assertEqual( @@ -1824,7 +1824,7 @@ def test_get_variation_lookup_and_save_is_called(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), [], None), ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast, mock.patch( @@ -1857,7 +1857,7 @@ def test_get_variation_with_experiment_in_feature(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=(project_config.get_variation_from_id('test_experiment', '111129'), []), + return_value=(project_config.get_variation_from_id('test_experiment', '111129'), [], None), ), mock.patch('optimizely.notification_center.NotificationCenter.send_notifications') as mock_broadcast: variation = opt_obj.get_variation('test_experiment', 'test_user') self.assertEqual('variation', variation) @@ -1876,7 +1876,7 @@ def test_get_variation__returns_none(self): """ Test that get_variation returns no variation and broadcasts decision with proper parameters. """ with mock.patch('optimizely.decision_service.DecisionService.get_variation', - return_value=(None, []), ), mock.patch( + return_value=(None, [], None), ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast: self.assertEqual( @@ -2035,7 +2035,7 @@ def test_is_feature_enabled__returns_true_for_feature_experiment_if_feature_enab with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process, mock.patch( @@ -2135,7 +2135,7 @@ def test_is_feature_enabled__returns_false_for_feature_experiment_if_feature_dis with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.FEATURE_TEST), []), + mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process, mock.patch( @@ -2235,7 +2235,7 @@ def test_is_feature_enabled__returns_true_for_feature_rollout_if_feature_enabled with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process, mock.patch( @@ -2285,7 +2285,7 @@ def test_is_feature_enabled__returns_true_for_feature_rollout_if_feature_enabled with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process, mock.patch( @@ -2387,7 +2387,7 @@ def test_is_feature_enabled__returns_false_for_feature_rollout_if_feature_disabl with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process, mock.patch( @@ -2429,7 +2429,7 @@ def test_is_feature_enabled__returns_false_when_user_is_not_bucketed_into_any_va feature = project_config.get_feature_from_key('test_feature_in_experiment') with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT, None), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process, mock.patch( @@ -2473,7 +2473,7 @@ def test_is_feature_enabled__returns_false_when_variation_is_nil(self, ): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT, None), []), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process, mock.patch( @@ -2580,14 +2580,16 @@ def side_effect(*args, **kwargs): response = None if feature.key == 'test_feature_in_experiment': response = decision_service.Decision(mock_experiment, mock_variation, - enums.DecisionSources.FEATURE_TEST) + 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_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, ) + 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_service.Decision(mock_experiment, mock_variation_2, + enums.DecisionSources.ROLLOUT, None) return (response, []) @@ -2714,7 +2716,7 @@ def test_get_feature_variable_boolean(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_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2752,7 +2754,7 @@ def test_get_feature_variable_double(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_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2790,7 +2792,7 @@ def test_get_feature_variable_integer(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_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2828,7 +2830,7 @@ def test_get_feature_variable_string(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_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2867,7 +2869,7 @@ def test_get_feature_variable_json(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_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2914,7 +2916,7 @@ def test_get_all_feature_variables(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_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -2971,7 +2973,7 @@ def test_get_feature_variable(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_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3000,7 +3002,7 @@ def test_get_feature_variable(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_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3031,7 +3033,7 @@ def test_get_feature_variable(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_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3062,7 +3064,7 @@ def test_get_feature_variable(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_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3094,7 +3096,7 @@ def test_get_feature_variable(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_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3134,7 +3136,7 @@ def test_get_feature_variable_boolean_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3176,7 +3178,7 @@ def test_get_feature_variable_double_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3218,7 +3220,7 @@ def test_get_feature_variable_integer_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3260,7 +3262,7 @@ def test_get_feature_variable_string_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3302,7 +3304,7 @@ def test_get_feature_variable_json_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3344,7 +3346,7 @@ def test_get_all_feature_variables_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3402,7 +3404,7 @@ def test_get_feature_variable_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3435,7 +3437,7 @@ def test_get_feature_variable_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3468,7 +3470,7 @@ def test_get_feature_variable_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3501,7 +3503,7 @@ def test_get_feature_variable_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3535,7 +3537,7 @@ def test_get_feature_variable_for_feature_in_rollout(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', return_value=(decision_service.Decision(mock_experiment, - mock_variation, enums.DecisionSources.ROLLOUT), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ), mock.patch.object(opt_obj, 'logger') as mock_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3579,7 +3581,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), []), + mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ): self.assertTrue( opt_obj.get_feature_variable_boolean('test_feature_in_experiment', 'is_working', 'test_user') @@ -3589,7 +3591,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), []), + mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ): self.assertEqual( 10.99, opt_obj.get_feature_variable_double('test_feature_in_experiment', 'cost', 'test_user'), @@ -3599,7 +3601,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), []), + mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ): self.assertEqual( 999, opt_obj.get_feature_variable_integer('test_feature_in_experiment', 'count', 'test_user'), @@ -3609,7 +3611,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), []), + mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ): self.assertEqual( 'devel', opt_obj.get_feature_variable_string('test_feature_in_experiment', 'environment', 'test_user'), @@ -3619,7 +3621,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), []), + mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ): self.assertEqual( {"test": 12}, opt_obj.get_feature_variable_json('test_feature_in_experiment', 'object', 'test_user'), @@ -3629,14 +3631,14 @@ 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), []), + mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ): 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), []), + mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ): self.assertEqual( 10.99, opt_obj.get_feature_variable('test_feature_in_experiment', 'cost', 'test_user'), @@ -3645,7 +3647,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), []), + mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ): self.assertEqual( 999, opt_obj.get_feature_variable('test_feature_in_experiment', 'count', 'test_user'), @@ -3654,7 +3656,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), []), + mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ): self.assertEqual( 'devel', opt_obj.get_feature_variable('test_feature_in_experiment', 'environment', 'test_user'), @@ -3669,7 +3671,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # Boolean with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT, None), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3703,7 +3705,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # Double with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT, None), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3737,7 +3739,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # Integer with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT, None), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3772,7 +3774,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # String with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT, None), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3806,7 +3808,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # JSON with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT, None), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3840,7 +3842,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): # Non-typed with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT, None), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3871,7 +3873,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT, None), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3904,7 +3906,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT, None), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -3937,7 +3939,7 @@ def test_get_feature_variable__returns_default_value_if_no_variation(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', - return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT), []), + return_value=(decision_service.Decision(None, None, enums.DecisionSources.ROLLOUT, None), []), ), mock.patch.object(opt_obj, 'logger') as mock_client_logger, mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision: @@ -4250,7 +4252,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), []), + mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ), 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') @@ -4265,7 +4267,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), []), + mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ), 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'), @@ -4280,7 +4282,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), []), + mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ), 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'), @@ -4295,7 +4297,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), []), + mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ), 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'), @@ -4310,7 +4312,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), []), + mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ), 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'), @@ -4325,7 +4327,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), []), + mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ), 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')) @@ -4337,7 +4339,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), []), + mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ), 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'), @@ -4351,7 +4353,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), []), + mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ), 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'), @@ -4365,7 +4367,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), []), + mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), ), 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'), @@ -4387,7 +4389,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), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ), 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')) @@ -4400,7 +4402,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), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ), 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'), @@ -4415,7 +4417,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), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ), 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'), @@ -4430,7 +4432,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), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ), 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'), @@ -4444,7 +4446,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), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ), 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'), @@ -4458,7 +4460,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), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ), 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')) @@ -4470,7 +4472,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), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ), 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'), @@ -4484,7 +4486,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), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ), 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'), @@ -4498,7 +4500,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), []), + mock_variation, enums.DecisionSources.ROLLOUT, None), []), ), 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 +4519,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( @@ -4538,7 +4540,7 @@ def test_get_feature_variable__returns_none_if_unable_to_cast(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( 'optimizely.project_config.ProjectConfig.get_typecast_value', side_effect=ValueError(), ), mock.patch.object( @@ -4809,7 +4811,7 @@ def test_activate(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), [], None), ), mock.patch('time.time', return_value=42), mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ), mock.patch.object( @@ -4950,7 +4952,7 @@ def test_activate__empty_user_id(self): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation', - return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), []), + return_value=(self.project_config.get_variation_from_id('test_experiment', '111129'), [], None), ), mock.patch('time.time', return_value=42), mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ), mock.patch.object( From a5e4993452a2da009e03e67c979584eccbc99072 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Mon, 16 Jun 2025 20:58:36 +0600 Subject: [PATCH 08/38] update: add None parameter to Decision constructor in user context tests --- tests/test_user_context.py | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/tests/test_user_context.py b/tests/test_user_context.py index 6705e4142..c238ad122 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -234,7 +234,8 @@ def test_decide__feature_test(self): decision_service.Decision( mock_experiment, mock_variation, - enums.DecisionSources.FEATURE_TEST + enums.DecisionSources.FEATURE_TEST, + None ), [] ) @@ -319,7 +320,8 @@ def test_decide__feature_test__send_flag_decision_false(self): decision_service.Decision( mock_experiment, mock_variation, - enums.DecisionSources.FEATURE_TEST + enums.DecisionSources.FEATURE_TEST, + None ), [] ) @@ -508,7 +510,8 @@ def test_decide_feature_null_variation(self): decision_service.Decision( mock_experiment, mock_variation, - enums.DecisionSources.ROLLOUT + enums.DecisionSources.ROLLOUT, + None ), [] ) @@ -593,7 +596,8 @@ def test_decide_feature_null_variation__send_flag_decision_false(self): decision_service.Decision( mock_experiment, mock_variation, - enums.DecisionSources.ROLLOUT + enums.DecisionSources.ROLLOUT, + None ), [] ) @@ -664,7 +668,8 @@ def test_decide__option__disable_decision_event(self): decision_service.Decision( mock_experiment, mock_variation, - enums.DecisionSources.FEATURE_TEST + enums.DecisionSources.FEATURE_TEST, + None ), [] ) @@ -738,7 +743,8 @@ def test_decide__default_option__disable_decision_event(self): decision_service.Decision( mock_experiment, mock_variation, - enums.DecisionSources.FEATURE_TEST + enums.DecisionSources.FEATURE_TEST, + None ), [] ) @@ -809,7 +815,8 @@ def test_decide__option__exclude_variables(self): decision_service.Decision( mock_experiment, mock_variation, - enums.DecisionSources.FEATURE_TEST + enums.DecisionSources.FEATURE_TEST, + None ), [] ) @@ -915,7 +922,8 @@ def test_decide__option__enabled_flags_only(self): decision_service.Decision( expected_experiment, expected_var, - enums.DecisionSources.ROLLOUT + enums.DecisionSources.ROLLOUT, + None ), [] ) @@ -1004,7 +1012,8 @@ def test_decide__default_options__with__options(self): decision_service.Decision( mock_experiment, mock_variation, - enums.DecisionSources.FEATURE_TEST + enums.DecisionSources.FEATURE_TEST, + None ), [] ) @@ -1423,7 +1432,8 @@ def test_decide_experiment(self): decision_service.Decision( mock_experiment, mock_variation, - enums.DecisionSources.FEATURE_TEST + enums.DecisionSources.FEATURE_TEST, + None ), [] ), From c1cd97ab6ac2c950e3c7f393fb7cb7a10c145a78 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Mon, 16 Jun 2025 21:53:55 +0600 Subject: [PATCH 09/38] update: enhance CMAB decision handling and add related tests --- optimizely/decision_service.py | 3 +- tests/test_decision_service.py | 270 +++++++++++++++++++++++++++++++++ tests/test_optimizely.py | 3 +- 3 files changed, 274 insertions(+), 2 deletions(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index d160348df..6c9bb7fcd 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -417,7 +417,8 @@ def get_variation( options) decide_reasons += decision_variation_value.get('reasons', []) cmab_decision = decision_variation_value.get('result') - if not cmab_decision: + if not cmab_decision or decision_variation_value['error']: + self.logger.error(Errors.CMAB_FETCH_FAILED.format(decide_reasons[0])) return None, decide_reasons, cmab_uuid variation_id = cmab_decision['variation_id'] cmab_uuid = cmab_decision['cmab_uuid'] diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py index fcf70d965..f9a98f85d 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -750,6 +750,276 @@ 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} + ) + + cmab_decision = { + 'variation_id': '111151', + 'cmab_uuid': 'test-cmab-uuid-123' + } + + 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': False, 'result': cmab_decision, 'reasons': []}), \ + 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: + + # Call get_variation with the CMAB experiment + variation, reasons, cmab_uuid = self.decision_service.get_variation( + self.project_config, + cmab_experiment, + user, + None + ) + + # Verify the variation and cmab_uuid + self.assertEqual(entities.Variation('111151', 'variation_1'), variation) + self.assertEqual('test-cmab-uuid-123', cmab_uuid) + + # 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('optimizely.bucketer.Bucketer.bucket_to_entity_id', return_value=['not_in_allocation', []]), \ + mock.patch('optimizely.decision_service.DecisionService._get_decision_for_cmab_experiment' + ) as mock_cmab_decision, \ + mock.patch.object(self.decision_service, + 'logger') as mock_logger: + + # Call get_variation with the CMAB experiment + variation, reasons, cmab_uuid = self.decision_service.get_variation( + self.project_config, + cmab_experiment, + user, + None + ) + + # Verify we get no variation and CMAB service wasn't called + self.assertIsNone(variation) + self.assertIsNone(cmab_uuid) + mock_cmab_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']}), \ + mock.patch.object(self.decision_service, + 'logger') as mock_logger: + + # Call get_variation with the CMAB experiment + variation, reasons, cmab_uuid = self.decision_service.get_variation( + self.project_config, + cmab_experiment, + user, + None + ) + + # Verify we get no variation due to CMAB service error + self.assertIsNone(variation) + self.assertIsNone(cmab_uuid) + self.assertIn('CMAB service error', reasons) + + # Verify logger was called + mock_logger.error.assert_any_call('CMAB decision fetch failed with status: CMAB service 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, reasons, cmab_uuid = self.decision_service.get_variation( + self.project_config, + cmab_experiment, + user, + None + ) + + # Verify we get the forced variation + self.assertEqual(forced_variation, variation) + self.assertIsNone(cmab_uuid) + self.assertIn('User is forced into variation', reasons) + + # 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, reasons, cmab_uuid = self.decision_service.get_variation( + self.project_config, + cmab_experiment, + user, + None + ) + + # Verify we get the whitelisted variation + self.assertEqual(whitelisted_variation, variation) + self.assertIsNone(cmab_uuid) + self.assertIn('User is whitelisted into variation', reasons) + + # 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): diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index a6ab34c9e..d6e75b263 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -671,7 +671,8 @@ def on_activate(experiment, user_id, attributes, variation, event): with mock.patch( 'optimizely.decision_service.DecisionService.get_variation_for_feature', return_value=( - decision_service.Decision(mock_experiment, mock_variation, enums.DecisionSources.FEATURE_TEST, None), []), + decision_service.Decision(mock_experiment, mock_variation, + enums.DecisionSources.FEATURE_TEST, None), []), ) as mock_decision, mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process'): self.assertTrue(opt_obj.is_feature_enabled('test_feature_in_experiment', 'test_user')) From fd7c72310d7b8b3edeeb3dda23e9b3f4ca4c6756 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Mon, 16 Jun 2025 22:08:51 +0600 Subject: [PATCH 10/38] update: fix logger message formatting in CMAB experiment tests --- tests/test_decision_service.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py index f9a98f85d..0873d191e 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -808,8 +808,8 @@ def test_get_variation_cmab_experiment_user_in_traffic_allocation(self): self.assertEqual('test-cmab-uuid-123', cmab_uuid) # Verify logger was called - mock_logger.info.assert_any_call('User "test_user" is in variation\ - "variation_1" of experiment cmab_experiment.') + 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.""" @@ -857,8 +857,8 @@ def test_get_variation_cmab_experiment_user_not_in_traffic_allocation(self): mock_cmab_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.') + 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.""" From ec19c3b2d9272847e06e9d1d833846573b754af4 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Mon, 16 Jun 2025 22:13:08 +0600 Subject: [PATCH 11/38] mypy fix --- optimizely/bucketer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/optimizely/bucketer.py b/optimizely/bucketer.py index 66d5f61a0..b4f13f071 100644 --- a/optimizely/bucketer.py +++ b/optimizely/bucketer.py @@ -22,7 +22,7 @@ if version_info < (3, 8): from typing_extensions import Final else: - from typing import Final # type: ignore + from typing import Final, cast # type: ignore if TYPE_CHECKING: @@ -193,8 +193,8 @@ def bucket_to_entity_id( matched = False for allocation in group.trafficAllocation: - end_of_range = allocation.get("endOfRange", 0) - entity_id = allocation.get("entityId") + end_of_range = cast(int, allocation.get("end_of_range", 0)) + entity_id = cast(Optional[str], allocation.get("entity_id")) if bucket_val < end_of_range: matched = True if entity_id != experiment.id: From 029262d7dd3e2e5090f28a69a035e2109063030c Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Mon, 16 Jun 2025 22:21:42 +0600 Subject: [PATCH 12/38] update: refine traffic allocation type hints and key naming in bucketer and decision service --- optimizely/bucketer.py | 10 +++++----- optimizely/decision_service.py | 7 ++++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/optimizely/bucketer.py b/optimizely/bucketer.py index b4f13f071..569e765e7 100644 --- a/optimizely/bucketer.py +++ b/optimizely/bucketer.py @@ -169,7 +169,7 @@ def bucket_to_entity_id( self, bucketing_id: str, experiment: Experiment, - traffic_allocations: list, + traffic_allocations: list[TrafficAllocation], group: Optional[Group] = None ) -> tuple[Optional[str], list[str]]: """ @@ -193,8 +193,8 @@ def bucket_to_entity_id( matched = False for allocation in group.trafficAllocation: - end_of_range = cast(int, allocation.get("end_of_range", 0)) - entity_id = cast(Optional[str], allocation.get("entity_id")) + end_of_range = allocation['endOfRange'] + entity_id = allocation['entityId'] if bucket_val < end_of_range: matched = True if entity_id != experiment.id: @@ -218,8 +218,8 @@ def bucket_to_entity_id( decide_reasons.append(f'Generated experiment bucket value {bucket_val} for key "{bucket_key}".') for allocation in traffic_allocations: - end_of_range = allocation.get("end_of_range", 0) - entity_id = allocation.get("entity_id") + end_of_range = allocation['endOfRange'] + entity_id = allocation['entityId'] if bucket_val < end_of_range: decide_reasons.append( f'User bucketed into entity id "{entity_id}".' diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index 6c9bb7fcd..1058fa602 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -30,6 +30,7 @@ # prevent circular dependenacy by skipping import at runtime from .project_config import ProjectConfig from .logger import Logger + from .helpers.types import TrafficAllocation class CmabDecisionResult(TypedDict): @@ -391,9 +392,9 @@ def get_variation( if experiment.cmab: CMAB_DUMMY_ENTITY_ID = "$" # Build the CMAB-specific traffic allocation - cmab_traffic_allocation = [{ - "entity_id": CMAB_DUMMY_ENTITY_ID, - "end_of_range": experiment.cmab['trafficAllocation'] + cmab_traffic_allocation: list[TrafficAllocation] = [{ + "entityId": CMAB_DUMMY_ENTITY_ID, + "endOfRange": experiment.cmab['trafficAllocation'] }] # Check if user is in CMAB traffic allocation From 180fdee549887cc70cfe658c167545bd4835f6ab Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Mon, 16 Jun 2025 22:24:11 +0600 Subject: [PATCH 13/38] update: remove unused import of cast in bucketer.py --- optimizely/bucketer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/optimizely/bucketer.py b/optimizely/bucketer.py index 569e765e7..2328048f1 100644 --- a/optimizely/bucketer.py +++ b/optimizely/bucketer.py @@ -22,7 +22,7 @@ if version_info < (3, 8): from typing_extensions import Final else: - from typing import Final, cast # type: ignore + from typing import Final # type: ignore if TYPE_CHECKING: From cd5ba394d45d61c169dff78eab1a402d3bb514c3 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Mon, 16 Jun 2025 23:58:23 +0600 Subject: [PATCH 14/38] update: fix return type for numeric_metric_value in get_numeric_value and ensure key is of bytes type in hash128 --- optimizely/helpers/event_tag_utils.py | 2 +- optimizely/lib/pymmh3.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/optimizely/helpers/event_tag_utils.py b/optimizely/helpers/event_tag_utils.py index 0efbafb7d..c1e9999e4 100644 --- a/optimizely/helpers/event_tag_utils.py +++ b/optimizely/helpers/event_tag_utils.py @@ -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) From 92a3258530b3d969b04659bbbcd694b513ea8037 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Tue, 17 Jun 2025 00:01:10 +0600 Subject: [PATCH 15/38] update: specify type hint for numeric_metric_value in get_numeric_value function --- optimizely/helpers/event_tag_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/optimizely/helpers/event_tag_utils.py b/optimizely/helpers/event_tag_utils.py index c1e9999e4..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 From fe100cb5d3f480f3febc7a0bd89a89359f1b7e00 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Tue, 17 Jun 2025 07:01:57 +0600 Subject: [PATCH 16/38] update: fix logger reference in DefaultCmabClient initialization and add __init__.py for cmab module --- optimizely/cmab/__init__.py | 12 ++++++++++++ optimizely/optimizely.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 optimizely/cmab/__init__.py 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/optimizely.py b/optimizely/optimizely.py index f66d9dc45..583ffbd9b 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -179,7 +179,7 @@ def __init__( # Initialize CMAB components self.cmab_client = DefaultCmabClient( retry_config=CmabRetryConfig(), - logger=logger + logger=self.logger ) self.cmab_cache: LRUCache[str, CmabCacheValue] = LRUCache(DEFAULT_CMAB_CACHE_SIZE, DEFAULT_CMAB_CACHE_TIMEOUT) self.cmab_service = DefaultCmabService( From 60a4ada99b9ae50bfd432ba413961203fb8282e4 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 20 Jun 2025 08:56:54 +0600 Subject: [PATCH 17/38] update: enhance error logging for CMAB fetch failures with detailed messages and add a test for handling 500 errors --- optimizely/decision_service.py | 5 ++- optimizely/helpers/enums.py | 3 +- tests/test_decision_service.py | 62 ++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index 1058fa602..d0797907a 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -124,7 +124,10 @@ def _get_decision_for_cmab_experiment( "reasons": [], } except Exception as e: - error_message = Errors.CMAB_FETCH_FAILED.format(str(e)) + error_message = Errors.CMAB_FETCH_FAILED_DETAILED.format( + experiment.key, + str(e) + ) if self.logger: self.logger.error(error_message) return { diff --git a/optimizely/helpers/enums.py b/optimizely/helpers/enums.py index 2d6febabc..f45c7a3bb 100644 --- a/optimizely/helpers/enums.py +++ b/optimizely/helpers/enums.py @@ -128,7 +128,8 @@ class Errors: 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' + INVALID_CMAB_FETCH_RESPONSE: Final = 'Invalid CMAB fetch response' + CMAB_FETCH_FAILED_DETAILED: Final = 'Failed to fetch CMAB decision for experiment key "{}" - {}' class ForcedDecisionLogs: diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py index 0873d191e..261197167 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -908,6 +908,68 @@ def test_get_variation_cmab_experiment_service_error(self): # Verify logger was called mock_logger.error.assert_any_call('CMAB decision fetch failed with status: CMAB service error') + def test_get_variation_cmab_experiment_deep_mock_500_error(self): + """Test the full flow of a CMAB experiment with a 500 error from the HTTP request layer.""" + import requests + from optimizely.exceptions import CmabFetchError + from optimizely.helpers.enums import Errors + + # 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} + ) + + # Define HTTP error details + http_error = requests.exceptions.HTTPError("500 Server Error") + error_message = Errors.CMAB_FETCH_FAILED.format(http_error) + detailed_error_message = Errors.CMAB_FETCH_FAILED_DETAILED.format(cmab_experiment.key, error_message) + + # Set up mocks for the entire call chain + 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.object(self.decision_service.cmab_service, 'get_decision', + side_effect=lambda *args, **kwargs: self.decision_service.cmab_service._fetch_decision(*args, **kwargs)), \ + mock.patch.object(self.decision_service.cmab_service, '_fetch_decision', + side_effect=lambda *args, **kwargs: self.decision_service.cmab_service.cmab_client.fetch_decision(*args, **kwargs)), \ + mock.patch.object(self.decision_service.cmab_service.cmab_client, 'fetch_decision', + side_effect=lambda *args, **kwargs: self.decision_service.cmab_service.cmab_client._do_fetch(*args, **kwargs)), \ + mock.patch.object(self.decision_service.cmab_service.cmab_client, '_do_fetch', + side_effect=CmabFetchError(error_message)), \ + mock.patch.object(self.decision_service, 'logger') as mock_logger: + + # Call get_variation with the CMAB experiment + variation, reasons, cmab_uuid = self.decision_service.get_variation( + self.project_config, + cmab_experiment, + user, + None + ) + + # Verify we get no variation due to CMAB service error + self.assertIsNone(variation) + self.assertIsNone(cmab_uuid) + self.assertIn(detailed_error_message, reasons) + + # Verify logger was called with the specific 500 error + mock_logger.error.assert_any_call(detailed_error_message) + def test_get_variation_cmab_experiment_forced_variation(self): """Test get_variation with CMAB experiment when user has a forced variation.""" From 265d82b13e495a46fc086b0880aebf2dd2370408 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 20 Jun 2025 10:57:13 +0600 Subject: [PATCH 18/38] update: enhance decision result handling by introducing VariationResult and updating get_variation return type to include detailed error information --- optimizely/decision_service.py | 169 ++++++++++++++++++++++++++------- optimizely/optimizely.py | 5 +- tests/test_decision_service.py | 36 ++++--- 3 files changed, 157 insertions(+), 53 deletions(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index d0797907a..d77ba9d9f 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -34,11 +34,49 @@ 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, source and cmab_uuid. None if no experiment/variation was selected.""" @@ -310,30 +348,38 @@ def get_variation( user_profile_tracker: Optional[UserProfileTracker], reasons: list[str] = [], options: Optional[Sequence[str]] = None - ) -> tuple[Optional[entities.Variation], list[str], Optional[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. + 9. Return the assigned variation, decision reasons, and cmab_uuid (if applicable). 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, - an array of log messages representing decision making - and a cmab_uuid if experiment is cmab-experiment + A tuple of: + - The assigned Variation (or None if not assigned). + - A list of log messages representing decision making. + - The cmab_uuid if the experiment is a CMAB experiment, otherwise None. """ user_id = user_context.user_id - cmab_uuid = None if options: ignore_user_profile = OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE in options else: @@ -347,20 +393,35 @@ def get_variation( message = f'Experiment "{experiment.key}" is not running.' self.logger.info(message) decide_reasons.append(message) - return None, decide_reasons, cmab_uuid + 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, cmab_uuid + 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, cmab_uuid + 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: @@ -370,7 +431,12 @@ def get_variation( f'"{experiment}" for user "{user_id}" from user profile.' self.logger.info(message) decide_reasons.append(message) - return variation, decide_reasons, cmab_uuid + return { + 'cmab_uuid': None, + 'error': False, + 'reasons': decide_reasons, + 'variation': variation + } else: self.logger.warning('User profile has invalid format.') @@ -386,12 +452,21 @@ 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, cmab_uuid + 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 + 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_DUMMY_ENTITY_ID = "$" # Build the CMAB-specific traffic allocation @@ -412,18 +487,27 @@ def get_variation( message = f'User "{user_id}" not in CMAB experiment "{experiment.key}" due to traffic allocation.' self.logger.info(message) decide_reasons.append(message) - return None, decide_reasons, cmab_uuid + return { + 'cmab_uuid': None, + 'error': False, + 'reasons': decide_reasons, + 'variation': None + } # User is in CMAB allocation, proceed to CMAB decision - decision_variation_value = self._get_decision_for_cmab_experiment(project_config, - experiment, - user_context, - options) - decide_reasons += decision_variation_value.get('reasons', []) - cmab_decision = decision_variation_value.get('result') - if not cmab_decision or decision_variation_value['error']: - self.logger.error(Errors.CMAB_FETCH_FAILED.format(decide_reasons[0])) - return None, decide_reasons, cmab_uuid + cmab_decision_result = self._get_decision_for_cmab_experiment(project_config, + experiment, + user_context, + options) + decide_reasons += cmab_decision_result.get('reasons', []) + cmab_decision = cmab_decision_result.get('result') + if not cmab_decision or cmab_decision_result['error']: + return { + 'cmab_uuid': None, + 'error': True, + 'reasons': decide_reasons, + 'variation': None + } variation_id = cmab_decision['variation_id'] cmab_uuid = cmab_decision['cmab_uuid'] variation = project_config.get_variation_from_id(experiment_key=experiment.key, variation_id=variation_id) @@ -442,11 +526,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, cmab_uuid + 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, cmab_uuid + 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 @@ -693,9 +787,12 @@ def get_variations_for_feature_list( decision_variation = forced_decision_variation cmab_uuid = None else: - decision_variation, variation_reasons, cmab_uuid = 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'] feature_reasons.extend(variation_reasons) if decision_variation: diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 583ffbd9b..02805063a 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -652,8 +652,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 diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py index 261197167..877f43742 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -942,31 +942,37 @@ def test_get_variation_cmab_experiment_deep_mock_500_error(self): # Set up mocks for the entire call chain 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.object(self.decision_service.cmab_service, 'get_decision', - side_effect=lambda *args, **kwargs: self.decision_service.cmab_service._fetch_decision(*args, **kwargs)), \ - mock.patch.object(self.decision_service.cmab_service, '_fetch_decision', - side_effect=lambda *args, **kwargs: self.decision_service.cmab_service.cmab_client.fetch_decision(*args, **kwargs)), \ - mock.patch.object(self.decision_service.cmab_service.cmab_client, 'fetch_decision', - side_effect=lambda *args, **kwargs: self.decision_service.cmab_service.cmab_client._do_fetch(*args, **kwargs)), \ - mock.patch.object(self.decision_service.cmab_service.cmab_client, '_do_fetch', - side_effect=CmabFetchError(error_message)), \ - mock.patch.object(self.decision_service, 'logger') as mock_logger: - + 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.object(self.decision_service.cmab_service, 'get_decision', + side_effect=lambda *args, + **kwargs: self.decision_service.cmab_service._fetch_decision(*args, **kwargs)), \ + mock.patch.object(self.decision_service.cmab_service, '_fetch_decision', + side_effect=lambda *args, + **kwargs: self. + decision_service.cmab_service.cmab_client.fetch_decision(*args, **kwargs)), \ + mock.patch.object(self.decision_service.cmab_service.cmab_client, 'fetch_decision', + side_effect=lambda *args, + **kwargs: self.decision_service.cmab_service.cmab_client._do_fetch(*args, **kwargs)), \ + mock.patch.object(self.decision_service.cmab_service.cmab_client, '_do_fetch', + side_effect=CmabFetchError(error_message)), \ + mock.patch.object(self.decision_service, 'logger') as mock_logger: # Call get_variation with the CMAB experiment - variation, reasons, cmab_uuid = self.decision_service.get_variation( + 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'] + # Verify we get no variation due to CMAB service error self.assertIsNone(variation) self.assertIsNone(cmab_uuid) self.assertIn(detailed_error_message, reasons) - + # Verify logger was called with the specific 500 error mock_logger.error.assert_any_call(detailed_error_message) From 6ca1102ffde42db24677d22f3eefeb13f08ba1b7 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 20 Jun 2025 14:02:24 +0600 Subject: [PATCH 19/38] update: refactor get_variation return structure and change tests accordingly --- tests/test_decision_service.py | 78 +++++++++++++-------- tests/test_optimizely.py | 122 +++++++++++++++++++++++++-------- 2 files changed, 144 insertions(+), 56 deletions(-) diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py index 877f43742..06b77f1e2 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, @@ -796,16 +797,22 @@ def test_get_variation_cmab_experiment_user_in_traffic_allocation(self): 'logger') as mock_logger: # Call get_variation with the CMAB experiment - variation, reasons, cmab_uuid = self.decision_service.get_variation( + 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 logger was called mock_logger.info.assert_any_call('User "test_user" is in variation ' @@ -844,16 +851,23 @@ def test_get_variation_cmab_experiment_user_not_in_traffic_allocation(self): 'logger') as mock_logger: # Call get_variation with the CMAB experiment - variation, reasons, cmab_uuid = self.decision_service.get_variation( + 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) mock_cmab_decision.assert_not_called() # Verify logger was called @@ -888,25 +902,25 @@ def test_get_variation_cmab_experiment_service_error(self): 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']}), \ - mock.patch.object(self.decision_service, - 'logger') as mock_logger: + return_value={'error': True, 'result': None, 'reasons': ['CMAB service error']}): # Call get_variation with the CMAB experiment - variation, reasons, cmab_uuid = self.decision_service.get_variation( + 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) - - # Verify logger was called - mock_logger.error.assert_any_call('CMAB decision fetch failed with status: CMAB service error') + self.assertStrictTrue(error) def test_get_variation_cmab_experiment_deep_mock_500_error(self): """Test the full flow of a CMAB experiment with a 500 error from the HTTP request layer.""" @@ -1015,17 +1029,22 @@ def test_get_variation_cmab_experiment_forced_variation(self): ) as mock_cmab_decision: # Call get_variation with the CMAB experiment - variation, reasons, cmab_uuid = self.decision_service.get_variation( + 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() @@ -1072,17 +1091,22 @@ def test_get_variation_cmab_experiment_with_whitelisted_variation(self): ) as mock_cmab_decision: # Call get_variation with the CMAB experiment - variation, reasons, cmab_uuid = self.decision_service.get_variation( + 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() @@ -1353,7 +1377,7 @@ 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, [], None], + 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( @@ -1485,7 +1509,7 @@ 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, [], None), + return_value={'variation': expected_variation, 'cmab_uuid': None, 'reasons': [], 'error': False}, ) as mock_decision: variation_received, _ = self.decision_service.get_variation_for_feature( self.project_config, feature, user, options=None @@ -1520,7 +1544,7 @@ 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, [], None], + return_value={'variation': None, 'cmab_uuid': None, 'reasons': [], 'error': False}, ) as mock_decision: variation_received, _ = self.decision_service.get_variation_for_feature( self.project_config, feature, user @@ -1552,7 +1576,7 @@ 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, [], None], + return_value={'variation': None, 'cmab_uuid': None, 'reasons': [], 'error': False}, ) as mock_decision: variation_received, _ = self.decision_service.get_variation_for_feature( self.project_config, feature, user, False diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index d6e75b263..d198d3b2b 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'), [], None), + 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'), [], None), + 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) - return_tuple = (self.project_config.get_variation_from_id('test_experiment', '111129'), [], 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=return_tuple, + 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': return_tuple[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'), [], None) - + 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, [], None), ), mock.patch( + return_value=variation_result), mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' @@ -716,10 +740,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'), [], None), + 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( @@ -1061,10 +1090,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'), [], None), + 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( @@ -1800,10 +1834,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'), [], None), + 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( @@ -1822,10 +1861,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'), [], None), + return_value=variation_result, ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast, mock.patch( @@ -1855,10 +1899,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'), [], None), + 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) @@ -1875,9 +1924,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, [], None), ), mock.patch( + return_value=variation_result, ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast: self.assertEqual( @@ -4809,10 +4863,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'), [], None), + return_value=variation_result, ), mock.patch('time.time', return_value=42), mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ), mock.patch.object( @@ -4950,10 +5009,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'), [], None), + return_value=variation_result ), mock.patch('time.time', return_value=42), mock.patch( 'optimizely.event.event_processor.ForwardingEventProcessor.process' ), mock.patch.object( From c2b3d9663786f69403c31ece8d837e2895747973 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Mon, 23 Jun 2025 21:37:57 +0600 Subject: [PATCH 20/38] -Error propagated to optimizely.py -test cases changed to handle return type dicts of DecisionResult and VariationResult --- optimizely/decision_service.py | 64 ++++-- optimizely/optimizely.py | 16 +- tests/test_decision_service.py | 65 +++--- tests/test_optimizely.py | 402 ++++++++++++++++++++------------- tests/test_user_context.py | 197 +++++++--------- 5 files changed, 416 insertions(+), 328 deletions(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index d77ba9d9f..843ab916e 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -363,7 +363,6 @@ def get_variation( 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. - 9. Return the assigned variation, decision reasons, and cmab_uuid (if applicable). Args: project_config: Instance of ProjectConfig. @@ -374,10 +373,11 @@ def get_variation( options: Decide options. Returns: - A tuple of: - - The assigned Variation (or None if not assigned). - - A list of log messages representing decision making. - - The cmab_uuid if the experiment is a CMAB experiment, otherwise None. + 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: @@ -657,7 +657,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: @@ -667,8 +667,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( @@ -740,17 +743,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] = [] @@ -786,6 +793,7 @@ def get_variations_for_feature_list( if forced_decision_variation: decision_variation = forced_decision_variation cmab_uuid = None + error = False else: variation_result = self.get_variation( project_config, experiment, user_context, user_profile_tracker, feature_reasons, options @@ -793,8 +801,20 @@ def get_variations_for_feature_list( 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}" ' @@ -802,11 +822,16 @@ def get_variations_for_feature_list( ) decision = Decision(experiment, decision_variation, enums.DecisionSources.FEATURE_TEST, cmab_uuid) - decisions.append((decision, feature_reasons)) + 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, @@ -820,7 +845,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/optimizely.py b/optimizely/optimizely.py index 02805063a..a1cbafb06 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -357,7 +357,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: @@ -444,7 +445,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: @@ -715,7 +718,7 @@ 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 = self.decision_service.get_variation_for_feature(project_config, feature, user_context)['decision'] is_source_experiment = decision.source == enums.DecisionSources.FEATURE_TEST is_source_rollout = decision.source == enums.DecisionSources.ROLLOUT @@ -1368,8 +1371,11 @@ def _decide_for_keys( ) 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'] + # Can catch errors now. Not used as decision logic implicitly handles error decision. + # Will be required for impression events + # error = decision_list[i]['error'] flag_key = flags_without_forced_decision[i].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 06b77f1e2..d25e24971 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -1380,9 +1380,9 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_experiment( 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, @@ -1421,9 +1421,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, @@ -1461,9 +1461,9 @@ 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, @@ -1511,9 +1511,9 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_group(self) "optimizely.decision_service.DecisionService.get_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, @@ -1546,9 +1546,9 @@ def test_get_variation_for_feature__returns_none_for_user_not_in_experiment(self "optimizely.decision_service.DecisionService.get_variation", 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, None), variation_received, @@ -1578,9 +1578,9 @@ def test_get_variation_for_feature__returns_none_for_user_in_group_experiment_no "optimizely.decision_service.DecisionService.get_variation", 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, None), variation_received, @@ -1607,9 +1607,9 @@ 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( @@ -1643,9 +1643,9 @@ 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, @@ -1678,9 +1678,10 @@ 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, @@ -1688,7 +1689,7 @@ def test_get_variation_for_feature__returns_variation_for_feature_in_mutex_group 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') @@ -1707,9 +1708,9 @@ 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( @@ -1743,9 +1744,9 @@ 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, @@ -1776,9 +1777,9 @@ 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, @@ -1810,9 +1811,9 @@ 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, @@ -1839,9 +1840,9 @@ 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, @@ -1874,9 +1875,9 @@ 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, @@ -1911,9 +1912,9 @@ 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 - ) + )['decision'] self.assertEqual( decision_service.Decision( expected_experiment, diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index d198d3b2b..da13fc04c 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -691,12 +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, None), []), + 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')) @@ -721,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, None), []), + return_value=(get_variation_for_feature_return_value), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process: @@ -2083,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, None), []), + return_value=(get_variation_for_feature_return_value), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process, mock.patch( @@ -2183,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, None), []), + return_value=get_variation_for_feature_return_value, ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process, mock.patch( @@ -2283,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, None), []), + return_value=(get_variation_for_feature_return_value), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process, mock.patch( @@ -2333,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, None), []), + return_value=(get_variation_for_feature_return_value), ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process, mock.patch( @@ -2438,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, None), []), + return_value=get_variation_for_feature_return_value, ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process, mock.patch( @@ -2482,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, None), []), + return_value=get_variation_for_feature_return_value, ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process, mock.patch( @@ -2525,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, None), []), + return_value=get_variation_for_feature_return_value, ) as mock_decision, mock.patch( 'optimizely.event.event_processor.BatchEventProcessor.process' ) as mock_process, mock.patch( @@ -2632,21 +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, None) + 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, None) + 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( + 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, None) + 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, @@ -2768,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, None), []), + 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: @@ -2806,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, None), []), + 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: @@ -2844,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, None), []), + 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: @@ -2882,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, None), []), + 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: @@ -2921,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, None), []), + 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: @@ -2968,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, None), []), + 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: @@ -3024,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, None), []), + 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: @@ -3056,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, None), []), + 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: @@ -3087,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, None), []), + 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: @@ -3118,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, None), []), + 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: @@ -3150,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, None), []), + 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: @@ -3187,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, None), []), + 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: @@ -3229,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, None), []), + 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: @@ -3271,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, None), []), + 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: @@ -3313,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, None), []), + 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: @@ -3355,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, None), []), + 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,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, None), []), + 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: @@ -3454,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, None), []), + 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: @@ -3491,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, None), []), + 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: @@ -3524,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, None), []), + 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: @@ -3557,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, None), []), + 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: @@ -3591,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, None), []), + 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: @@ -3628,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, None), []), + return_value=get_variation_for_feature_return_value, ): self.assertTrue( opt_obj.get_feature_variable_boolean('test_feature_in_experiment', 'is_working', 'test_user') @@ -3645,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, None), []), + 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'), @@ -3655,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, None), []), + return_value=get_variation_for_feature_return_value, ): self.assertEqual( 999, opt_obj.get_feature_variable_integer('test_feature_in_experiment', 'count', 'test_user'), @@ -3665,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, None), []), + return_value=get_variation_for_feature_return_value, ): self.assertEqual( 'devel', opt_obj.get_feature_variable_string('test_feature_in_experiment', 'environment', 'test_user'), @@ -3675,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, None), []), + 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'), @@ -3685,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, None), []), + 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, None), []), + return_value=get_variation_for_feature_return_value, ): self.assertEqual( 10.99, opt_obj.get_feature_variable('test_feature_in_experiment', 'cost', 'test_user'), @@ -3701,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, None), []), + return_value=get_variation_for_feature_return_value, ): self.assertEqual( 999, opt_obj.get_feature_variable('test_feature_in_experiment', 'count', 'test_user'), @@ -3710,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, None), []), + return_value=get_variation_for_feature_return_value, ): self.assertEqual( 'devel', opt_obj.get_feature_variable('test_feature_in_experiment', 'environment', 'test_user'), @@ -3722,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, None), []), + 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: @@ -3760,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, None), []), + 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: @@ -3794,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, None), []), + 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: @@ -3829,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, None), []), + 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: @@ -3863,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, None), []), + 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: @@ -3897,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, None), []), + 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: @@ -3928,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, None), []), + 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: @@ -3961,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, None), []), + 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: @@ -3994,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, None), []), + 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: @@ -4302,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, None), []), + 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') @@ -4321,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, None), []), + 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'), @@ -4336,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, None), []), + 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'), @@ -4351,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, None), []), + 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'), @@ -4366,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, None), []), + 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'), @@ -4381,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, None), []), + 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')) @@ -4393,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, None), []), + 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'), @@ -4407,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, None), []), + 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'), @@ -4421,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, None), []), + 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'), @@ -4439,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, None), []), + 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')) @@ -4456,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, None), []), + 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'), @@ -4471,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, None), []), + 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'), @@ -4486,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, None), []), + 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'), @@ -4500,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, None), []), + 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'), @@ -4514,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, None), []), + 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')) @@ -4526,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, None), []), + 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'), @@ -4540,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, None), []), + 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'), @@ -4554,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, None), []), + 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'), @@ -4592,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, None), []), + return_value=get_variation_for_feature_return_value, ), mock.patch( 'optimizely.project_config.ProjectConfig.get_typecast_value', side_effect=ValueError(), ), mock.patch.object( diff --git a/tests/test_user_context.py b/tests/test_user_context.py index c238ad122..41064c425 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -226,20 +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, - None - ), - [] - ) - ] + return_value=[get_variation_for_feature_return_value] ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision, mock.patch( @@ -312,20 +307,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, - None - ), - [] - ) - ] + return_value=[get_variation_for_feature_return_value] ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision, mock.patch( @@ -502,20 +492,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, - None - ), - [] - ) - ] + return_value=[get_variation_for_feature_return_value] ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision, mock.patch( @@ -588,20 +573,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, - None - ), - [] - ) - ] + return_value=[get_variation_for_feature_return_value] ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision, mock.patch( @@ -660,20 +640,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, - None - ), - [] - ) - ] + return_value=[get_variation_for_feature_return_value] ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision, mock.patch( @@ -735,20 +710,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, - None - ), - [] - ) - ] + return_value=[get_variation_for_feature_return_value] ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision, mock.patch( @@ -807,20 +777,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, - None - ), - [] - ) - ] + return_value=[get_variation_for_feature_return_value] ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision, mock.patch( @@ -914,20 +879,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, - None - ), - [] - ) - ] + return_value=[get_variation_for_feature_return_value] ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision, mock.patch( @@ -1004,20 +964,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, - None - ), - [] - ) - ] + return_value=[get_variation_for_feature_return_value] ), mock.patch( 'optimizely.notification_center.NotificationCenter.send_notifications' ) as mock_broadcast_decision, mock.patch( @@ -1160,8 +1115,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) @@ -1425,19 +1384,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, - None - ), - [] - ), - ] + 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]) From 0e256220ea3ac4f27b77da6bd23c19b9e19c4e66 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 27 Jun 2025 21:37:29 +0600 Subject: [PATCH 21/38] update: add cmab_uuid parameter to impression events --- optimizely/event/event_factory.py | 4 +- optimizely/event/payload.py | 5 +- optimizely/event/user_event.py | 4 +- optimizely/event/user_event_factory.py | 4 +- optimizely/optimizely.py | 21 ++-- tests/test_event_factory.py | 139 +++++++++++++++++++++++++ tests/test_user_context.py | 3 +- tests/test_user_event_factory.py | 120 ++++++++++++++++++++- 8 files changed, 286 insertions(+), 14 deletions(-) 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/optimizely.py b/optimizely/optimizely.py index a1cbafb06..beb26d746 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -14,6 +14,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, Optional +from unittest import mock from . import decision_service from . import entities @@ -260,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: """ Helper method to send impression event. @@ -280,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: @@ -550,7 +553,7 @@ def activate(self, experiment_key: str, user_id: str, attributes: Optional[UserA # Create and dispatch impression event self.logger.info(f'Activating user "{user_id}" in experiment "{experiment.key}".') self._send_impression_event(project_config, experiment, variation, '', experiment.key, - enums.DecisionSources.EXPERIMENT, True, user_id, attributes) + enums.DecisionSources.EXPERIMENT, True, user_id, attributes, None) return variation.key @@ -718,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'] + 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 @@ -729,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. @@ -740,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: @@ -1193,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 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_user_context.py b/tests/test_user_context.py index 41064c425..55adf63d4 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -297,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): 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']) From 088f4afc4fdc8328e1d2c980233988b9201a6d9c Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 27 Jun 2025 21:40:43 +0600 Subject: [PATCH 22/38] update: add None parameter to impression events in decision tests --- tests/test_user_context.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/test_user_context.py b/tests/test_user_context.py index 55adf63d4..3ae9be0dc 100644 --- a/tests/test_user_context.py +++ b/tests/test_user_context.py @@ -401,7 +401,8 @@ def test_decide_feature_rollout(self): 'rollout', expected.enabled, 'test_user', - user_attributes + user_attributes, + None ) # assert notification count @@ -564,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): @@ -841,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): @@ -953,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): @@ -1512,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.' From b901c5fae4b76b51d038ffbc1f9350153bf26a7f Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 27 Jun 2025 21:45:38 +0600 Subject: [PATCH 23/38] update: modify get_variation to return VariationResult and adjust related logic for improved variation handling --- optimizely/optimizely.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index a1cbafb06..a63cc4242 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -29,7 +29,7 @@ from .decision.optimizely_decide_option import OptimizelyDecideOption from .decision.optimizely_decision import OptimizelyDecision from .decision.optimizely_decision_message import OptimizelyDecisionMessage -from .decision_service import Decision +from .decision_service import Decision, VariationResult from .error_handler import NoOpErrorHandler, BaseErrorHandler from .event import event_factory, user_event_factory from .event.event_processor import BatchEventProcessor, BaseEventProcessor @@ -535,8 +535,10 @@ def activate(self, experiment_key: str, user_id: str, attributes: Optional[UserA self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('activate')) return None - variation_key = self.get_variation(experiment_key, user_id, attributes) - + variation_result = self.get_variation(experiment_key, user_id, attributes) + variation_key = None + if variation_result: + variation_key = variation_result['variation'].key if not variation_key: self.logger.info(f'Not activating user "{user_id}".') return None @@ -612,17 +614,18 @@ def track( def get_variation( self, experiment_key: str, user_id: str, attributes: Optional[UserAttributes] = None - ) -> Optional[str]: - """ Gets variation where user will be bucketed. + ) -> Optional[VariationResult]: + """ + Returns the variation result for the given user in the specified experiment. Args: - experiment_key: Experiment for which user variation needs to be determined. - user_id: ID for user. - attributes: Dict representing user attributes. + experiment_key: The key identifying the experiment. + user_id: The user ID. + attributes: Optional dictionary of user attributes. Returns: - Variation key representing the variation the user will be bucketed in. - None if user is not in experiment or if experiment is not Running. + A VariationResult object containing the variation assigned to the user, or None if the user is not + bucketed into any variation or the experiment is not running. """ if not self.is_valid: @@ -675,7 +678,7 @@ def get_variation( {'experiment_key': experiment_key, 'variation_key': variation_key}, ) - return variation_key + return variation_result def is_feature_enabled(self, feature_key: str, user_id: str, attributes: Optional[UserAttributes] = None) -> bool: """ Returns true if the feature is enabled for the given user. From d2fc631c6eefadcf5359271546301bac30f471f3 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 27 Jun 2025 22:17:16 +0600 Subject: [PATCH 24/38] update: unit test fixes --- optimizely/optimizely.py | 2 +- tests/test_optimizely.py | 19 +++++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index a63cc4242..84d3784a4 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -537,7 +537,7 @@ def activate(self, experiment_key: str, user_id: str, attributes: Optional[UserA variation_result = self.get_variation(experiment_key, user_id, attributes) variation_key = None - if variation_result: + if variation_result and variation_result['variation']: variation_key = variation_result['variation'].key if not variation_key: self.logger.info(f'Not activating user "{user_id}".') diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index da13fc04c..626b55453 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -1852,7 +1852,7 @@ def test_get_variation(self): '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') + variation = self.optimizely.get_variation('test_experiment', 'test_user')['variation'].key self.assertEqual( 'variation', variation, ) @@ -1885,7 +1885,7 @@ def test_get_variation_lookup_and_save_is_called(self): ) as mock_load_user_profile, mock.patch( 'optimizely.user_profile.UserProfileTracker.save_user_profile' ) as mock_save_user_profile: - variation = self.optimizely.get_variation('test_experiment', 'test_user') + variation = self.optimizely.get_variation('test_experiment', 'test_user')['variation'].key self.assertEqual( 'variation', variation, ) @@ -1917,7 +1917,7 @@ def test_get_variation_with_experiment_in_feature(self): 'optimizely.decision_service.DecisionService.get_variation', return_value=variation_result, ), mock.patch('optimizely.notification_center.NotificationCenter.send_notifications') as mock_broadcast: - variation = opt_obj.get_variation('test_experiment', 'test_user') + variation = opt_obj.get_variation('test_experiment', 'test_user')['variation'].key self.assertEqual('variation', variation) self.assertEqual(mock_broadcast.call_count, 1) @@ -1946,7 +1946,7 @@ def test_get_variation__returns_none(self): None, self.optimizely.get_variation( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value'}, - ), + )['variation'], ) self.assertEqual(mock_broadcast.call_count, 1) @@ -5172,7 +5172,7 @@ def test_get_variation__forced_bucketing(self): ) variation_key = self.optimizely.get_variation( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value'} - ) + )['variation'].key self.assertEqual('variation', variation_key) def test_get_variation__experiment_not_running__forced_bucketing(self): @@ -5187,7 +5187,7 @@ def test_get_variation__experiment_not_running__forced_bucketing(self): ) variation_key = self.optimizely.get_variation( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value'}, - ) + )['variation'] self.assertIsNone(variation_key) mock_is_experiment_running.assert_called_once_with( self.project_config.get_experiment_from_key('test_experiment') @@ -5201,7 +5201,7 @@ def test_get_variation__whitelisted_user_forced_bucketing(self): self.assertEqual('group_exp_1_variation', forced_variation) variation_key = self.optimizely.get_variation( 'group_exp_1', 'user_1', attributes={'test_attribute': 'test_value'} - ) + )['variation'].key self.assertEqual('group_exp_1_variation', variation_key) def test_get_variation__user_profile__forced_bucketing(self): @@ -5216,7 +5216,7 @@ def test_get_variation__user_profile__forced_bucketing(self): ) variation_key = self.optimizely.get_variation( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value'}, - ) + )['variation'].key self.assertEqual('variation', variation_key) def test_get_variation__invalid_attributes__forced_bucketing(self): @@ -5228,8 +5228,7 @@ def test_get_variation__invalid_attributes__forced_bucketing(self): ) variation_key = self.optimizely.get_variation( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value_invalid'}, - ) - variation_key = variation_key + )['variation'].key self.assertEqual('variation', variation_key) def test_set_forced_variation__invalid_object(self): From cbf2c2c31a9424e5715ddd8c4b4ce40448b20cde Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 27 Jun 2025 22:28:13 +0600 Subject: [PATCH 25/38] update: include CMAB UUID in activation and add corresponding tests --- optimizely/optimizely.py | 7 +++-- tests/test_optimizely.py | 60 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 8f2922885..2b7ac0d8d 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -14,7 +14,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, Optional -from unittest import mock + from . import decision_service from . import entities @@ -540,8 +540,11 @@ def activate(self, experiment_key: str, user_id: str, attributes: Optional[UserA variation_result = self.get_variation(experiment_key, user_id, attributes) variation_key = None + cmab_uuid = None if variation_result and variation_result['variation']: variation_key = variation_result['variation'].key + if variation_result and variation_result['cmab_uuid']: + cmab_uuid = variation_result['cmab_uuid'] if not variation_key: self.logger.info(f'Not activating user "{user_id}".') return None @@ -555,7 +558,7 @@ def activate(self, experiment_key: str, user_id: str, attributes: Optional[UserA # Create and dispatch impression event self.logger.info(f'Activating user "{user_id}" in experiment "{experiment.key}".') self._send_impression_event(project_config, experiment, variation, '', experiment.key, - enums.DecisionSources.EXPERIMENT, True, user_id, attributes, None) + enums.DecisionSources.EXPERIMENT, True, user_id, attributes, cmab_uuid) return variation.key diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index 626b55453..aea5214e4 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -4890,6 +4890,66 @@ def test_odp_events_not_sent_with_legacy_apis(self): client.close() + def test_activate_with_cmab_uuid(self): + """ Test that activate includes CMAB UUID when available from CMAB service. """ + expected_cmab_uuid = "test-cmab-uuid-123" + 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('time.time', return_value=42), mock.patch( + 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' + ), mock.patch( + 'optimizely.event.event_processor.BatchEventProcessor.process' + ) as mock_process: + result = self.optimizely.activate('test_experiment', 'test_user') + self.assertEqual('variation', result) + + # Verify the impression event includes CMAB UUID + impression_event = mock_process.call_args[0][0] + self.assertEqual(impression_event.cmab_uuid, expected_cmab_uuid) + + # Verify the log event includes CMAB UUID in metadata + log_event = EventFactory.create_log_event(impression_event, self.optimizely.logger) + metadata = log_event.params['visitors'][0]['snapshots'][0]['decisions'][0]['metadata'] + self.assertIn('cmab_uuid', metadata) + self.assertEqual(metadata['cmab_uuid'], expected_cmab_uuid) + + def test_activate_without_cmab_uuid(self): + """ Test that activate works correctly when CMAB service returns 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('time.time', return_value=42), mock.patch( + 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' + ), mock.patch( + 'optimizely.event.event_processor.BatchEventProcessor.process' + ) as mock_process: + result = self.optimizely.activate('test_experiment', 'test_user') + self.assertEqual('variation', result) + + # Verify the impression event has no CMAB UUID + impression_event = mock_process.call_args[0][0] + self.assertIsNone(impression_event.cmab_uuid) + + # Verify the log event does not include CMAB UUID in metadata + log_event = EventFactory.create_log_event(impression_event, self.optimizely.logger) + metadata = log_event.params['visitors'][0]['snapshots'][0]['decisions'][0]['metadata'] + self.assertNotIn('cmab_uuid', metadata) + class OptimizelyWithExceptionTest(base.BaseTest): def setUp(self): From fdcdfbfae471250b75b7360d9b9850b5f4b6ff9f Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 27 Jun 2025 22:33:11 +0600 Subject: [PATCH 26/38] update: add tests for get_variation with and without CMAB UUID --- tests/test_optimizely.py | 51 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index aea5214e4..d8fe50b3a 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -4950,6 +4950,57 @@ def test_activate_without_cmab_uuid(self): metadata = log_event.params['visitors'][0]['snapshots'][0]['decisions'][0]['metadata'] self.assertNotIn('cmab_uuid', metadata) + 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['variation'].key) + + # 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['variation'].key) + + # 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): From b9a8555637ee9b313b889cb6e5437caad8b284e0 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Mon, 30 Jun 2025 21:22:03 +0600 Subject: [PATCH 27/38] Revert "update: unit test fixes" This reverts commit d2fc631c6eefadcf5359271546301bac30f471f3. --- optimizely/optimizely.py | 2 +- tests/test_optimizely.py | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 84d3784a4..a63cc4242 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -537,7 +537,7 @@ def activate(self, experiment_key: str, user_id: str, attributes: Optional[UserA variation_result = self.get_variation(experiment_key, user_id, attributes) variation_key = None - if variation_result and variation_result['variation']: + if variation_result: variation_key = variation_result['variation'].key if not variation_key: self.logger.info(f'Not activating user "{user_id}".') diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index 626b55453..da13fc04c 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -1852,7 +1852,7 @@ def test_get_variation(self): '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')['variation'].key + variation = self.optimizely.get_variation('test_experiment', 'test_user') self.assertEqual( 'variation', variation, ) @@ -1885,7 +1885,7 @@ def test_get_variation_lookup_and_save_is_called(self): ) as mock_load_user_profile, mock.patch( 'optimizely.user_profile.UserProfileTracker.save_user_profile' ) as mock_save_user_profile: - variation = self.optimizely.get_variation('test_experiment', 'test_user')['variation'].key + variation = self.optimizely.get_variation('test_experiment', 'test_user') self.assertEqual( 'variation', variation, ) @@ -1917,7 +1917,7 @@ def test_get_variation_with_experiment_in_feature(self): 'optimizely.decision_service.DecisionService.get_variation', return_value=variation_result, ), mock.patch('optimizely.notification_center.NotificationCenter.send_notifications') as mock_broadcast: - variation = opt_obj.get_variation('test_experiment', 'test_user')['variation'].key + variation = opt_obj.get_variation('test_experiment', 'test_user') self.assertEqual('variation', variation) self.assertEqual(mock_broadcast.call_count, 1) @@ -1946,7 +1946,7 @@ def test_get_variation__returns_none(self): None, self.optimizely.get_variation( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value'}, - )['variation'], + ), ) self.assertEqual(mock_broadcast.call_count, 1) @@ -5172,7 +5172,7 @@ def test_get_variation__forced_bucketing(self): ) variation_key = self.optimizely.get_variation( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value'} - )['variation'].key + ) self.assertEqual('variation', variation_key) def test_get_variation__experiment_not_running__forced_bucketing(self): @@ -5187,7 +5187,7 @@ def test_get_variation__experiment_not_running__forced_bucketing(self): ) variation_key = self.optimizely.get_variation( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value'}, - )['variation'] + ) self.assertIsNone(variation_key) mock_is_experiment_running.assert_called_once_with( self.project_config.get_experiment_from_key('test_experiment') @@ -5201,7 +5201,7 @@ def test_get_variation__whitelisted_user_forced_bucketing(self): self.assertEqual('group_exp_1_variation', forced_variation) variation_key = self.optimizely.get_variation( 'group_exp_1', 'user_1', attributes={'test_attribute': 'test_value'} - )['variation'].key + ) self.assertEqual('group_exp_1_variation', variation_key) def test_get_variation__user_profile__forced_bucketing(self): @@ -5216,7 +5216,7 @@ def test_get_variation__user_profile__forced_bucketing(self): ) variation_key = self.optimizely.get_variation( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value'}, - )['variation'].key + ) self.assertEqual('variation', variation_key) def test_get_variation__invalid_attributes__forced_bucketing(self): @@ -5228,7 +5228,8 @@ def test_get_variation__invalid_attributes__forced_bucketing(self): ) variation_key = self.optimizely.get_variation( 'test_experiment', 'test_user', attributes={'test_attribute': 'test_value_invalid'}, - )['variation'].key + ) + variation_key = variation_key self.assertEqual('variation', variation_key) def test_set_forced_variation__invalid_object(self): From a129854d076855b6379964fe69698a611e3cd3bb Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Mon, 30 Jun 2025 21:23:04 +0600 Subject: [PATCH 28/38] Revert "update: modify get_variation to return VariationResult and adjust related logic for improved variation handling" This reverts commit b901c5fae4b76b51d038ffbc1f9350153bf26a7f. --- optimizely/optimizely.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index a63cc4242..a1cbafb06 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -29,7 +29,7 @@ from .decision.optimizely_decide_option import OptimizelyDecideOption from .decision.optimizely_decision import OptimizelyDecision from .decision.optimizely_decision_message import OptimizelyDecisionMessage -from .decision_service import Decision, VariationResult +from .decision_service import Decision from .error_handler import NoOpErrorHandler, BaseErrorHandler from .event import event_factory, user_event_factory from .event.event_processor import BatchEventProcessor, BaseEventProcessor @@ -535,10 +535,8 @@ def activate(self, experiment_key: str, user_id: str, attributes: Optional[UserA self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('activate')) return None - variation_result = self.get_variation(experiment_key, user_id, attributes) - variation_key = None - if variation_result: - variation_key = variation_result['variation'].key + variation_key = self.get_variation(experiment_key, user_id, attributes) + if not variation_key: self.logger.info(f'Not activating user "{user_id}".') return None @@ -614,18 +612,17 @@ def track( def get_variation( self, experiment_key: str, user_id: str, attributes: Optional[UserAttributes] = None - ) -> Optional[VariationResult]: - """ - Returns the variation result for the given user in the specified experiment. + ) -> Optional[str]: + """ Gets variation where user will be bucketed. Args: - experiment_key: The key identifying the experiment. - user_id: The user ID. - attributes: Optional dictionary of user attributes. + experiment_key: Experiment for which user variation needs to be determined. + user_id: ID for user. + attributes: Dict representing user attributes. Returns: - A VariationResult object containing the variation assigned to the user, or None if the user is not - bucketed into any variation or the experiment is not running. + Variation key representing the variation the user will be bucketed in. + None if user is not in experiment or if experiment is not Running. """ if not self.is_valid: @@ -678,7 +675,7 @@ def get_variation( {'experiment_key': experiment_key, 'variation_key': variation_key}, ) - return variation_result + return variation_key def is_feature_enabled(self, feature_key: str, user_id: str, attributes: Optional[UserAttributes] = None) -> bool: """ Returns true if the feature is enabled for the given user. From 1f7e2a9886b814f47955a9c5d9bdfc3dcb394d30 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Mon, 30 Jun 2025 21:35:20 +0600 Subject: [PATCH 29/38] update: make cmab_uuid parameter optional in _send_impression_event method --- optimizely/optimizely.py | 2 +- tests/test_optimizely.py | 64 ++-------------------------------------- 2 files changed, 3 insertions(+), 63 deletions(-) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 0837a6b74..12734a33a 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -261,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], cmab_uuid: Optional[str] + enabled: bool, user_id: str, attributes: Optional[UserAttributes], cmab_uuid: Optional[str] = None ) -> None: """ Helper method to send impression event. diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index 75834b6f1..8ef2c04c4 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -4890,66 +4890,6 @@ def test_odp_events_not_sent_with_legacy_apis(self): client.close() - def test_activate_with_cmab_uuid(self): - """ Test that activate includes CMAB UUID when available from CMAB service. """ - expected_cmab_uuid = "test-cmab-uuid-123" - 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('time.time', return_value=42), mock.patch( - 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' - ), mock.patch( - 'optimizely.event.event_processor.BatchEventProcessor.process' - ) as mock_process: - result = self.optimizely.activate('test_experiment', 'test_user') - self.assertEqual('variation', result) - - # Verify the impression event includes CMAB UUID - impression_event = mock_process.call_args[0][0] - self.assertEqual(impression_event.cmab_uuid, expected_cmab_uuid) - - # Verify the log event includes CMAB UUID in metadata - log_event = EventFactory.create_log_event(impression_event, self.optimizely.logger) - metadata = log_event.params['visitors'][0]['snapshots'][0]['decisions'][0]['metadata'] - self.assertIn('cmab_uuid', metadata) - self.assertEqual(metadata['cmab_uuid'], expected_cmab_uuid) - - def test_activate_without_cmab_uuid(self): - """ Test that activate works correctly when CMAB service returns 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('time.time', return_value=42), mock.patch( - 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' - ), mock.patch( - 'optimizely.event.event_processor.BatchEventProcessor.process' - ) as mock_process: - result = self.optimizely.activate('test_experiment', 'test_user') - self.assertEqual('variation', result) - - # Verify the impression event has no CMAB UUID - impression_event = mock_process.call_args[0][0] - self.assertIsNone(impression_event.cmab_uuid) - - # Verify the log event does not include CMAB UUID in metadata - log_event = EventFactory.create_log_event(impression_event, self.optimizely.logger) - metadata = log_event.params['visitors'][0]['snapshots'][0]['decisions'][0]['metadata'] - self.assertNotIn('cmab_uuid', metadata) - def test_get_variation_with_cmab_uuid(self): """ Test that get_variation works correctly with CMAB UUID. """ expected_cmab_uuid = "get-variation-cmab-uuid" @@ -4965,7 +4905,7 @@ def test_get_variation_with_cmab_uuid(self): 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['variation'].key) + self.assertEqual('variation', variation) # Verify decision notification is sent with correct parameters mock_broadcast.assert_any_call( @@ -4990,7 +4930,7 @@ def test_get_variation_without_cmab_uuid(self): 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['variation'].key) + self.assertEqual('variation', variation) # Verify decision notification is sent correctly mock_broadcast.assert_any_call( From 73a28027bc238b795e590f8c251c23867a0ea030 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Mon, 30 Jun 2025 21:49:15 +0600 Subject: [PATCH 30/38] chore: trigger CI by turning on python flag From a6d97712f6f7fc4377b107439229b0de1d87f664 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Tue, 1 Jul 2025 05:39:40 +0600 Subject: [PATCH 31/38] update: new class method to handle optimizely error decisions --- optimizely/decision/optimizely_decision.py | 22 ++++++++++++++++++++++ optimizely/decision_service.py | 7 ++----- optimizely/helpers/enums.py | 2 +- optimizely/optimizely.py | 11 ++++++++--- 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/optimizely/decision/optimizely_decision.py b/optimizely/decision/optimizely_decision.py index 7ae3f1366..5d7f57d1c 100644 --- a/optimizely/decision/optimizely_decision.py +++ b/optimizely/decision/optimizely_decision.py @@ -48,3 +48,25 @@ 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 843ab916e..d873db8f9 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -162,12 +162,9 @@ def _get_decision_for_cmab_experiment( "reasons": [], } except Exception as e: - error_message = Errors.CMAB_FETCH_FAILED_DETAILED.format( - experiment.key, - str(e) - ) + error_message = Errors.CMAB_FETCH_FAILED_DETAILED.format(experiment.key) if self.logger: - self.logger.error(error_message) + self.logger.error(f"{error_message} - {str(e)}") return { "error": True, "result": None, diff --git a/optimizely/helpers/enums.py b/optimizely/helpers/enums.py index f45c7a3bb..97bcaf57e 100644 --- a/optimizely/helpers/enums.py +++ b/optimizely/helpers/enums.py @@ -129,7 +129,7 @@ class Errors: 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: Final = 'Invalid CMAB fetch response' - CMAB_FETCH_FAILED_DETAILED: Final = 'Failed to fetch CMAB decision for experiment key "{}" - {}' + CMAB_FETCH_FAILED_DETAILED: Final = 'Failed to fetch CMAB data for experiment {}' class ForcedDecisionLogs: diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 12734a33a..ad2740a68 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -1380,10 +1380,15 @@ def _decide_for_keys( for i in range(0, len(flags_without_forced_decision)): decision = decision_list[i]['decision'] reasons = decision_list[i]['reasons'] - # Can catch errors now. Not used as decision logic implicitly handles error decision. - # Will be required for impression events - # error = decision_list[i]['error'] + 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 From 4743376dc425f29f1b81a786894f18b8fdcfa0bf Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Tue, 1 Jul 2025 05:48:35 +0600 Subject: [PATCH 32/38] fix unit test --- tests/test_decision_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py index d25e24971..1278885a0 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -952,7 +952,7 @@ def test_get_variation_cmab_experiment_deep_mock_500_error(self): # Define HTTP error details http_error = requests.exceptions.HTTPError("500 Server Error") error_message = Errors.CMAB_FETCH_FAILED.format(http_error) - detailed_error_message = Errors.CMAB_FETCH_FAILED_DETAILED.format(cmab_experiment.key, error_message) + detailed_error_message = Errors.CMAB_FETCH_FAILED_DETAILED.format(cmab_experiment.key) # Set up mocks for the entire call chain with mock.patch('optimizely.helpers.experiment.is_experiment_running', return_value=True), \ @@ -988,7 +988,7 @@ def test_get_variation_cmab_experiment_deep_mock_500_error(self): self.assertIn(detailed_error_message, reasons) # Verify logger was called with the specific 500 error - mock_logger.error.assert_any_call(detailed_error_message) + mock_logger.error.assert_any_call(f'{detailed_error_message} - {error_message}') def test_get_variation_cmab_experiment_forced_variation(self): """Test get_variation with CMAB experiment when user has a forced variation.""" From 6d790532e1727815cca13f5a0b60d59a711f074d Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Tue, 1 Jul 2025 05:52:05 +0600 Subject: [PATCH 33/38] fix: update error logging format for CMAB fetch failures --- optimizely/decision_service.py | 2 +- optimizely/helpers/enums.py | 6 +++--- tests/test_decision_service.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index d873db8f9..35fe9e5f9 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -164,7 +164,7 @@ def _get_decision_for_cmab_experiment( 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)}") + self.logger.error(f"{error_message} {str(e)}") return { "error": True, "result": None, diff --git a/optimizely/helpers/enums.py b/optimizely/helpers/enums.py index 97bcaf57e..e3acafef2 100644 --- a/optimizely/helpers/enums.py +++ b/optimizely/helpers/enums.py @@ -127,9 +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: Final = 'Invalid CMAB fetch response' - CMAB_FETCH_FAILED_DETAILED: Final = 'Failed to fetch CMAB data for experiment {}' + 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/tests/test_decision_service.py b/tests/test_decision_service.py index 1278885a0..618bef72a 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -988,7 +988,7 @@ def test_get_variation_cmab_experiment_deep_mock_500_error(self): self.assertIn(detailed_error_message, reasons) # Verify logger was called with the specific 500 error - mock_logger.error.assert_any_call(f'{detailed_error_message} - {error_message}') + mock_logger.error.assert_any_call(f'{detailed_error_message} {error_message}') def test_get_variation_cmab_experiment_forced_variation(self): """Test get_variation with CMAB experiment when user has a forced variation.""" From 3c903c74cf2e24ebdc61342b81ca4ceed64a63e3 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Tue, 1 Jul 2025 16:05:00 +0600 Subject: [PATCH 34/38] chore: trigger CI From c637878f763e3ee800b79e651f8e7c27749074df Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Thu, 3 Jul 2025 16:31:59 +0600 Subject: [PATCH 35/38] update: enhance decision service to handle error states and improve bucketing logic --- optimizely/bucketer.py | 104 ++++++------------ optimizely/decision/optimizely_decision.py | 20 ++++ optimizely/decision_service.py | 66 ++++++------ optimizely/optimizely.py | 13 ++- tests/test_decision_service.py | 119 +++++++-------------- tests/test_optimizely.py | 30 ++++++ 6 files changed, 160 insertions(+), 192 deletions(-) diff --git a/optimizely/bucketer.py b/optimizely/bucketer.py index 2328048f1..eb66f9bfe 100644 --- a/optimizely/bucketer.py +++ b/optimizely/bucketer.py @@ -28,7 +28,7 @@ if TYPE_CHECKING: # prevent circular dependenacy by skipping import at runtime from .project_config import ProjectConfig - from .entities import Experiment, Variation, Group + from .entities import Experiment, Variation from .helpers.types import TrafficAllocation @@ -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 @@ -154,77 +182,5 @@ def bucket( # 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) - - return None, decide_reasons - - def bucket_to_entity_id( - self, - bucketing_id: str, - experiment: Experiment, - traffic_allocations: list[TrafficAllocation], - group: Optional[Group] = None - ) -> tuple[Optional[str], list[str]]: - """ - Buckets the user and returns the entity ID (for CMAB experiments). - Args: - bucketing_id: The bucketing ID string for the user. - experiment: The experiment object (for group/groupPolicy logic if needed). - traffic_allocations: List of traffic allocation dicts (should have 'entity_id' and 'end_of_range' keys). - group: (optional) Group object for mutex group support. - Returns: - Tuple of (entity_id or None, list of decide reasons). - """ - decide_reasons = [] - - group_id = getattr(experiment, 'groupId', None) - if group_id and group and getattr(group, 'policy', None) == 'random': - bucket_key = bucketing_id + group_id - bucket_val = self._generate_bucket_value(bucket_key) - decide_reasons.append(f'Generated group bucket value {bucket_val} for key "{bucket_key}".') - - matched = False - for allocation in group.trafficAllocation: - end_of_range = allocation['endOfRange'] - entity_id = allocation['entityId'] - if bucket_val < end_of_range: - matched = True - if entity_id != experiment.id: - decide_reasons.append( - f'User not bucketed into experiment "{experiment.id}" (got "{entity_id}").' - ) - return None, decide_reasons - decide_reasons.append( - f'User is bucketed into experiment "{experiment.id}" within group "{group_id}".' - ) - break - if not matched: - decide_reasons.append( - f'User not bucketed into any experiment in group "{group_id}".' - ) - return None, decide_reasons - - # Main experiment bucketing - bucket_key = bucketing_id + experiment.id - bucket_val = self._generate_bucket_value(bucket_key) - decide_reasons.append(f'Generated experiment bucket value {bucket_val} for key "{bucket_key}".') - - for allocation in traffic_allocations: - end_of_range = allocation['endOfRange'] - entity_id = allocation['entityId'] - if bucket_val < end_of_range: - decide_reasons.append( - f'User bucketed into entity id "{entity_id}".' - ) - return entity_id, decide_reasons - - decide_reasons.append('User not bucketed into any entity id.') - return None, decide_reasons + return variation_id, decide_reasons 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 843ab916e..8a06e0584 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -30,7 +30,6 @@ # prevent circular dependenacy by skipping import at runtime from .project_config import ProjectConfig from .logger import Logger - from .helpers.types import TrafficAllocation class CmabDecisionResult(TypedDict): @@ -134,6 +133,7 @@ def _get_decision_for_cmab_experiment( project_config: ProjectConfig, experiment: entities.Experiment, user_context: OptimizelyUserContext, + bucketing_id: str, options: Optional[Sequence[str]] = None ) -> CmabDecisionResult: """ @@ -143,14 +143,36 @@ def _get_decision_for_cmab_experiment( 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 empty dict if 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( @@ -159,7 +181,7 @@ def _get_decision_for_cmab_experiment( return { "error": False, "result": cmab_decision, - "reasons": [], + "reasons": decide_reasons, } except Exception as e: error_message = Errors.CMAB_FETCH_FAILED_DETAILED.format( @@ -468,49 +490,25 @@ def get_variation( # If so, handle CMAB-specific traffic allocation and decision logic. # Otherwise, proceed with standard bucketing logic for non-CMAB experiments. if experiment.cmab: - CMAB_DUMMY_ENTITY_ID = "$" - # Build the CMAB-specific traffic allocation - cmab_traffic_allocation: list[TrafficAllocation] = [{ - "entityId": CMAB_DUMMY_ENTITY_ID, - "endOfRange": experiment.cmab['trafficAllocation'] - }] - - # Check if user is in CMAB traffic allocation - group = None - if experiment.groupId: - group = project_config.get_group(group_id=experiment.groupId) - bucketed_entity_id, bucket_reasons = self.bucketer.bucket_to_entity_id( - bucketing_id, experiment, cmab_traffic_allocation, group - ) - decide_reasons += bucket_reasons - if bucketed_entity_id != CMAB_DUMMY_ENTITY_ID: - message = f'User "{user_id}" not in CMAB experiment "{experiment.key}" due to traffic allocation.' - self.logger.info(message) - decide_reasons.append(message) - return { - 'cmab_uuid': None, - 'error': False, - 'reasons': decide_reasons, - 'variation': None - } - - # User is in CMAB allocation, proceed to CMAB decision + 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 not cmab_decision or cmab_decision_result['error']: + if cmab_decision_result['error']: return { 'cmab_uuid': None, 'error': True, 'reasons': decide_reasons, 'variation': None } - variation_id = cmab_decision['variation_id'] - cmab_uuid = cmab_decision['cmab_uuid'] - variation = project_config.get_variation_from_id(experiment_key=experiment.key, variation_id=variation_id) + 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) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index a1cbafb06..0fa7350ef 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -1369,14 +1369,19 @@ def _decide_for_keys( user_context, merged_decide_options ) - + print("here") for i in range(0, len(flags_without_forced_decision)): decision = decision_list[i]['decision'] reasons = decision_list[i]['reasons'] - # Can catch errors now. Not used as decision logic implicitly handles error decision. - # Will be required for impression events - # error = decision_list[i]['error'] + 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 d25e24971..d906a3cfc 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -781,21 +781,22 @@ def test_get_variation_cmab_experiment_user_in_traffic_allocation(self): cmab={'trafficAllocation': 5000} ) - cmab_decision = { - 'variation_id': '111151', - 'cmab_uuid': 'test-cmab-uuid-123' - } - 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': False, 'result': cmab_decision, 'reasons': []}), \ + 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, @@ -814,6 +815,22 @@ def test_get_variation_cmab_experiment_user_in_traffic_allocation(self): 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.') @@ -844,9 +861,9 @@ def test_get_variation_cmab_experiment_user_not_in_traffic_allocation(self): 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=['not_in_allocation', []]), \ - mock.patch('optimizely.decision_service.DecisionService._get_decision_for_cmab_experiment' - ) as mock_cmab_decision, \ + 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: @@ -868,7 +885,17 @@ def test_get_variation_cmab_experiment_user_not_in_traffic_allocation(self): self.assertStrictFalse(error) self.assertIn('User "test_user" not in CMAB experiment "cmab_experiment" due to traffic allocation.', reasons) - mock_cmab_decision.assert_not_called() + + # 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 ' @@ -922,74 +949,6 @@ def test_get_variation_cmab_experiment_service_error(self): self.assertIn('CMAB service error', reasons) self.assertStrictTrue(error) - def test_get_variation_cmab_experiment_deep_mock_500_error(self): - """Test the full flow of a CMAB experiment with a 500 error from the HTTP request layer.""" - import requests - from optimizely.exceptions import CmabFetchError - from optimizely.helpers.enums import Errors - - # 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} - ) - - # Define HTTP error details - http_error = requests.exceptions.HTTPError("500 Server Error") - error_message = Errors.CMAB_FETCH_FAILED.format(http_error) - detailed_error_message = Errors.CMAB_FETCH_FAILED_DETAILED.format(cmab_experiment.key, error_message) - - # Set up mocks for the entire call chain - 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.object(self.decision_service.cmab_service, 'get_decision', - side_effect=lambda *args, - **kwargs: self.decision_service.cmab_service._fetch_decision(*args, **kwargs)), \ - mock.patch.object(self.decision_service.cmab_service, '_fetch_decision', - side_effect=lambda *args, - **kwargs: self. - decision_service.cmab_service.cmab_client.fetch_decision(*args, **kwargs)), \ - mock.patch.object(self.decision_service.cmab_service.cmab_client, 'fetch_decision', - side_effect=lambda *args, - **kwargs: self.decision_service.cmab_service.cmab_client._do_fetch(*args, **kwargs)), \ - mock.patch.object(self.decision_service.cmab_service.cmab_client, '_do_fetch', - side_effect=CmabFetchError(error_message)), \ - 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'] - reasons = variation_result['reasons'] - - # Verify we get no variation due to CMAB service error - self.assertIsNone(variation) - self.assertIsNone(cmab_uuid) - self.assertIn(detailed_error_message, reasons) - - # Verify logger was called with the specific 500 error - mock_logger.error.assert_any_call(detailed_error_message) - def test_get_variation_cmab_experiment_forced_variation(self): """Test get_variation with CMAB experiment when user has a forced variation.""" diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index da13fc04c..bcf63fb4c 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -5720,3 +5720,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) From 0bc4fbd731ed42fd2221a1d0f1cfc1a38e967235 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Thu, 3 Jul 2025 16:50:57 +0600 Subject: [PATCH 36/38] update: remove debug print statement from Optimizely class --- optimizely/optimizely.py | 1 - 1 file changed, 1 deletion(-) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index 0fa7350ef..ebbde985d 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -1369,7 +1369,6 @@ def _decide_for_keys( user_context, merged_decide_options ) - print("here") for i in range(0, len(flags_without_forced_decision)): decision = decision_list[i]['decision'] reasons = decision_list[i]['reasons'] From fcdad1f0fd0dcc9b5dc691dd4d993c10fbfd7faa Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Thu, 3 Jul 2025 18:25:07 +0600 Subject: [PATCH 37/38] update: enhance bucketing logic to support CMAB traffic allocations --- optimizely/bucketer.py | 10 +++++++++- optimizely/decision_service.py | 1 - 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/optimizely/bucketer.py b/optimizely/bucketer.py index eb66f9bfe..e4ae04efe 100644 --- a/optimizely/bucketer.py +++ b/optimizely/bucketer.py @@ -179,8 +179,16 @@ def bucket_to_entity_id( 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) + experiment.id, traffic_allocations) return variation_id, decide_reasons diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index 8a06e0584..de1e071ef 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -490,7 +490,6 @@ def get_variation( # If so, handle CMAB-specific traffic allocation and decision logic. # Otherwise, proceed with standard bucketing logic for non-CMAB experiments. if experiment.cmab: - experiment.cmab cmab_decision_result = self._get_decision_for_cmab_experiment(project_config, experiment, user_context, From aca7df4682d22f618fdc91af0b4ba1ac84757581 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Thu, 3 Jul 2025 18:39:45 +0600 Subject: [PATCH 38/38] update: improve error logging for CMAB decision fetch failures --- optimizely/decision_service.py | 5 ++--- optimizely/helpers/enums.py | 6 +++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index de1e071ef..dafbdc508 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -185,11 +185,10 @@ def _get_decision_for_cmab_experiment( } except Exception as e: error_message = Errors.CMAB_FETCH_FAILED_DETAILED.format( - experiment.key, - str(e) + experiment.key ) if self.logger: - self.logger.error(error_message) + self.logger.error(f'{error_message} {str(e)}') return { "error": True, "result": None, diff --git a/optimizely/helpers/enums.py b/optimizely/helpers/enums.py index f45c7a3bb..e3acafef2 100644 --- a/optimizely/helpers/enums.py +++ b/optimizely/helpers/enums.py @@ -127,9 +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: Final = 'Invalid CMAB fetch response' - CMAB_FETCH_FAILED_DETAILED: Final = 'Failed to fetch CMAB decision for experiment key "{}" - {}' + 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: