From 88b4f1e4ca833d2e1534fcf52f488814882f7fc3 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 4 Jun 2025 08:27:56 +0600 Subject: [PATCH 01/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] - 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] -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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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: From 72955a057e2e8d4714a2996c2688615dabdd9774 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Mon, 7 Jul 2025 21:58:25 +0600 Subject: [PATCH 39/41] update: improve logging and error handling in bucketer and decision service --- optimizely/bucketer.py | 2 +- optimizely/decision/optimizely_decision.py | 2 +- optimizely/decision_service.py | 3 ++- tests/test_bucketing.py | 14 +++++++++++--- tests/test_optimizely.py | 2 +- 5 files changed, 16 insertions(+), 7 deletions(-) diff --git a/optimizely/bucketer.py b/optimizely/bucketer.py index e4ae04efe..1bd7ff527 100644 --- a/optimizely/bucketer.py +++ b/optimizely/bucketer.py @@ -124,7 +124,7 @@ def bucket( variation = project_config.get_variation_from_id_by_experiment_id(experiment.id, variation_id) return variation, decide_reasons - elif not decide_reasons: + else: message = 'Bucketed into an empty traffic range. Returning nil.' project_config.logger.info(message) decide_reasons.append(message) diff --git a/optimizely/decision/optimizely_decision.py b/optimizely/decision/optimizely_decision.py index cca770662..ee97e39e2 100644 --- a/optimizely/decision/optimizely_decision.py +++ b/optimizely/decision/optimizely_decision.py @@ -66,5 +66,5 @@ def new_error_decision(cls, key: str, user: OptimizelyUserContext, reasons: list rule_key=None, flag_key=key, user_context=user, - reasons=[reasons[-1]] if reasons else [] + reasons=reasons if reasons else [] ) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index dafbdc508..d22bec87c 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -187,12 +187,13 @@ def _get_decision_for_cmab_experiment( error_message = Errors.CMAB_FETCH_FAILED_DETAILED.format( experiment.key ) + decide_reasons.append(error_message) if self.logger: self.logger.error(f'{error_message} {str(e)}') return { "error": True, "result": None, - "reasons": [error_message], + "reasons": decide_reasons, } def set_forced_variation( diff --git a/tests/test_bucketing.py b/tests/test_bucketing.py index 36adce754..973cbe376 100644 --- a/tests/test_bucketing.py +++ b/tests/test_bucketing.py @@ -337,7 +337,12 @@ def test_bucket__experiment_in_group(self): variation ) mock_config_logging.debug.assert_called_once_with('Assigned bucket 8400 to user with bucketing ID "test_user".') - mock_config_logging.info.assert_called_once_with('User "test_user" is in no experiment.') + mock_config_logging.info.assert_has_calls( + [ + mock.call('User "test_user" is in no experiment.'), + mock.call('Bucketed into an empty traffic range. Returning nil.') + ] + ) # In group, no matching experiment with mock.patch( @@ -378,8 +383,11 @@ def test_bucket__experiment_in_group(self): variation ) mock_config_logging.debug.assert_called_once_with('Assigned bucket 42 to user with bucketing ID "test_user".') - mock_config_logging.info.assert_called_once_with( - 'User "test_user" is not in experiment "group_exp_2" of group 19228.' + mock_config_logging.info.assert_has_calls( + [ + mock.call('User "test_user" is not in experiment "group_exp_2" of group 19228.'), + mock.call('Bucketed into an empty traffic range. Returning nil.') + ] ) # In group no matching variation diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index bcf63fb4c..f494a766e 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -5743,7 +5743,7 @@ def test_decide_returns_error_decision_when_decision_service_fails(self): ): # 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) From db1c99119174d850120181090bcffe5cde46adce Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 18 Jul 2025 10:17:02 +0600 Subject: [PATCH 40/41] update: add test for CMAB UUID handling in decision events. Removed two redundant tests on legacy api. --- tests/test_optimizely.py | 125 +++++++++++++++++++++++---------------- 1 file changed, 74 insertions(+), 51 deletions(-) diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index c95b4113d..ac4e2172e 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -4890,57 +4890,6 @@ def test_odp_events_not_sent_with_legacy_apis(self): client.close() - def test_get_variation_with_cmab_uuid(self): - """ Test that get_variation works correctly with CMAB UUID. """ - expected_cmab_uuid = "get-variation-cmab-uuid" - variation_result = { - 'variation': self.project_config.get_variation_from_id('test_experiment', '111129'), - 'cmab_uuid': expected_cmab_uuid, - 'reasons': [], - 'error': False - } - - with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation', - return_value=variation_result, - ), mock.patch('optimizely.notification_center.NotificationCenter.send_notifications') as mock_broadcast: - variation = self.optimizely.get_variation('test_experiment', 'test_user') - self.assertEqual('variation', variation) - - # Verify decision notification is sent with correct parameters - mock_broadcast.assert_any_call( - enums.NotificationTypes.DECISION, - 'ab-test', - 'test_user', - {}, - {'experiment_key': 'test_experiment', 'variation_key': 'variation'}, - ) - - def test_get_variation_without_cmab_uuid(self): - """ Test that get_variation works correctly when CMAB UUID is None. """ - variation_result = { - 'variation': self.project_config.get_variation_from_id('test_experiment', '111129'), - 'cmab_uuid': None, - 'reasons': [], - 'error': False - } - - with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation', - return_value=variation_result, - ), mock.patch('optimizely.notification_center.NotificationCenter.send_notifications') as mock_broadcast: - variation = self.optimizely.get_variation('test_experiment', 'test_user') - self.assertEqual('variation', variation) - - # Verify decision notification is sent correctly - mock_broadcast.assert_any_call( - enums.NotificationTypes.DECISION, - 'ab-test', - 'test_user', - {}, - {'experiment_key': 'test_experiment', 'variation_key': 'variation'}, - ) - class OptimizelyWithExceptionTest(base.BaseTest): def setUp(self): @@ -5801,3 +5750,77 @@ def test_decide_returns_error_decision_when_decision_service_fails(self): self.assertIsNone(decision.rule_key) self.assertEqual(decision.flag_key, 'test_feature_in_experiment') self.assertIn('CMAB service failed to fetch decision', decision.reasons) + + def test_decide_includes_cmab_uuid_in_dispatched_event(self): + """Test that decide calls UserEventFactory.create_impression_event with correct CMAB UUID.""" + 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') + project_config = opt_obj.config_manager.get_config() + + # Mock decision service to return a CMAB result + expected_cmab_uuid = 'uuid-cmab' + mock_experiment = project_config.get_experiment_from_key('test_experiment') + mock_variation = project_config.get_variation_from_id('test_experiment', '111129') + decision_result = { + 'decision': decision_service.Decision( + mock_experiment, + mock_variation, + enums.DecisionSources.FEATURE_TEST, + expected_cmab_uuid + ), + 'reasons': [], + 'error': False + } + + with mock.patch.object( + opt_obj.decision_service, 'get_variations_for_feature_list', + return_value=[decision_result] + ), mock.patch( + 'optimizely.event.user_event_factory.UserEventFactory.create_impression_event' + ) as mock_create_impression, mock.patch( + 'time.time', return_value=42 + ), mock.patch( + 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' + ): + # Call decide + decision = user_context.decide('test_feature_in_experiment') + + # Verify the decision contains the expected information + self.assertTrue(decision.enabled) + self.assertEqual(decision.variation_key, 'variation') + self.assertEqual(decision.rule_key, 'test_experiment') + self.assertEqual(decision.flag_key, 'test_feature_in_experiment') + + # Verify that create_impression_event was called once + mock_create_impression.assert_called_once() + + # Get the call arguments + call_args = mock_create_impression.call_args[0] + + # Verify the correct parameters were passed + project_config_arg = call_args[0] + experiment_arg = call_args[1] + variation_id_arg = call_args[2] + flag_key_arg = call_args[3] + rule_key_arg = call_args[4] + rule_type_arg = call_args[5] + enabled_arg = call_args[6] + user_id_arg = call_args[7] + attributes_arg = call_args[8] + cmab_uuid_arg = call_args[9] + + # Verify all parameters + self.assertEqual(project_config_arg, project_config) + self.assertEqual(experiment_arg, mock_experiment) + self.assertEqual(variation_id_arg, '111129') # variation.id + self.assertEqual(flag_key_arg, 'test_feature_in_experiment') + self.assertEqual(rule_key_arg, 'test_experiment') + self.assertEqual(rule_type_arg, str(enums.DecisionSources.FEATURE_TEST)) + self.assertTrue(enabled_arg) + self.assertEqual(user_id_arg, 'test_user') + self.assertEqual(attributes_arg, {}) + self.assertEqual(cmab_uuid_arg, expected_cmab_uuid) From 67220bb747cd6c65a83a5ca69f96660bc313dd96 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 18 Jul 2025 22:35:18 +0600 Subject: [PATCH 41/41] update: enhance CMAB UUID handling in decide method and verify dispatched events --- tests/test_optimizely.py | 96 ++++++++++++++++++++-------------------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index ac4e2172e..c2de186fa 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -5752,12 +5752,26 @@ def test_decide_returns_error_decision_when_decision_service_fails(self): self.assertIn('CMAB service failed to fetch decision', decision.reasons) def test_decide_includes_cmab_uuid_in_dispatched_event(self): - """Test that decide calls UserEventFactory.create_impression_event with correct CMAB UUID.""" + """Test that decide dispatches event with correct CMAB UUID.""" import copy + from typing import List 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)) + + class TestEventDispatcher: + """Custom event dispatcher for testing that captures dispatched events.""" + + def __init__(self): + self.dispatched_events: List[event_builder.Event] = [] + + def dispatch_event(self, event: event_builder.Event) -> None: + """Capture the event instead of actually dispatching it.""" + self.dispatched_events.append(event) + + test_dispatcher = TestEventDispatcher() + + opt_obj = optimizely.Optimizely(json.dumps(config_dict), event_dispatcher=test_dispatcher) user_context = opt_obj.create_user_context('test_user') project_config = opt_obj.config_manager.get_config() @@ -5765,26 +5779,23 @@ def test_decide_includes_cmab_uuid_in_dispatched_event(self): expected_cmab_uuid = 'uuid-cmab' mock_experiment = project_config.get_experiment_from_key('test_experiment') mock_variation = project_config.get_variation_from_id('test_experiment', '111129') - decision_result = { - 'decision': decision_service.Decision( - mock_experiment, - mock_variation, - enums.DecisionSources.FEATURE_TEST, - expected_cmab_uuid - ), - 'reasons': [], - 'error': False - } + # Create decision with CMAB UUID + decision_with_cmab = decision_service.Decision( + mock_experiment, + mock_variation, + enums.DecisionSources.FEATURE_TEST, + expected_cmab_uuid + ) + + # Mock the decision service method that's actually called by decide with mock.patch.object( opt_obj.decision_service, 'get_variations_for_feature_list', - return_value=[decision_result] - ), mock.patch( - 'optimizely.event.user_event_factory.UserEventFactory.create_impression_event' - ) as mock_create_impression, mock.patch( - 'time.time', return_value=42 - ), mock.patch( - 'uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c' + return_value=[{ + 'decision': decision_with_cmab, + 'reasons': [], + 'error': False + }] ): # Call decide decision = user_context.decide('test_feature_in_experiment') @@ -5795,32 +5806,21 @@ def test_decide_includes_cmab_uuid_in_dispatched_event(self): self.assertEqual(decision.rule_key, 'test_experiment') self.assertEqual(decision.flag_key, 'test_feature_in_experiment') - # Verify that create_impression_event was called once - mock_create_impression.assert_called_once() - - # Get the call arguments - call_args = mock_create_impression.call_args[0] - - # Verify the correct parameters were passed - project_config_arg = call_args[0] - experiment_arg = call_args[1] - variation_id_arg = call_args[2] - flag_key_arg = call_args[3] - rule_key_arg = call_args[4] - rule_type_arg = call_args[5] - enabled_arg = call_args[6] - user_id_arg = call_args[7] - attributes_arg = call_args[8] - cmab_uuid_arg = call_args[9] - - # Verify all parameters - self.assertEqual(project_config_arg, project_config) - self.assertEqual(experiment_arg, mock_experiment) - self.assertEqual(variation_id_arg, '111129') # variation.id - self.assertEqual(flag_key_arg, 'test_feature_in_experiment') - self.assertEqual(rule_key_arg, 'test_experiment') - self.assertEqual(rule_type_arg, str(enums.DecisionSources.FEATURE_TEST)) - self.assertTrue(enabled_arg) - self.assertEqual(user_id_arg, 'test_user') - self.assertEqual(attributes_arg, {}) - self.assertEqual(cmab_uuid_arg, expected_cmab_uuid) + # Verify an event was dispatched + time.sleep(0.1) + self.assertEqual(len(test_dispatcher.dispatched_events), 1) + + dispatched_event = test_dispatcher.dispatched_events[0] + + # Verify the structure exists before accessing + self.assertIn('visitors', dispatched_event.params) + self.assertTrue(len(dispatched_event.params['visitors']) > 0) + self.assertIn('snapshots', dispatched_event.params['visitors'][0]) + self.assertTrue(len(dispatched_event.params['visitors'][0]['snapshots']) > 0) + self.assertIn('decisions', dispatched_event.params['visitors'][0]['snapshots'][0]) + self.assertTrue(len(dispatched_event.params['visitors'][0]['snapshots'][0]['decisions']) > 0) + + # Get the metadata and assert CMAB UUID + metadata = dispatched_event.params['visitors'][0]['snapshots'][0]['decisions'][0]['metadata'] + self.assertIn('cmab_uuid', metadata) + self.assertEqual(metadata['cmab_uuid'], expected_cmab_uuid) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy