diff --git a/.travis.yml b/.travis.yml index bc8dc9da..0df41c9b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,7 @@ python: - "2.7" - "3.4" install: "pip install -r requirements/core.txt;pip install -r requirements/test.txt" +before_script: "pep8" script: "nosetests --with-coverage --cover-package=optimizely" after_success: - coveralls diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b8664ec..47821529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 1.3.0 +- Forced bucketing. +- Numeric metrics. +- Updated event builder to support new endpoint. + # 1.2.1 - Removed older feature flag parsing. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf7cfaa6..a09a4dea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ We welcome contributions and feedback! All contributors must sign our [Contribut ## Style -We enforce PEP-8 rules with a few minor deviations. +We enforce PEP-8 rules with a few minor [deviations](https://github.com/optimizely/python-sdk/blob/master/tox.ini). ## License diff --git a/README.md b/README.md index f5076502..713d68de 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ nosetests tests.test_event:EventTest To run a single test you can use the following command: ``` -nosetests tests.:ClassName:test_name +nosetests tests.:ClassName.test_name ``` For example, to run `test_event.EventTest.test_dispatch`, the command would be: diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index 5639771f..527adecb 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -99,6 +99,11 @@ def get_variation(self, experiment, user_id, attributes): self.logger.log(enums.LogLevels.INFO, 'Experiment "%s" is not running.' % experiment.key) return None + # Check if the user is forced into a variation + variation = self.config.get_forced_variation(experiment.key, user_id) + if variation: + return variation + # Check to see if user is white-listed for a certain variation variation = self.get_forced_variation(experiment, user_id) if variation: diff --git a/optimizely/entities.py b/optimizely/entities.py index e03158ec..0185a5e1 100644 --- a/optimizely/entities.py +++ b/optimizely/entities.py @@ -11,6 +11,7 @@ # See the License for the specific language governing permissions and # limitations under the License. + class BaseEntity(object): def __eq__(self, other): diff --git a/optimizely/error_handler.py b/optimizely/error_handler.py index 7d64787d..d3b70acd 100644 --- a/optimizely/error_handler.py +++ b/optimizely/error_handler.py @@ -11,6 +11,7 @@ # See the License for the specific language governing permissions and # limitations under the License. + class BaseErrorHandler(object): """ Class encapsulating exception handling functionality. Override with your own exception handler providing handle_error method. """ diff --git a/optimizely/event_builder.py b/optimizely/event_builder.py index 67ee655a..b40f1191 100644 --- a/optimizely/event_builder.py +++ b/optimizely/event_builder.py @@ -12,6 +12,7 @@ # limitations under the License. import time +import uuid from abc import abstractmethod from abc import abstractproperty @@ -97,34 +98,32 @@ def _add_common_params(self, user_id, attributes): class EventBuilder(BaseEventBuilder): """ Class which encapsulates methods to build events for tracking - impressions and conversions using the new endpoints. """ + impressions and conversions using the new V3 event API (batch). """ - IMPRESSION_ENDPOINT = 'https://logx.optimizely.com/log/decision' - CONVERSION_ENDPOINT = 'https://logx.optimizely.com/log/event' + EVENTS_URL = 'https://logx.optimizely.com/v1/events' HTTP_VERB = 'POST' HTTP_HEADERS = {'Content-Type': 'application/json'} class EventParams(object): - ACCOUNT_ID = 'accountId' - PROJECT_ID = 'projectId' - LAYER_ID = 'layerId' - EXPERIMENT_ID = 'experimentId' - VARIATION_ID = 'variationId' - END_USER_ID = 'visitorId' - EVENT_ID = 'eventEntityId' - EVENT_NAME = 'eventName' - EVENT_METRICS = 'eventMetrics' - EVENT_FEATURES = 'eventFeatures' - USER_FEATURES = 'userFeatures' - DECISION = 'decision' - LAYER_STATES = 'layerStates' - REVISION = 'revision' + ACCOUNT_ID = 'account_id' + PROJECT_ID = 'project_id' + EXPERIMENT_ID = 'experiment_id' + CAMPAIGN_ID = 'campaign_id' + VARIATION_ID = 'variation_id' + END_USER_ID = 'visitor_id' + EVENTS = 'events' + EVENT_ID = 'entity_id' + ATTRIBUTES = 'attributes' + DECISIONS = 'decisions' TIME = 'timestamp' - SOURCE_SDK_TYPE = 'clientEngine' - SOURCE_SDK_VERSION = 'clientVersion' - ACTION_TRIGGERED = 'actionTriggered' - IS_GLOBAL_HOLDBACK = 'isGlobalHoldback' - IS_LAYER_HOLDBACK = 'isLayerHoldback' + KEY = 'key' + TAGS = 'tags' + UUID = 'uuid' + USERS = 'visitors' + SNAPSHOTS = 'snapshots' + SOURCE_SDK_TYPE = 'client_name' + SOURCE_SDK_VERSION = 'client_version' + CUSTOM = 'custom' def _add_attributes(self, attributes): """ Add attribute(s) information to the event. @@ -133,7 +132,9 @@ def _add_attributes(self, attributes): attributes: Dict representing user attributes and values which need to be recorded. """ - self.params[self.EventParams.USER_FEATURES] = [] + visitor = self.params[self.EventParams.USERS][0] + visitor[self.EventParams.ATTRIBUTES] = [] + if not attributes: return @@ -143,12 +144,11 @@ def _add_attributes(self, attributes): if attribute_value: attribute = self.config.get_attribute(attribute_key) if attribute: - self.params[self.EventParams.USER_FEATURES].append({ - 'id': attribute.id, - 'name': attribute_key, - 'type': 'custom', + visitor[self.EventParams.ATTRIBUTES].append({ + self.EventParams.EVENT_ID: attribute.id, + 'key': attribute_key, + 'type': self.EventParams.CUSTOM, 'value': attribute_value, - 'shouldIndex': True }) def _add_source(self): @@ -157,15 +157,34 @@ def _add_source(self): self.params[self.EventParams.SOURCE_SDK_TYPE] = 'python-sdk' self.params[self.EventParams.SOURCE_SDK_VERSION] = version.__version__ - def _add_revision(self): - """ Add datafile revision information to the event. """ - self.params[self.EventParams.REVISION] = self.config.get_revision() - def _add_time(self): """ Add time information to the event. """ self.params[self.EventParams.TIME] = int(round(time.time() * 1000)) + def _add_visitor(self, user_id): + """ Add user to the event """ + + self.params[self.EventParams.USERS] = [] + # Add a single visitor + visitor = {} + visitor[self.EventParams.END_USER_ID] = user_id + visitor[self.EventParams.SNAPSHOTS] = [] + self.params[self.EventParams.USERS].append(visitor) + + def _add_common_params(self, user_id, attributes): + """ Add params which are used same in both conversion and impression events. + + Args: + user_id: ID for user. + attributes: Dict representing user attributes and values which need to be recorded. + """ + self._add_project_id() + self._add_account_id() + self._add_visitor(user_id) + self._add_attributes(attributes) + self._add_source() + def _add_required_params_for_impression(self, experiment, variation_id): """ Add parameters that are required for the impression event to register. @@ -173,14 +192,23 @@ def _add_required_params_for_impression(self, experiment, variation_id): experiment: Experiment for which impression needs to be recorded. variation_id: ID for variation which would be presented to user. """ + snapshot = {} - self.params[self.EventParams.IS_GLOBAL_HOLDBACK] = False - self.params[self.EventParams.LAYER_ID] = experiment.layerId - self.params[self.EventParams.DECISION] = { + snapshot[self.EventParams.DECISIONS] = [{ self.EventParams.EXPERIMENT_ID: experiment.id, self.EventParams.VARIATION_ID: variation_id, - self.EventParams.IS_LAYER_HOLDBACK: False - } + self.EventParams.CAMPAIGN_ID: experiment.layerId + }] + + snapshot[self.EventParams.EVENTS] = [{ + self.EventParams.EVENT_ID: experiment.layerId, + self.EventParams.TIME: int(round(time.time() * 1000)), + self.EventParams.KEY: 'campaign_activated', + self.EventParams.UUID: str(uuid.uuid4()) + }] + + visitor = self.params[self.EventParams.USERS][0] + visitor[self.EventParams.SNAPSHOTS].append(snapshot) def _add_required_params_for_conversion(self, event_key, event_tags, decisions): """ Add parameters that are required for the conversion event to register. @@ -191,47 +219,40 @@ def _add_required_params_for_conversion(self, event_key, event_tags, decisions): decisions: List of tuples representing valid experiments IDs and variation IDs. """ - self.params[self.EventParams.IS_GLOBAL_HOLDBACK] = False - self.params[self.EventParams.EVENT_FEATURES] = [] - self.params[self.EventParams.EVENT_METRICS] = [] - - if event_tags: - event_value = event_tag_utils.get_revenue_value(event_tags) - if event_value is not None: - self.params[self.EventParams.EVENT_METRICS] = [{ - 'name': event_tag_utils.EVENT_VALUE_METRIC, - 'value': event_value - }] - - for event_tag_id in event_tags.keys(): - event_tag_value = event_tags.get(event_tag_id) - if event_tag_value is None: - continue - - event_feature = { - 'name': event_tag_id, - 'type': 'custom', - 'value': event_tag_value, - 'shouldIndex': False, - } - self.params[self.EventParams.EVENT_FEATURES].append(event_feature) + visitor = self.params[self.EventParams.USERS][0] - self.params[self.EventParams.LAYER_STATES] = [] for experiment_id, variation_id in decisions: + snapshot = {} experiment = self.config.get_experiment_from_id(experiment_id) - self.params[self.EventParams.LAYER_STATES].append({ - self.EventParams.LAYER_ID: experiment.layerId, - self.EventParams.REVISION: self.config.get_revision(), - self.EventParams.ACTION_TRIGGERED: True, - self.EventParams.DECISION: { - self.EventParams.EXPERIMENT_ID: experiment.id, + + if variation_id: + snapshot[self.EventParams.DECISIONS] = [{ + self.EventParams.EXPERIMENT_ID: experiment_id, self.EventParams.VARIATION_ID: variation_id, - self.EventParams.IS_LAYER_HOLDBACK: False + self.EventParams.CAMPAIGN_ID: experiment.layerId + }] + + event_dict = { + self.EventParams.EVENT_ID: self.config.get_event(event_key).id, + self.EventParams.TIME: int(round(time.time() * 1000)), + self.EventParams.KEY: event_key, + self.EventParams.UUID: str(uuid.uuid4()) } - }) - self.params[self.EventParams.EVENT_ID] = self.config.get_event(event_key).id - self.params[self.EventParams.EVENT_NAME] = event_key + if event_tags: + revenue_value = event_tag_utils.get_revenue_value(event_tags) + if revenue_value is not None: + event_dict[event_tag_utils.REVENUE_METRIC_TYPE] = revenue_value + + numeric_value = event_tag_utils.get_numeric_value(event_tags, self.config.logger) + if numeric_value is not None: + event_dict[event_tag_utils.NUMERIC_METRIC_TYPE] = numeric_value + + if len(event_tags) > 0: + event_dict[self.EventParams.TAGS] = event_tags + + snapshot[self.EventParams.EVENTS] = [event_dict] + visitor[self.EventParams.SNAPSHOTS].append(snapshot) def create_impression_event(self, experiment, variation_id, user_id, attributes): """ Create impression Event to be sent to the logging endpoint. @@ -249,7 +270,8 @@ def create_impression_event(self, experiment, variation_id, user_id, attributes) self.params = {} self._add_common_params(user_id, attributes) self._add_required_params_for_impression(experiment, variation_id) - return Event(self.IMPRESSION_ENDPOINT, + + return Event(self.EVENTS_URL, self.params, http_verb=self.HTTP_VERB, headers=self.HTTP_HEADERS) @@ -271,7 +293,7 @@ def create_conversion_event(self, event_key, user_id, attributes, event_tags, de self.params = {} self._add_common_params(user_id, attributes) self._add_required_params_for_conversion(event_key, event_tags, decisions) - return Event(self.CONVERSION_ENDPOINT, + return Event(self.EVENTS_URL, self.params, http_verb=self.HTTP_VERB, headers=self.HTTP_HEADERS) diff --git a/optimizely/exceptions.py b/optimizely/exceptions.py index 715edbc9..6cadf0fb 100644 --- a/optimizely/exceptions.py +++ b/optimizely/exceptions.py @@ -11,6 +11,7 @@ # See the License for the specific language governing permissions and # limitations under the License. + class InvalidAttributeException(Exception): """ Raised when provided attribute is invalid. """ pass diff --git a/optimizely/helpers/event_tag_utils.py b/optimizely/helpers/event_tag_utils.py index d39e96f9..05e69c42 100644 --- a/optimizely/helpers/event_tag_utils.py +++ b/optimizely/helpers/event_tag_utils.py @@ -11,9 +11,12 @@ # See the License for the specific language governing permissions and # limitations under the License. +from . import enums +import math import numbers -EVENT_VALUE_METRIC = 'revenue' +REVENUE_METRIC_TYPE = 'revenue' +NUMERIC_METRIC_TYPE = 'value' def get_revenue_value(event_tags): @@ -23,12 +26,95 @@ def get_revenue_value(event_tags): if not isinstance(event_tags, dict): return None - if EVENT_VALUE_METRIC not in event_tags: + if REVENUE_METRIC_TYPE not in event_tags: return None - raw_value = event_tags[EVENT_VALUE_METRIC] + raw_value = event_tags[REVENUE_METRIC_TYPE] if not isinstance(raw_value, numbers.Integral): return None return raw_value + + +def get_numeric_value(event_tags, logger=None): + """ + A smart getter of the numeric value from the event tags. + + Args: + event_tags: A dictionary of event tags. + logger: Optional logger. + + Returns: + A float numeric metric value is returned when the provided numeric + metric value is in the following format: + - A string (properly formatted, e.g., no commas) + - An integer + - A float or double + None is returned when the provided numeric metric values is in + the following format: + - None + - A boolean + - inf, -inf, nan + - A string not properly formatted (e.g., '1,234') + - Any values that cannot be cast to a float (e.g., an array or dictionary) + """ + + logger_message_debug = None + numeric_metric_value = None + + if event_tags is None: + logger_message_debug = 'Event tags is undefined.' + elif not isinstance(event_tags, dict): + logger_message_debug = 'Event tags is not a dictionary.' + elif NUMERIC_METRIC_TYPE not in event_tags: + logger_message_debug = 'The numeric metric key is not in event tags.' + else: + numeric_metric_value = event_tags[NUMERIC_METRIC_TYPE] + try: + if isinstance(numeric_metric_value, (numbers.Integral, float, str)): + # Attempt to convert the numeric metric value to a float + # (if it isn't already a float). + cast_numeric_metric_value = float(numeric_metric_value) + + # If not a float after casting, then make everything else a None. + # Other potential values are nan, inf, and -inf. + if not isinstance(cast_numeric_metric_value, float) \ + or math.isnan(cast_numeric_metric_value) \ + or math.isinf(cast_numeric_metric_value): + logger_message_debug = 'Provided numeric value {} is in an invalid format.'\ + .format(numeric_metric_value) + numeric_metric_value = None + else: + # Handle booleans as a special case. + # They are treated like an integer in the cast, but we do not want to cast this. + if isinstance(numeric_metric_value, bool): + logger_message_debug = 'Provided numeric value is a boolean, which is an invalid format.' + numeric_metric_value = None + else: + numeric_metric_value = cast_numeric_metric_value + else: + logger_message_debug = 'Numeric metric value is not in integer, float, or string form.' + numeric_metric_value = None + + except ValueError: + logger_message_debug = 'Value error while casting numeric metric value to a float.' + numeric_metric_value = None + + # Log all potential debug messages while converting the numeric value to a float. + if logger and logger_message_debug: + logger.log(enums.LogLevels.DEBUG, logger_message_debug) + + # Log the final numeric metric value + if numeric_metric_value is not None: + if logger: + logger.log(enums.LogLevels.INFO, + 'The numeric metric value {} will be sent to results.' + .format(numeric_metric_value)) + else: + if logger: + logger.log(enums.LogLevels.WARNING, + 'The provided numeric metric value {} is in an invalid format and will not be sent to results.' + .format(numeric_metric_value)) + + return numeric_metric_value diff --git a/optimizely/helpers/validator.py b/optimizely/helpers/validator.py index 9f5d3901..55f828f0 100644 --- a/optimizely/helpers/validator.py +++ b/optimizely/helpers/validator.py @@ -136,10 +136,10 @@ def is_user_profile_valid(user_profile): if not type(user_profile) is dict: return False - if not UserProfile.USER_ID_KEY in user_profile: + if UserProfile.USER_ID_KEY not in user_profile: return False - if not UserProfile.EXPERIMENT_BUCKET_MAP_KEY in user_profile: + if UserProfile.EXPERIMENT_BUCKET_MAP_KEY not in user_profile: return False experiment_bucket_map = user_profile.get(UserProfile.EXPERIMENT_BUCKET_MAP_KEY) diff --git a/optimizely/optimizely.py b/optimizely/optimizely.py index ef28bdd7..fb5c20c4 100644 --- a/optimizely/optimizely.py +++ b/optimizely/optimizely.py @@ -21,6 +21,7 @@ from .error_handler import NoOpErrorHandler as noop_error_handler from .event_dispatcher import EventDispatcher as default_event_dispatcher from .helpers import enums +from .helpers import event_tag_utils from .helpers import validator from .logger import NoOpLogger as noop_logger from .logger import SimpleLogger @@ -92,16 +93,16 @@ def _validate_instantiation_options(self, datafile, skip_json_validation): """ if not skip_json_validation and not validator.is_datafile_valid(datafile): - raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT_ERROR.format('datafile')) + raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT_ERROR.format('datafile')) if not validator.is_event_dispatcher_valid(self.event_dispatcher): - raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT_ERROR.format('event_dispatcher')) + raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT_ERROR.format('event_dispatcher')) if not validator.is_logger_valid(self.logger): - raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT_ERROR.format('logger')) + raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT_ERROR.format('logger')) if not validator.is_error_handler_valid(self.error_handler): - raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT_ERROR.format('error_handler')) + raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT_ERROR.format('error_handler')) def _validate_user_inputs(self, attributes=None, event_tags=None): """ Helper method to validate user inputs. @@ -191,7 +192,7 @@ def activate(self, experiment_key, user_id, attributes=None): return variation.key - def track(self, event_key, user_id, attributes=None, event_tags=None): + def track(self, event_key, user_id, attributes=None, event_tags=None): """ Send conversion event to Optimizely. Args: @@ -261,6 +262,7 @@ def get_variation(self, experiment_key, user_id, attributes=None): return None experiment = self.config.get_experiment_from_key(experiment_key) + if not experiment: self.logger.log(enums.LogLevels.INFO, 'Experiment key "%s" is invalid. Not activating user "%s".' % (experiment_key, @@ -275,3 +277,32 @@ def get_variation(self, experiment_key, user_id, attributes=None): return variation.key return None + + def set_forced_variation(self, experiment_key, user_id, variation_key): + """ Force a user into a variation for a given experiment. + + Args: + experiment_key: A string key identifying the experiment. + user_id: The user ID. + variation_key: A string variation key that specifies the variation which the user. + will be forced into. If null, then clear the existing experiment-to-variation mapping. + + Returns: + A boolean value that indicates if the set completed successfully. + """ + + return self.config.set_forced_variation(experiment_key, user_id, variation_key) + + def get_forced_variation(self, experiment_key, user_id): + """ Gets the forced variation for a given user and experiment. + + Args: + experiment_key: A string key identifying the experiment. + user_id: The user ID. + + Returns: + The forced variation key. None if no forced variation key. + """ + + forced_variation = self.config.get_forced_variation(experiment_key, user_id) + return forced_variation.key if forced_variation else None diff --git a/optimizely/project_config.py b/optimizely/project_config.py index 09ec7a7a..3c0c8d61 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -84,6 +84,12 @@ def __init__(self, datafile, logger, error_handler): self.parsing_succeeded = True + # Map of user IDs to another map of experiments to variations. + # This contains all the forced variations set by the user + # by calling set_forced_variation (it is not the same as the + # whitelisting forcedVariations data structure). + self.forced_variation_map = {} + @staticmethod def _generate_key_map(list, key, entity_class): """ Helper method to generate map from key to entity object for given list of dicts. @@ -340,3 +346,106 @@ def get_attribute(self, attribute_key): self.logger.log(enums.LogLevels.ERROR, 'Attribute "%s" is not in datafile.' % attribute_key) self.error_handler.handle_error(exceptions.InvalidAttributeException(enums.Errors.INVALID_ATTRIBUTE_ERROR)) return None + + def set_forced_variation(self, experiment_key, user_id, variation_key): + """ Sets users to a map of experiments to forced variations. + + Args: + experiment_key: Key for experiment. + user_id: The user ID. + variation_key: Key for variation. If None, then clear the existing experiment-to-variation mapping. + + Returns: + A boolean value that indicates if the set completed successfully. + """ + if not user_id: + self.logger.log(enums.LogLevels.DEBUG, 'User ID is invalid.') + return False + + experiment = self.get_experiment_from_key(experiment_key) + if not experiment: + # The invalid experiment key will be logged inside this call. + return False + + experiment_id = experiment.id + if not variation_key: + if user_id in self.forced_variation_map: + experiment_to_variation_map = self.forced_variation_map.get(user_id) + if experiment_id in experiment_to_variation_map: + del(self.forced_variation_map[user_id][experiment_id]) + self.logger.log(enums.LogLevels.DEBUG, + 'Variation mapped to experiment "%s" has been removed for user "%s".' + % (experiment_key, user_id)) + else: + self.logger.log(enums.LogLevels.DEBUG, + 'Nothing to remove. Variation mapped to experiment "%s" for user "%s" does not exist.' + % (experiment_key, user_id)) + else: + self.logger.log(enums.LogLevels.DEBUG, + 'Nothing to remove. User "%s" does not exist in the forced variation map.' % user_id) + return True + + forced_variation = self.get_variation_from_key(experiment_key, variation_key) + if not forced_variation: + # The invalid variation key will be logged inside this call. + return False + + variation_id = forced_variation.id + + if user_id not in self.forced_variation_map: + self.forced_variation_map[user_id] = {experiment_id: variation_id} + else: + self.forced_variation_map[user_id][experiment_id] = variation_id + + self.logger.log(enums.LogLevels.DEBUG, + 'Set variation "%s" for experiment "%s" and user "%s" in the forced variation map.' + % (variation_id, experiment_id, user_id)) + return True + + def get_forced_variation(self, experiment_key, user_id): + """ Gets the forced variation key for the given user and experiment. + + Args: + experiment_key: Key for experiment. + user_id: The user ID. + + Returns: + The variation which the given user and experiment should be forced into. + """ + if not user_id: + self.logger.log(enums.LogLevels.DEBUG, 'User ID is invalid.') + return None + + if user_id not in self.forced_variation_map: + self.logger.log(enums.LogLevels.DEBUG, 'User "%s" is not in the forced variation map.' % user_id) + return None + + experiment = self.get_experiment_from_key(experiment_key) + if not experiment: + # The invalid experiment key will be logged inside this call. + return None + + experiment_to_variation_map = self.forced_variation_map.get(user_id) + + if not experiment_to_variation_map: + self.logger.log(enums.LogLevels.DEBUG, + 'No experiment "%s" mapped to user "%s" in the forced variation map.' + % (experiment_key, user_id)) + return None + + variation_id = experiment_to_variation_map.get(experiment.id) + if variation_id is None: + self.logger.log(enums.LogLevels.DEBUG, + 'No variation mapped to experiment "%s" in the forced variation map.' + % experiment_key) + return None + + variation = self.get_variation_from_id(experiment_key, variation_id) + if not variation: + # The invalid variation ID will be logged inside this call. + return None + + self.logger.log(enums.LogLevels.DEBUG, + 'Variation "%s" is mapped to experiment "%s" and user "%s" in the forced variation map' + % (variation.key, experiment_key, user_id)) + return variation diff --git a/optimizely/user_profile.py b/optimizely/user_profile.py index 0c9f494e..1e634e32 100644 --- a/optimizely/user_profile.py +++ b/optimizely/user_profile.py @@ -2,7 +2,7 @@ # 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 diff --git a/optimizely/version.py b/optimizely/version.py index e3c3c23c..c5f4445e 100644 --- a/optimizely/version.py +++ b/optimizely/version.py @@ -11,5 +11,5 @@ # See the License for the specific language governing permissions and # limitations under the License. -version_info = (1, 2, 1) +version_info = (1, 3, 0) __version__ = '.'.join(str(v) for v in version_info) diff --git a/tests/base.py b/tests/base.py index fe0f917f..4fa7ccff 100644 --- a/tests/base.py +++ b/tests/base.py @@ -14,7 +14,12 @@ import json import unittest + +from optimizely import error_handler +from optimizely import event_builder +from optimizely import logger from optimizely import optimizely +from optimizely import project_config class BaseTest(unittest.TestCase): @@ -136,3 +141,227 @@ def setUp(self): self.optimizely = optimizely.Optimizely(json.dumps(self.config_dict)) self.project_config = self.optimizely.config + + +class BaseTestV3(unittest.TestCase): + + def setUp(self): + self.config_dict = { + 'revision': '42', + 'version': '2', + 'events': [{ + 'key': 'test_event', + 'experimentIds': ['111127'], + 'id': '111095' + }, { + 'key': 'Total Revenue', + 'experimentIds': ['111127'], + 'id': '111096' + }], + 'experiments': [{ + 'key': 'test_experiment', + 'status': 'Running', + 'forcedVariations': { + 'user_1': 'control', + 'user_2': 'control' + }, + 'layerId': '111182', + 'audienceIds': ['11154'], + 'trafficAllocation': [{ + 'entityId': '111128', + 'endOfRange': 4000 + }, { + 'entityId': '', + 'endOfRange': 5000 + }, { + 'entityId': '111129', + 'endOfRange': 9000 + }], + 'id': '111127', + 'variations': [{ + 'key': 'control', + 'id': '111128' + }, { + 'key': 'variation', + 'id': '111129' + }] + }], + 'groups': [{ + 'id': '19228', + 'policy': 'random', + 'experiments': [{ + 'id': '32222', + 'key': 'group_exp_1', + 'status': 'Running', + 'audienceIds': [], + 'layerId': '111183', + 'variations': [{ + 'key': 'group_exp_1_control', + 'id': '28901' + }, { + 'key': 'group_exp_1_variation', + 'id': '28902' + }], + 'forcedVariations': { + 'user_1': 'group_exp_1_control', + 'user_2': 'group_exp_1_control' + }, + 'trafficAllocation': [{ + 'entityId': '28901', + 'endOfRange': 3000 + }, { + 'entityId': '28902', + 'endOfRange': 9000 + }] + }, { + 'id': '32223', + 'key': 'group_exp_2', + 'status': 'Running', + 'audienceIds': [], + 'layerId': '111184', + 'variations': [{ + 'key': 'group_exp_2_control', + 'id': '28905' + }, { + 'key': 'group_exp_2_variation', + 'id': '28906' + }], + 'forcedVariations': { + 'user_1': 'group_exp_2_control', + 'user_2': 'group_exp_2_control' + }, + 'trafficAllocation': [{ + 'entityId': '28905', + 'endOfRange': 8000 + }, { + 'entityId': '28906', + 'endOfRange': 10000 + }] + }], + 'trafficAllocation': [{ + 'entityId': '32222', + "endOfRange": 3000 + }, { + 'entityId': '32223', + 'endOfRange': 7500 + }] + }], + 'accountId': '12001', + 'attributes': [{ + 'key': 'test_attribute', + 'id': '111094' + }], + 'audiences': [{ + 'name': 'Test attribute users', + 'conditions': '["and", ["or", ["or", ' + '{"name": "test_attribute", "type": "custom_attribute", "value": "test_value"}]]]', + 'id': '11154' + }], + 'projectId': '111001' + } + + # datafile version 4 + self.config_dict_with_features = { + 'revision': '1', + 'accountId': '12001', + 'projectId': '111111', + 'version': '4', + 'events': [{ + 'key': 'test_event', + 'experimentIds': ['111127'], + 'id': '111095' + }], + 'experiments': [{ + 'key': 'test_experiment', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': '111182', + 'audienceIds': [], + 'trafficAllocation': [{ + 'entityId': '111128', + 'endOfRange': 5000 + }, { + 'entityId': '111129', + 'endOfRange': 9000 + }], + 'id': '111127', + 'variations': [{ + 'key': 'control', + 'id': '111128', + 'variables': [{ + 'id': '127', 'value': 'false' + }, { + 'id': '128', 'value': 'prod' + }] + }, { + 'key': 'variation', + 'id': '111129' + }] + }], + 'groups': [], + 'attributes': [{ + 'key': 'test_attribute', + 'id': '111094' + }], + 'audiences': [{ + 'name': 'Test attribute users', + 'conditions': '["and", ["or", ["or", ' + '{"name": "test_attribute", "type": "custom_attribute", "value": "test_value"}]]]', + 'id': '11154' + }], + 'layers': [{ + 'id': '211111', + 'policy': 'ordered', + 'experiments': [{ + 'key': 'test_rollout_exp_1', + 'status': 'Running', + 'forcedVariations': {}, + 'layerId': '211111', + 'audienceIds': ['11154'], + 'trafficAllocation': [{ + 'entityId': '211128', + 'endOfRange': 5000 + }, { + 'entityId': '211129', + 'endOfRange': 9000 + }], + 'id': '211127', + 'variations': [{ + 'key': 'control', + 'id': '211128' + }, { + 'key': 'variation', + 'id': '211129' + }] + }] + }], + 'features': [{ + 'id': '91111', + 'key': 'test_feature_1', + 'experimentIds': ['111127'], + 'layerId': '', + 'variables': [{ + 'id': '127', + 'key': 'is_working', + 'defaultValue': 'true', + 'type': 'boolean', + }, { + 'id': '128', + 'key': 'environment', + 'defaultValue': 'devel', + 'type': 'string', + }] + }, { + 'id': '91112', + 'key': 'test_feature_2', + 'experimentIds': [], + 'layerId': '211111', + 'variables': [], + }] + } + + self.optimizely = optimizely.Optimizely(json.dumps(self.config_dict)) + self.config = project_config.ProjectConfig(json.dumps(self.config_dict), + logger.SimpleLogger(), error_handler.NoOpErrorHandler()) + self.optimizely.event_builder = event_builder.EventBuilderV3(self.config) + self.project_config = self.optimizely.config diff --git a/tests/benchmarking/benchmarking_tests.py b/tests/benchmarking/benchmarking_tests.py index c98ba60e..16817a25 100644 --- a/tests/benchmarking/benchmarking_tests.py +++ b/tests/benchmarking/benchmarking_tests.py @@ -169,7 +169,7 @@ def compute_average(values): Returns: Average of all values. """ - return float(sum(values))/len(values) + return float(sum(values)) / len(values) def compute_median(values): @@ -185,7 +185,7 @@ def compute_median(values): sorted_values = sorted(values) num1 = (len(values) - 1) / 2 num2 = len(values) / 2 - return float(sorted_values[num1] + sorted_values[num2])/2 + return float(sorted_values[num1] + sorted_values[num2]) / 2 def display_results(results_average, results_median): diff --git a/tests/helpers_tests/test_event_tag_utils.py b/tests/helpers_tests/test_event_tag_utils.py index 1b99568d..903cce4f 100644 --- a/tests/helpers_tests/test_event_tag_utils.py +++ b/tests/helpers_tests/test_event_tag_utils.py @@ -11,10 +11,13 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys import unittest +from optimizely import logger from optimizely.helpers import event_tag_utils + class EventTagUtilsTest(unittest.TestCase): def test_get_revenue_value__invalid_args(self): @@ -48,3 +51,81 @@ def test_get_revenue_value__revenue_tag(self): self.assertEqual(0, event_tag_utils.get_revenue_value({'revenue': 0})) self.assertEqual(65536, event_tag_utils.get_revenue_value({'revenue': 65536})) self.assertEqual(9223372036854775807, event_tag_utils.get_revenue_value({'revenue': 9223372036854775807})) + + def test_get_numeric_metric__invalid_args(self): + """ Test that numeric value is not returned for invalid arguments. """ + self.assertIsNone(event_tag_utils.get_numeric_value(None)) + self.assertIsNone(event_tag_utils.get_numeric_value(0.5)) + self.assertIsNone(event_tag_utils.get_numeric_value(65536)) + self.assertIsNone(event_tag_utils.get_numeric_value(9223372036854775807)) + self.assertIsNone(event_tag_utils.get_numeric_value('9223372036854775807')) + self.assertIsNone(event_tag_utils.get_numeric_value(True)) + self.assertIsNone(event_tag_utils.get_numeric_value(False)) + + def test_get_numeric_metric__no_value_tag(self): + """ Test that numeric value is not returned when there's no numeric event tag. """ + self.assertIsNone(event_tag_utils.get_numeric_value([])) + self.assertIsNone(event_tag_utils.get_numeric_value({})) + self.assertIsNone(event_tag_utils.get_numeric_value({'non-value': 42})) + + def test_get_numeric_metric__invalid_value_tag(self): + """ Test that numeric value is not returned when revenue event tag has invalid data type. """ + self.assertIsNone(event_tag_utils.get_numeric_value({'non-value': None})) + self.assertIsNone(event_tag_utils.get_numeric_value({'non-value': 0.5})) + self.assertIsNone(event_tag_utils.get_numeric_value({'non-value': 12345})) + self.assertIsNone(event_tag_utils.get_numeric_value({'non-value': '65536'})) + self.assertIsNone(event_tag_utils.get_numeric_value({'non-value': True})) + self.assertIsNone(event_tag_utils.get_numeric_value({'non-value': False})) + self.assertIsNone(event_tag_utils.get_numeric_value({'non-value': [1, 2, 3]})) + self.assertIsNone(event_tag_utils.get_numeric_value({'non-value': {'a', 'b', 'c'}})) + + def test_get_numeric_metric__value_tag(self): + """ Test that the correct numeric value is returned. """ + + # An integer should be cast to a float + self.assertEqual(12345.0, event_tag_utils.get_numeric_value({'value': 12345}, logger=logger.SimpleLogger())) + + # A string should be cast to a float + self.assertEqual(12345.0, event_tag_utils.get_numeric_value({'value': '12345'}, logger=logger.SimpleLogger())) + + # Valid float values + some_float = 1.2345 + self.assertEqual(some_float, event_tag_utils.get_numeric_value({'value': some_float}, logger=logger.SimpleLogger())) + + max_float = sys.float_info.max + self.assertEqual(max_float, event_tag_utils.get_numeric_value({'value': max_float}, logger=logger.SimpleLogger())) + + min_float = sys.float_info.min + self.assertEqual(min_float, event_tag_utils.get_numeric_value({'value': min_float}, logger=logger.SimpleLogger())) + + # Invalid values + self.assertIsNone(event_tag_utils.get_numeric_value({'value': False}, logger=logger.SimpleLogger())) + self.assertIsNone(event_tag_utils.get_numeric_value({'value': None}, logger=logger.SimpleLogger())) + + numeric_value_nan = event_tag_utils.get_numeric_value({'value': float('nan')}, logger=logger.SimpleLogger()) + self.assertIsNone(numeric_value_nan, 'nan numeric value is {}'.format(numeric_value_nan)) + + numeric_value_array = event_tag_utils.get_numeric_value({'value': []}, logger=logger.SimpleLogger()) + self.assertIsNone(numeric_value_array, 'Array numeric value is {}'.format(numeric_value_array)) + + numeric_value_dict = event_tag_utils.get_numeric_value({'value': []}, logger=logger.SimpleLogger()) + self.assertIsNone(numeric_value_dict, 'Dict numeric value is {}'.format(numeric_value_dict)) + + numeric_value_none = event_tag_utils.get_numeric_value({'value': None}, logger=logger.SimpleLogger()) + self.assertIsNone(numeric_value_none, 'None numeric value is {}'.format(numeric_value_none)) + + numeric_value_invalid_literal = event_tag_utils.get_numeric_value({'value': '1,234'}, logger=logger.SimpleLogger()) + self.assertIsNone(numeric_value_invalid_literal, 'Invalid string literal value is {}' + .format(numeric_value_invalid_literal)) + + numeric_value_overflow = event_tag_utils.get_numeric_value({'value': sys.float_info.max * 10}, + logger=logger.SimpleLogger()) + self.assertIsNone(numeric_value_overflow, 'Max numeric value is {}'.format(numeric_value_overflow)) + + numeric_value_inf = event_tag_utils.get_numeric_value({'value': float('inf')}, logger=logger.SimpleLogger()) + self.assertIsNone(numeric_value_inf, 'Infinity numeric value is {}'.format(numeric_value_inf)) + + numeric_value_neg_inf = event_tag_utils.get_numeric_value({'value': float('-inf')}, logger=logger.SimpleLogger()) + self.assertIsNone(numeric_value_neg_inf, 'Negative infinity numeric value is {}'.format(numeric_value_neg_inf)) + + self.assertEqual(0.0, event_tag_utils.get_numeric_value({'value': 0.0}, logger=logger.SimpleLogger())) diff --git a/tests/test_config.py b/tests/test_config.py index 5b549d2e..87cd4cf3 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -453,12 +453,75 @@ def test_get_group__valid_id(self): self.config_dict['groups'][0]['trafficAllocation']), self.project_config.get_group('19228')) - def test_get_group__invalid_id(self): """ Test that None is returned when provided group ID is invalid. """ self.assertIsNone(self.project_config.get_group('42')) + # get_forced_variation tests + def test_get_forced_variation__invalid_user_id(self): + """ Test invalid user IDs return a null variation. """ + self.project_config.forced_variation_map['test_user'] = {} + self.project_config.forced_variation_map['test_user']['test_experiment'] = 'test_variation' + + self.assertIsNone(self.project_config.get_forced_variation('test_experiment', None)) + self.assertIsNone(self.project_config.get_forced_variation('test_experiment', '')) + + def test_get_forced_variation__invalid_experiment_key(self): + """ Test invalid experiment keys return a null variation. """ + self.project_config.forced_variation_map['test_user'] = {} + self.project_config.forced_variation_map['test_user']['test_experiment'] = 'test_variation' + + self.assertIsNone(self.project_config.get_forced_variation('test_experiment_not_in_datafile', 'test_user')) + self.assertIsNone(self.project_config.get_forced_variation(None, 'test_user')) + self.assertIsNone(self.project_config.get_forced_variation('', 'test_user')) + + # set_forced_variation tests + def test_set_forced_variation__invalid_user_id(self): + """ Test invalid user IDs set fail to set a forced variation """ + + self.assertFalse(self.project_config.set_forced_variation('test_experiment', None, 'variation')) + self.assertFalse(self.project_config.set_forced_variation('test_experiment', '', 'variation')) + + def test_set_forced_variation__invalid_experiment_key(self): + """ Test invalid experiment keys set fail to set a forced variation """ + + self.assertFalse(self.project_config.set_forced_variation('test_experiment_not_in_datafile', + 'test_user', 'variation')) + self.assertFalse(self.project_config.set_forced_variation('', 'test_user', 'variation')) + self.assertFalse(self.project_config.set_forced_variation(None, 'test_user', 'variation')) + + def test_set_forced_variation__invalid_variation_key(self): + """ Test invalid variation keys set fail to set a forced variation """ + + self.assertFalse(self.project_config.set_forced_variation('test_experiment', 'test_user', + 'variation_not_in_datafile')) + self.assertTrue(self.project_config.set_forced_variation('test_experiment', 'test_user', '')) + self.assertTrue(self.project_config.set_forced_variation('test_experiment', 'test_user', None)) + + def test_set_forced_variation__multiple_sets(self): + """ Test multiple sets of experiments for one and multiple users work """ + + self.assertTrue(self.project_config.set_forced_variation('test_experiment', 'test_user_1', 'variation')) + self.assertEqual(self.project_config.get_forced_variation('test_experiment', 'test_user_1').key, 'variation') + # same user, same experiment, different variation + self.assertTrue(self.project_config.set_forced_variation('test_experiment', 'test_user_1', 'control')) + self.assertEqual(self.project_config.get_forced_variation('test_experiment', 'test_user_1').key, 'control') + # same user, different experiment + self.assertTrue(self.project_config.set_forced_variation('group_exp_1', 'test_user_1', 'group_exp_1_control')) + self.assertEqual(self.project_config.get_forced_variation('group_exp_1', 'test_user_1').key, 'group_exp_1_control') + + # different user + self.assertTrue(self.project_config.set_forced_variation('test_experiment', 'test_user_2', 'variation')) + self.assertEqual(self.project_config.get_forced_variation('test_experiment', 'test_user_2').key, 'variation') + # different user, different experiment + self.assertTrue(self.project_config.set_forced_variation('group_exp_1', 'test_user_2', 'group_exp_1_control')) + self.assertEqual(self.project_config.get_forced_variation('group_exp_1', 'test_user_2').key, 'group_exp_1_control') + + # make sure the first user forced variations are still valid + self.assertEqual(self.project_config.get_forced_variation('test_experiment', 'test_user_1').key, 'control') + self.assertEqual(self.project_config.get_forced_variation('group_exp_1', 'test_user_1').key, 'group_exp_1_control') + class ConfigLoggingTest(base.BaseTest): def setUp(self): diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py index dfa67755..f8c72038 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -2,7 +2,7 @@ # 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 diff --git a/tests/test_event_builder.py b/tests/test_event_builder.py index 724f8fc0..b5ede37c 100644 --- a/tests/test_event_builder.py +++ b/tests/test_event_builder.py @@ -58,28 +58,36 @@ def test_create_impression_event(self): """ Test that create_impression_event creates Event object with right params. """ expected_params = { - 'accountId': '12001', - 'projectId': '111001', - 'layerId': '111182', - 'visitorId': 'test_user', - 'decision': { - 'experimentId': '111127', - 'variationId': '111129', - 'isLayerHoldback': False - }, - 'revision': '42', - 'timestamp': 42123, - 'isGlobalHoldback': False, - 'userFeatures': [], - 'clientEngine': 'python-sdk', - 'clientVersion': version.__version__ + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [], + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111129', + 'experiment_id': '111127', + 'campaign_id': '111182' + }], + 'events': [{ + 'timestamp': 42123, + 'entity_id': '111182', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'campaign_activated' + }] + }] + }], + 'client_name': 'python-sdk', + 'client_version': version.__version__ } - with mock.patch('time.time', return_value=42.123): + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'): event_obj = self.event_builder.create_impression_event( self.project_config.get_experiment_from_key('test_experiment'), '111129', 'test_user', None ) self._validate_event_object(event_obj, - event_builder.EventBuilder.IMPRESSION_ENDPOINT, + event_builder.EventBuilder.EVENTS_URL, expected_params, event_builder.EventBuilder.HTTP_VERB, event_builder.EventBuilder.HTTP_HEADERS) @@ -89,35 +97,42 @@ def test_create_impression_event__with_attributes(self): with right params when attributes are provided. """ expected_params = { - 'accountId': '12001', - 'projectId': '111001', - 'layerId': '111182', - 'visitorId': 'test_user', - 'revision': '42', - 'decision': { - 'experimentId': '111127', - 'variationId': '111129', - 'isLayerHoldback': False - }, - 'timestamp': 42123, - 'isGlobalHoldback': False, - 'userFeatures': [{ - 'id': '111094', - 'name': 'test_attribute', - 'type': 'custom', - 'value': 'test_value', - 'shouldIndex': True + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [{ + 'type': 'custom', + 'value': 'test_value', + 'entity_id': '111094', + 'key': 'test_attribute' + }], + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111129', + 'experiment_id': '111127', + 'campaign_id': '111182' + }], + 'events': [{ + 'timestamp': 42123, + 'entity_id': '111182', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'campaign_activated' + }] + }] }], - 'clientEngine': 'python-sdk', - 'clientVersion': version.__version__ + 'client_name': 'python-sdk', + 'client_version': version.__version__ } - with mock.patch('time.time', return_value=42.123): + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'): event_obj = self.event_builder.create_impression_event( self.project_config.get_experiment_from_key('test_experiment'), '111129', 'test_user', {'test_attribute': 'test_value'} ) self._validate_event_object(event_obj, - event_builder.EventBuilder.IMPRESSION_ENDPOINT, + event_builder.EventBuilder.EVENTS_URL, expected_params, event_builder.EventBuilder.HTTP_VERB, event_builder.EventBuilder.HTTP_HEADERS) @@ -127,196 +142,152 @@ def test_create_conversion_event__with_attributes(self): with right params when attributes are provided. """ expected_params = { - 'accountId': '12001', - 'projectId': '111001', - 'visitorId': 'test_user', - 'eventName': 'test_event', - 'eventEntityId': '111095', - 'eventMetrics': [], - 'eventFeatures': [], - 'revision': '42', - 'layerStates': [{ - 'layerId': '111182', - 'revision': '42', - 'decision': { - 'experimentId': '111127', - 'variationId': '111129', - 'isLayerHoldback': False - }, - 'actionTriggered': True, - } - ], - 'timestamp': 42123, - 'isGlobalHoldback': False, - 'userFeatures': [{ - 'id': '111094', - 'name': 'test_attribute', - 'type': 'custom', - 'value': 'test_value', - 'shouldIndex': True + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [{ + 'type': 'custom', + 'value': 'test_value', + 'entity_id': '111094', + 'key': 'test_attribute' + }], + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111129', + 'experiment_id': '111127', + 'campaign_id': '111182' + }], + 'events': [{ + 'timestamp': 42123, + 'entity_id': '111095', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'test_event' + }] + }] }], - 'clientEngine': 'python-sdk', - 'clientVersion': version.__version__ + 'client_name': 'python-sdk', + 'client_version': version.__version__ } + with mock.patch('time.time', return_value=42.123), \ - mock.patch('optimizely.bucketer.Bucketer._generate_bucket_value', return_value=5042): + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'), \ + mock.patch('optimizely.bucketer.Bucketer._generate_bucket_value', return_value=5042): event_obj = self.event_builder.create_conversion_event( - 'test_event', 'test_user', {'test_attribute': 'test_value'}, None, - [('111127', '111129')] + 'test_event', 'test_user', {'test_attribute': 'test_value'}, None, [('111127', '111129')] ) self._validate_event_object(event_obj, - event_builder.EventBuilder.CONVERSION_ENDPOINT, + event_builder.EventBuilder.EVENTS_URL, expected_params, event_builder.EventBuilder.HTTP_VERB, event_builder.EventBuilder.HTTP_HEADERS) - def test_create_conversion_event__with_attributes_no_match(self): - """ Test that create_conversion_event creates Event object with right params if attributes do not match. """ - - expected_params = { - 'accountId': '12001', - 'projectId': '111001', - 'visitorId': 'test_user', - 'revision': '42', - 'eventName': 'test_event', - 'eventEntityId': '111095', - 'eventMetrics': [], - 'eventFeatures': [], - 'layerStates': [], - 'timestamp': 42123, - 'isGlobalHoldback': False, - 'userFeatures': [], - 'clientEngine': 'python-sdk', - 'clientVersion': version.__version__ - } - with mock.patch('time.time', return_value=42.123): - event_obj = self.event_builder.create_conversion_event('test_event', 'test_user', None, None, []) - self._validate_event_object(event_obj, - event_builder.EventBuilder.CONVERSION_ENDPOINT, - expected_params, - event_builder.EventBuilder.HTTP_VERB, - event_builder.EventBuilder.HTTP_HEADERS) - - def test_create_conversion_event__with_event_value(self): + def test_create_conversion_event__with_event_tags(self): """ Test that create_conversion_event creates Event object - with right params when event value is provided. """ + with right params when event tags are provided. """ expected_params = { - 'accountId': '12001', - 'projectId': '111001', - 'visitorId': 'test_user', - 'eventName': 'test_event', - 'eventEntityId': '111095', - 'eventMetrics': [{ - 'name': 'revenue', - 'value': 4200 - }], - 'eventFeatures': [{ - 'name': 'non-revenue', - 'type': 'custom', - 'value': 'abc', - 'shouldIndex': False, - }, { - 'name': 'revenue', + 'client_version': version.__version__, + 'project_id': '111001', + 'visitors': [{ + 'attributes': [{ + 'entity_id': '111094', 'type': 'custom', - 'value': 4200, - 'shouldIndex': False, - }], - 'layerStates': [{ - 'layerId': '111182', - 'revision': '42', - 'decision': { - 'experimentId': '111127', - 'variationId': '111129', - 'isLayerHoldback': False - }, - 'actionTriggered': True, - } - ], - 'timestamp': 42123, - 'revision': '42', - 'isGlobalHoldback': False, - 'userFeatures': [{ - 'id': '111094', - 'name': 'test_attribute', - 'type': 'custom', - 'value': 'test_value', - 'shouldIndex': True + 'value': 'test_value', + 'key': 'test_attribute' + }], + 'visitor_id': 'test_user', + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111129', + 'experiment_id': '111127', + 'campaign_id': '111182' + }], + 'events': [{ + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'tags': { + 'non-revenue': 'abc', + 'revenue': 4200, + 'value': 1.234 + }, + 'timestamp': 42123, + 'revenue': 4200, + 'value': 1.234, + 'key': 'test_event', + 'entity_id': '111095' + }] + }] }], - 'clientEngine': 'python-sdk', - 'clientVersion': version.__version__ + 'account_id': '12001', + 'client_name': 'python-sdk', } + with mock.patch('time.time', return_value=42.123), \ - mock.patch('optimizely.bucketer.Bucketer._generate_bucket_value', return_value=5042): + mock.patch('optimizely.bucketer.Bucketer._generate_bucket_value', return_value=5042), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'): event_obj = self.event_builder.create_conversion_event( - 'test_event', 'test_user', {'test_attribute': 'test_value'}, {'revenue': 4200, 'non-revenue': 'abc'}, + 'test_event', + 'test_user', + {'test_attribute': 'test_value'}, + {'revenue': 4200, 'value': 1.234, 'non-revenue': 'abc'}, [('111127', '111129')] ) - - # Sort event features based on ID - event_obj.params['eventFeatures'] = sorted(event_obj.params['eventFeatures'], key=lambda x: x.get('name')) self._validate_event_object(event_obj, - event_builder.EventBuilder.CONVERSION_ENDPOINT, + event_builder.EventBuilder.EVENTS_URL, expected_params, event_builder.EventBuilder.HTTP_VERB, event_builder.EventBuilder.HTTP_HEADERS) - def test_create_conversion_event__with_invalid_event_value(self): + def test_create_conversion_event__with_invalid_event_tags(self): """ Test that create_conversion_event creates Event object - with right params when event value is provided. """ + with right params when event tags are provided. """ expected_params = { - 'accountId': '12001', - 'projectId': '111001', - 'visitorId': 'test_user', - 'eventName': 'test_event', - 'eventEntityId': '111095', - 'revision': '42', - 'eventMetrics': [], - 'eventFeatures': [{ - 'name': 'non-revenue', + 'client_version': version.__version__, + 'project_id': '111001', + 'visitors': [{ + 'attributes': [{ + 'entity_id': '111094', 'type': 'custom', - 'value': 'abc', - 'shouldIndex': False, - }, { - 'name': 'revenue', - 'type': 'custom', - 'value': '4200', - 'shouldIndex': False, - }], - 'layerStates': [{ - 'layerId': '111182', - 'revision': '42', - 'decision': { - 'experimentId': '111127', - 'variationId': '111129', - 'isLayerHoldback': False - }, - 'actionTriggered': True, - } - ], - 'timestamp': 42123, - 'isGlobalHoldback': False, - 'userFeatures': [{ - 'id': '111094', - 'name': 'test_attribute', - 'type': 'custom', - 'value': 'test_value', - 'shouldIndex': True + 'value': 'test_value', + 'key': 'test_attribute' + }], + 'visitor_id': 'test_user', + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111129', + 'experiment_id': '111127', + 'campaign_id': '111182' + }], + 'events': [{ + 'timestamp': 42123, + 'entity_id': '111095', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'test_event', + 'tags': { + 'non-revenue': 'abc', + 'revenue': '4200', + 'value': True + } + }] + }] }], - 'clientEngine': 'python-sdk', - 'clientVersion': version.__version__ + 'account_id': '12001', + 'client_name': 'python-sdk', } + with mock.patch('time.time', return_value=42.123), \ - mock.patch('optimizely.bucketer.Bucketer._generate_bucket_value', return_value=5042): + mock.patch('optimizely.bucketer.Bucketer._generate_bucket_value', return_value=5042), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'): event_obj = self.event_builder.create_conversion_event( - 'test_event', 'test_user', {'test_attribute': 'test_value'}, {'revenue': '4200', 'non-revenue': 'abc'}, + 'test_event', + 'test_user', + {'test_attribute': 'test_value'}, + {'revenue': '4200', 'value': True, 'non-revenue': 'abc'}, [('111127', '111129')] ) - # Sort event features based on ID - event_obj.params['eventFeatures'] = sorted(event_obj.params['eventFeatures'], key=lambda x: x.get('name')) self._validate_event_object(event_obj, - event_builder.EventBuilder.CONVERSION_ENDPOINT, + event_builder.EventBuilder.EVENTS_URL, expected_params, event_builder.EventBuilder.HTTP_VERB, event_builder.EventBuilder.HTTP_HEADERS) diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index 08af8241..7e306793 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -14,11 +14,13 @@ import json import mock +from optimizely import entities from optimizely import error_handler from optimizely import exceptions from optimizely import logger from optimizely import optimizely from optimizely import project_config +from optimizely import user_profile from optimizely import version from optimizely.helpers import enums from . import base @@ -34,6 +36,17 @@ def _validate_event_object(self, event_obj, expected_url, expected_params, expec self.assertEqual(expected_verb, event_obj.http_verb) self.assertEqual(expected_headers, event_obj.headers) + def _validate_event_object_event_tags(self, event_obj, expected_event_metric_params, expected_event_features_params): + """ Helper method to validate properties of the event object related to event tags. """ + + # get event metrics from the created event object + event_metrics = event_obj.params['visitors'][0]['snapshots'][0]['events'][0]['tags'] + self.assertEqual(expected_event_metric_params, event_metrics) + + # get event features from the created event object + event_features = event_obj.params['visitors'][0]['attributes'][0] + self.assertEqual(expected_event_features_params, event_features) + def test_init__invalid_datafile__logs_error(self): """ Test that invalid datafile logs error on init. """ @@ -120,34 +133,41 @@ def test_activate(self): """ Test that activate calls dispatch_event with right params and returns expected variation. """ with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129')) as mock_decision,\ - mock.patch('time.time', return_value=42),\ - mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + 'optimizely.decision_service.DecisionService.get_variation', + return_value=self.project_config.get_variation_from_id('test_experiment', '111129')) as mock_decision, \ + mock.patch('time.time', return_value=42), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'), \ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: self.assertEqual('variation', self.optimizely.activate('test_experiment', 'test_user')) expected_params = { - 'visitorId': 'test_user', - 'accountId': '12001', - 'projectId': '111001', - 'layerId': '111182', - 'revision': '42', - 'decision': { - 'variationId': '111129', - 'isLayerHoldback': False, - 'experimentId': '111127' - }, - 'userFeatures': [], - 'isGlobalHoldback': False, - 'timestamp': 42000, - 'clientVersion': version.__version__, - 'clientEngine': 'python-sdk' + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [], + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111129', + 'experiment_id': '111127', + 'campaign_id': '111182' + }], + 'events': [{ + 'timestamp': 42000, + 'entity_id': '111182', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'campaign_activated', + }] + }] + }], + 'client_version': version.__version__, + 'client_name': 'python-sdk' } mock_decision.assert_called_once_with( self.project_config.get_experiment_from_key('test_experiment'), 'test_user', None ) self.assertEqual(1, mock_dispatch_event.call_count) - self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/log/decision', + self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/v1/events', expected_params, 'POST', {'Content-Type': 'application/json'}) def test_activate__with_attributes__audience_match(self): @@ -155,40 +175,92 @@ def test_activate__with_attributes__audience_match(self): variation when attributes are provided and audience conditions are met. """ with mock.patch( - 'optimizely.decision_service.DecisionService.get_variation', - return_value=self.project_config.get_variation_from_id('test_experiment', '111129')) as mock_get_variation,\ - mock.patch('time.time', return_value=42),\ - mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + 'optimizely.decision_service.DecisionService.get_variation', + return_value=self.project_config.get_variation_from_id('test_experiment', '111129')) \ + as mock_get_variation, \ + mock.patch('time.time', return_value=42), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'), \ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: self.assertEqual('variation', self.optimizely.activate('test_experiment', 'test_user', {'test_attribute': 'test_value'})) - expected_params = { - 'visitorId': 'test_user', - 'accountId': '12001', - 'projectId': '111001', - 'layerId': '111182', - 'revision': '42', - 'decision': { - 'variationId': '111129', - 'isLayerHoldback': False, - 'experimentId': '111127' - }, - 'userFeatures': [{ - 'shouldIndex': True, - 'type': 'custom', - 'id': '111094', - 'value': 'test_value', - 'name': 'test_attribute' + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [{ + 'type': 'custom', + 'value': 'test_value', + 'entity_id': '111094', + 'key': 'test_attribute' + }], + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111129', + 'experiment_id': '111127', + 'campaign_id': '111182' + }], + 'events': [{ + 'timestamp': 42000, + 'entity_id': '111182', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'campaign_activated', + }] + }] }], - 'isGlobalHoldback': False, - 'timestamp': 42000, - 'clientVersion': version.__version__, - 'clientEngine': 'python-sdk' + 'client_version': version.__version__, + 'client_name': 'python-sdk' } mock_get_variation.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment'), - 'test_user', {'test_attribute': 'test_value'}) + 'test_user', {'test_attribute': 'test_value'}) + self.assertEqual(1, mock_dispatch_event.call_count) + self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/v1/events', + expected_params, 'POST', {'Content-Type': 'application/json'}) + + def test_activate__with_attributes__audience_match__forced_bucketing(self): + """ Test that activate calls dispatch_event with right params and returns expected + variation when attributes are provided and audience conditions are met after a + set_forced_variation is called. """ + + with mock.patch('time.time', return_value=42), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'), \ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + + self.assertTrue(self.optimizely.set_forced_variation('test_experiment', 'test_user', 'control')) + self.assertEqual('control', self.optimizely.activate('test_experiment', 'test_user', + {'test_attribute': 'test_value'})) + + expected_params = { + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [{ + 'type': 'custom', + 'value': 'test_value', + 'entity_id': '111094', + 'key': 'test_attribute' + }], + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111128', + 'experiment_id': '111127', + 'campaign_id': '111182' + }], + 'events': [{ + 'timestamp': 42000, + 'entity_id': '111182', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'campaign_activated', + }] + }] + }], + 'client_version': version.__version__, + 'client_name': 'python-sdk' + } + self.assertEqual(1, mock_dispatch_event.call_count) - self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/log/decision', + self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/v1/events', expected_params, 'POST', {'Content-Type': 'application/json'}) def test_activate__with_attributes__no_audience_match(self): @@ -204,8 +276,8 @@ def test_activate__with_attributes__no_audience_match(self): def test_activate__with_attributes__invalid_attributes(self): """ Test that activate returns None and does not bucket or dispatch event when attributes are invalid. """ - with mock.patch('optimizely.bucketer.Bucketer.bucket') as mock_bucket,\ - mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + with mock.patch('optimizely.bucketer.Bucketer.bucket') as mock_bucket, \ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: self.assertIsNone(self.optimizely.activate('test_experiment', 'test_user', attributes='invalid')) self.assertEqual(0, mock_bucket.call_count) @@ -214,11 +286,11 @@ def test_activate__with_attributes__invalid_attributes(self): def test_activate__experiment_not_running(self): """ Test that activate returns None and does not dispatch event when experiment is not Running. """ - with mock.patch('optimizely.helpers.audience.is_user_in_experiment', return_value=True) as mock_audience_check,\ - mock.patch('optimizely.helpers.experiment.is_experiment_running', - return_value=False) as mock_is_experiment_running, \ - mock.patch('optimizely.bucketer.Bucketer.bucket') as mock_bucket,\ - mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + with mock.patch('optimizely.helpers.audience.is_user_in_experiment', return_value=True) as mock_audience_check, \ + mock.patch('optimizely.helpers.experiment.is_experiment_running', + return_value=False) as mock_is_experiment_running, \ + mock.patch('optimizely.bucketer.Bucketer.bucket') as mock_bucket, \ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: self.assertIsNone(self.optimizely.activate('test_experiment', 'test_user', attributes={'test_attribute': 'test_value'})) @@ -230,9 +302,9 @@ def test_activate__experiment_not_running(self): def test_activate__whitelisting_overrides_audience_check(self): """ Test that during activate whitelist overrides audience check if user is in the whitelist. """ - with mock.patch('optimizely.helpers.audience.is_user_in_experiment', return_value=False) as mock_audience_check,\ - mock.patch('optimizely.helpers.experiment.is_experiment_running', - return_value=True) as mock_is_experiment_running: + with mock.patch('optimizely.helpers.audience.is_user_in_experiment', return_value=False) as mock_audience_check, \ + mock.patch('optimizely.helpers.experiment.is_experiment_running', + return_value=True) as mock_is_experiment_running: self.assertEqual('control', self.optimizely.activate('test_experiment', 'user_1')) mock_is_experiment_running.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment')) self.assertEqual(0, mock_audience_check.call_count) @@ -240,9 +312,9 @@ def test_activate__whitelisting_overrides_audience_check(self): def test_activate__bucketer_returns_none(self): """ Test that activate returns None and does not dispatch event when user is in no variation. """ - with mock.patch('optimizely.helpers.audience.is_user_in_experiment', return_value=True),\ - mock.patch('optimizely.bucketer.Bucketer.bucket', return_value=None) as mock_bucket,\ - mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + with mock.patch('optimizely.helpers.audience.is_user_in_experiment', return_value=True), \ + mock.patch('optimizely.bucketer.Bucketer.bucket', return_value=None) as mock_bucket, \ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: self.assertIsNone(self.optimizely.activate('test_experiment', 'test_user', attributes={'test_attribute': 'test_value'})) mock_bucket.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment'), 'test_user') @@ -264,46 +336,44 @@ def test_track__with_attributes(self): with mock.patch('optimizely.decision_service.DecisionService.get_variation', return_value=self.project_config.get_variation_from_id( 'test_experiment', '111128' - )) as mock_get_variation,\ - mock.patch('time.time', return_value=42),\ - mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + )) as mock_get_variation, \ + mock.patch('time.time', return_value=42), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'), \ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: self.optimizely.track('test_event', 'test_user', attributes={'test_attribute': 'test_value'}) expected_params = { - 'visitorId': 'test_user', - 'clientVersion': version.__version__, - 'clientEngine': 'python-sdk', - 'userFeatures': [{ - 'shouldIndex': True, - 'type': 'custom', - 'id': '111094', - 'value': 'test_value', - 'name': 'test_attribute' - }], - 'projectId': '111001', - 'isGlobalHoldback': False, - 'eventEntityId': '111095', - 'eventName': 'test_event', - 'eventFeatures': [], - 'eventMetrics': [], - 'timestamp': 42000, - 'revision': '42', - 'layerStates': [{ - 'revision': '42', - 'decision': { - 'variationId': '111128', - 'isLayerHoldback': False, - 'experimentId': '111127' - }, - 'actionTriggered': True, - 'layerId': '111182' + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [{ + 'type': 'custom', + 'value': 'test_value', + 'entity_id': '111094', + 'key': 'test_attribute' + }], + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111128', + 'experiment_id': '111127', + 'campaign_id': '111182' + }], + 'events': [{ + 'timestamp': 42000, + 'entity_id': '111095', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'test_event', + }] + }] }], - 'accountId': '12001' + 'client_version': version.__version__, + 'client_name': 'python-sdk' } mock_get_variation.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment'), 'test_user', {'test_attribute': 'test_value'}) self.assertEqual(1, mock_dispatch_event.call_count) - self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/log/event', + self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/v1/events', expected_params, 'POST', {'Content-Type': 'application/json'}) def test_track__with_attributes__no_audience_match(self): @@ -312,9 +382,9 @@ def test_track__with_attributes__no_audience_match(self): with mock.patch('optimizely.bucketer.Bucketer.bucket', return_value=self.project_config.get_variation_from_id( 'test_experiment', '111128' - )) as mock_bucket,\ - mock.patch('time.time', return_value=42),\ - mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + )) as mock_bucket, \ + mock.patch('time.time', return_value=42), \ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: self.optimizely.track('test_event', 'test_user', attributes={'test_attribute': 'wrong_test_value'}) self.assertEqual(0, mock_bucket.call_count) @@ -323,78 +393,202 @@ def test_track__with_attributes__no_audience_match(self): def test_track__with_attributes__invalid_attributes(self): """ Test that track does not bucket or dispatch event if attributes are invalid. """ - with mock.patch('optimizely.bucketer.Bucketer.bucket') as mock_bucket,\ - mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + with mock.patch('optimizely.bucketer.Bucketer.bucket') as mock_bucket, \ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: self.optimizely.track('test_event', 'test_user', attributes='invalid') self.assertEqual(0, mock_bucket.call_count) self.assertEqual(0, mock_dispatch_event.call_count) - def test_track__with_event_value(self): - """ Test that track calls dispatch_event with right params when event_value information is provided. """ + def test_track__with_event_tags(self): + """ Test that track calls dispatch_event with right params when event tags are provided. """ with mock.patch('optimizely.decision_service.DecisionService.get_variation', return_value=self.project_config.get_variation_from_id( 'test_experiment', '111128' - )) as mock_get_variation,\ - mock.patch('time.time', return_value=42),\ - mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + )) as mock_get_variation, \ + mock.patch('time.time', return_value=42), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'), \ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: self.optimizely.track('test_event', 'test_user', attributes={'test_attribute': 'test_value'}, - event_tags={'revenue': 4200, 'non-revenue': 'abc'}) + event_tags={'revenue': 4200, 'value': 1.234, 'non-revenue': 'abc'}) expected_params = { - 'visitorId': 'test_user', - 'clientVersion': version.__version__, - 'clientEngine': 'python-sdk', - 'revision': '42', - 'userFeatures': [{ - 'shouldIndex': True, - 'type': 'custom', - 'id': '111094', - 'value': 'test_value', - 'name': 'test_attribute' - }], - 'projectId': '111001', - 'isGlobalHoldback': False, - 'eventEntityId': '111095', - 'eventName': 'test_event', - 'eventFeatures': [{ - 'name': 'non-revenue', + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [{ 'type': 'custom', - 'value': 'abc', - 'shouldIndex': False, - }, { - 'name': 'revenue', - 'type': 'custom', - 'value': 4200, - 'shouldIndex': False, - }], - 'eventMetrics': [{ - 'name': 'revenue', - 'value': 4200 + 'value': 'test_value', + 'entity_id': '111094', + 'key': 'test_attribute' + }], + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111128', + 'experiment_id': '111127', + 'campaign_id': '111182' + }], + 'events': [{ + 'entity_id': '111095', + 'key': 'test_event', + 'revenue': 4200, + 'tags': { + 'non-revenue': 'abc', + 'revenue': 4200, + 'value': 1.234, + }, + 'timestamp': 42000, + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'value': 1.234, + }] + }], }], - 'timestamp': 42000, - 'layerStates': [{ - 'revision': '42', - 'decision': { - 'variationId': '111128', - 'isLayerHoldback': False, - 'experimentId': '111127' - }, - 'actionTriggered': True, - 'layerId': '111182' + 'client_version': version.__version__, + 'client_name': 'python-sdk' + } + mock_get_variation.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', {'test_attribute': 'test_value'}) + self.assertEqual(1, mock_dispatch_event.call_count) + self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/v1/events', + expected_params, 'POST', {'Content-Type': 'application/json'}) + + def test_track__with_event_tags_revenue(self): + """ Test that track calls dispatch_event with right params when only revenue + event tags are provided only. """ + + with mock.patch('optimizely.decision_service.DecisionService.get_variation', + return_value=self.project_config.get_variation_from_id( + 'test_experiment', '111128' + )) as mock_get_variation, \ + mock.patch('time.time', return_value=42), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'), \ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + self.optimizely.track('test_event', 'test_user', attributes={'test_attribute': 'test_value'}, + event_tags={'revenue': 4200, 'non-revenue': 'abc'}) + + expected_params = { + 'visitors': [{ + 'attributes': [{ + 'entity_id': '111094', + 'type': 'custom', + 'value': 'test_value', + 'key': 'test_attribute' + }], + 'visitor_id': 'test_user', + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111128', + 'experiment_id': '111127', + 'campaign_id': '111182' + }], + 'events': [{ + 'entity_id': '111095', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'tags': { + 'non-revenue': 'abc', + 'revenue': 4200 + }, + 'timestamp': 42000, + 'revenue': 4200, + 'key': 'test_event' + }] + }] }], - 'accountId': '12001' + 'client_name': 'python-sdk', + 'project_id': '111001', + 'client_version': version.__version__, + 'account_id': '12001' } mock_get_variation.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment'), 'test_user', {'test_attribute': 'test_value'}) self.assertEqual(1, mock_dispatch_event.call_count) + self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/v1/events', + expected_params, 'POST', {'Content-Type': 'application/json'}) - # Sort event features based on ID - mock_dispatch_event.call_args[0][0].params['eventFeatures'] = sorted( - mock_dispatch_event.call_args[0][0].params['eventFeatures'], key=lambda x: x.get('name') - ) - self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/log/event', + def test_track__with_event_tags_numeric_metric(self): + """ Test that track calls dispatch_event with right params when only numeric metric + event tags are provided. """ + + with mock.patch('optimizely.decision_service.DecisionService.get_variation', + return_value=self.project_config.get_variation_from_id( + 'test_experiment', '111128' + )) as mock_get_variation, \ + mock.patch('time.time', return_value=42), \ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + self.optimizely.track('test_event', 'test_user', attributes={'test_attribute': 'test_value'}, + event_tags={'value': 1.234, 'non-revenue': 'abc'}) + + expected_event_metrics_params = { + 'non-revenue': 'abc', + 'value': 1.234 + } + + expected_event_features_params = { + 'entity_id': '111094', + 'type': 'custom', + 'value': 'test_value', + 'key': 'test_attribute' + } + mock_get_variation.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment'), + 'test_user', {'test_attribute': 'test_value'}) + self.assertEqual(1, mock_dispatch_event.call_count) + self._validate_event_object_event_tags(mock_dispatch_event.call_args[0][0], + expected_event_metrics_params, + expected_event_features_params) + + def test_track__with_event_tags__forced_bucketing(self): + """ Test that track calls dispatch_event with right params when event_value information is provided + after a forced bucket. """ + + with mock.patch('time.time', return_value=42), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'), \ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + + self.assertTrue(self.optimizely.set_forced_variation('test_experiment', 'test_user', 'variation')) + self.optimizely.track('test_event', 'test_user', attributes={'test_attribute': 'test_value'}, + event_tags={'revenue': 4200, 'value': 1.234, 'non-revenue': 'abc'}) + + expected_params = { + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [{ + 'type': 'custom', + 'value': 'test_value', + 'entity_id': '111094', + 'key': 'test_attribute' + }], + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111129', + 'experiment_id': '111127', + 'campaign_id': '111182' + }], + 'events': [{ + 'entity_id': '111095', + 'key': 'test_event', + 'revenue': 4200, + 'tags': { + 'non-revenue': 'abc', + 'revenue': 4200, + 'value': 1.234 + }, + 'timestamp': 42000, + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'value': 1.234, + }] + }], + }], + 'client_version': version.__version__, + 'client_name': 'python-sdk' + } + + self.assertEqual(1, mock_dispatch_event.call_count) + + self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/v1/events', expected_params, 'POST', {'Content-Type': 'application/json'}) def test_track__with_deprecated_event_value(self): @@ -403,118 +597,108 @@ def test_track__with_deprecated_event_value(self): with mock.patch('optimizely.decision_service.DecisionService.get_variation', return_value=self.project_config.get_variation_from_id( 'test_experiment', '111128' - )) as mock_get_variation,\ - mock.patch('time.time', return_value=42),\ - mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + )) as mock_get_variation, \ + mock.patch('time.time', return_value=42), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'), \ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: self.optimizely.track('test_event', 'test_user', attributes={'test_attribute': 'test_value'}, event_tags=4200) expected_params = { - 'visitorId': 'test_user', - 'clientVersion': version.__version__, - 'clientEngine': 'python-sdk', - 'userFeatures': [{ - 'shouldIndex': True, - 'type': 'custom', - 'id': '111094', - 'value': 'test_value', - 'name': 'test_attribute' - }], - 'projectId': '111001', - 'isGlobalHoldback': False, - 'eventEntityId': '111095', - 'eventName': 'test_event', - 'eventFeatures': [{ - 'name': 'revenue', - 'type': 'custom', - 'value': 4200, - 'shouldIndex': False, - }], - 'eventMetrics': [{ - 'name': 'revenue', - 'value': 4200 - }], - 'timestamp': 42000, - 'revision': '42', - 'layerStates': [{ - 'revision': '42', - 'decision': { - 'variationId': '111128', - 'isLayerHoldback': False, - 'experimentId': '111127' - }, - 'actionTriggered': True, - 'layerId': '111182' - }], - 'accountId': '12001' - } + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [{ + 'type': 'custom', + 'value': 'test_value', + 'entity_id': '111094', + 'key': 'test_attribute' + }], + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111128', + 'experiment_id': '111127', + 'campaign_id': '111182' + }], + 'events': [{ + 'entity_id': '111095', + 'key': 'test_event', + 'revenue': 4200, + 'tags': { + 'revenue': 4200, + }, + 'timestamp': 42000, + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + }] + }], + }], + 'client_version': version.__version__, + 'client_name': 'python-sdk' + } mock_get_variation.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment'), 'test_user', {'test_attribute': 'test_value'}) self.assertEqual(1, mock_dispatch_event.call_count) - self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/log/event', + self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/v1/events', expected_params, 'POST', {'Content-Type': 'application/json'}) - def test_track__with_invalid_event_value(self): - """ Test that track calls dispatch_event with right params when event_value information is provided. """ + def test_track__with_invalid_event_tags(self): + """ Test that track calls dispatch_event with right params when invalid event tags are provided. """ with mock.patch('optimizely.decision_service.DecisionService.get_variation', return_value=self.project_config.get_variation_from_id( 'test_experiment', '111128' - )) as mock_get_variation,\ - mock.patch('time.time', return_value=42),\ - mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + )) as mock_get_variation, \ + mock.patch('time.time', return_value=42), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'), \ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: self.optimizely.track('test_event', 'test_user', attributes={'test_attribute': 'test_value'}, - event_tags={'revenue': '4200'}) + event_tags={'revenue': '4200', 'value': True}) expected_params = { - 'visitorId': 'test_user', - 'clientVersion': version.__version__, - 'clientEngine': 'python-sdk', - 'revision': '42', - 'userFeatures': [{ - 'shouldIndex': True, - 'type': 'custom', - 'id': '111094', - 'value': 'test_value', - 'name': 'test_attribute' - }], - 'projectId': '111001', - 'isGlobalHoldback': False, - 'eventEntityId': '111095', - 'eventName': 'test_event', - 'eventFeatures': [{ - 'name': 'revenue', + 'visitors': [{ + 'attributes': [{ + 'entity_id': '111094', 'type': 'custom', - 'value': '4200', - 'shouldIndex': False, - }], - 'eventMetrics': [], - 'timestamp': 42000, - 'layerStates': [{ - 'revision': '42', - 'decision': { - 'variationId': '111128', - 'isLayerHoldback': False, - 'experimentId': '111127' - }, - 'actionTriggered': True, - 'layerId': '111182' + 'value': 'test_value', + 'key': 'test_attribute' + }], + 'visitor_id': 'test_user', + 'snapshots': [{ + 'decisions': [{ + 'variation_id': '111128', + 'experiment_id': '111127', + 'campaign_id': '111182' + }], + 'events': [{ + 'timestamp': 42000, + 'entity_id': '111095', + 'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c', + 'key': 'test_event', + 'tags': { + 'value': True, + 'revenue': '4200' + } + }] + }] }], - 'accountId': '12001' + 'client_name': 'python-sdk', + 'project_id': '111001', + 'client_version': version.__version__, + 'account_id': '12001' } - mock_get_variation.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment'), 'test_user', {'test_attribute': 'test_value'}) self.assertEqual(1, mock_dispatch_event.call_count) - self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/log/event', + self._validate_event_object(mock_dispatch_event.call_args[0][0], 'https://logx.optimizely.com/v1/events', expected_params, 'POST', {'Content-Type': 'application/json'}) def test_track__experiment_not_running(self): """ Test that track does not call dispatch_event when experiment is not running. """ with mock.patch('optimizely.helpers.experiment.is_experiment_running', - return_value=False) as mock_is_experiment_running,\ - mock.patch('time.time', return_value=42),\ - mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + return_value=False) as mock_is_experiment_running, \ + mock.patch('time.time', return_value=42), \ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: self.optimizely.track('test_event', 'test_user') mock_is_experiment_running.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment')) @@ -524,11 +708,12 @@ def test_track__whitelisted_user_overrides_audience_check(self): """ Test that track does not check for user in audience when user is in whitelist. """ with mock.patch('optimizely.helpers.experiment.is_experiment_running', - return_value=True) as mock_is_experiment_running,\ - mock.patch('optimizely.helpers.audience.is_user_in_experiment', - return_value=False) as mock_audience_check,\ - mock.patch('time.time', return_value=42),\ - mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: + return_value=True) as mock_is_experiment_running, \ + mock.patch('optimizely.helpers.audience.is_user_in_experiment', + return_value=False) as mock_audience_check, \ + mock.patch('time.time', return_value=42), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'), \ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event') as mock_dispatch_event: self.optimizely.track('test_event', 'user_1') mock_is_experiment_running.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment')) @@ -592,61 +777,62 @@ def setUp(self): def test_activate(self): """ Test that expected log messages are logged during activate. """ + variation_key = 'variation' + experiment_key = 'test_experiment' + user_id = 'test_user' + with mock.patch('optimizely.decision_service.DecisionService.get_variation', return_value=self.project_config.get_variation_from_id( - 'test_experiment', '111129')),\ - mock.patch('time.time', return_value=42),\ - mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event'),\ - mock.patch('optimizely.logger.SimpleLogger.log') as mock_logging: - self.assertEqual('variation', self.optimizely.activate('test_experiment', 'test_user')) + 'test_experiment', '111129')), \ + mock.patch('time.time', return_value=42), \ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event'), \ + mock.patch('optimizely.logger.SimpleLogger.log') as mock_logging: + self.assertEqual(variation_key, self.optimizely.activate(experiment_key, user_id)) self.assertEqual(2, mock_logging.call_count) - self.assertEqual(mock.call(enums.LogLevels.INFO, 'Activating user "test_user" in experiment "test_experiment".'), + self.assertEqual(mock.call(enums.LogLevels.INFO, 'Activating user "%s" in experiment "%s".' + % (user_id, experiment_key)), mock_logging.call_args_list[0]) (debug_level, debug_message) = mock_logging.call_args_list[1][0] self.assertEqual(enums.LogLevels.DEBUG, debug_level) self.assertRegexpMatches(debug_message, - 'Dispatching impression event to URL https://logx.optimizely.com/log/decision with params') + 'Dispatching impression event to URL https://logx.optimizely.com/v1/events with params') def test_track(self): """ Test that expected log messages are logged during track. """ - with mock.patch('optimizely.helpers.audience.is_user_in_experiment', - return_value=False),\ - mock.patch('time.time', return_value=42),\ - mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event'),\ - mock.patch('optimizely.logger.SimpleLogger.log') as mock_logging: - self.optimizely.track('test_event', 'test_user') + user_id = 'test_user' + event_key = 'test_event' + experiment_key = 'test_experiment' - self.assertEqual(3, mock_logging.call_count) - self.assertEqual(mock.call(enums.LogLevels.INFO, - 'User "test_user" does not meet conditions to be in experiment "test_experiment".'), + with mock.patch('optimizely.helpers.audience.is_user_in_experiment', + return_value=False), \ + mock.patch('time.time', return_value=42), \ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event'), \ + mock.patch('optimizely.logger.SimpleLogger.log') as mock_logging: + self.optimizely.track(event_key, user_id) + + self.assertEqual(4, mock_logging.call_count) + self.assertEqual(mock.call(enums.LogLevels.DEBUG, + 'User "%s" is not in the forced variation map.' % user_id), mock_logging.call_args_list[0]) self.assertEqual(mock.call(enums.LogLevels.INFO, - 'Not tracking user "test_user" for experiment "test_experiment".'), + 'User "%s" does not meet conditions to be in experiment "%s".' + % (user_id, experiment_key)), mock_logging.call_args_list[1]) self.assertEqual(mock.call(enums.LogLevels.INFO, - 'There are no valid experiments for event "test_event" to track.'), + 'Not tracking user "%s" for experiment "%s".' % (user_id, experiment_key)), mock_logging.call_args_list[2]) - - def test_activate__invalid_attributes(self): - """ Test that expected log messages are logged during activate when attributes are in invalid format. """ - - with mock.patch('optimizely.logger.SimpleLogger.log') as mock_logging: - self.optimizely.activate('test_experiment', 'test_user', attributes='invalid') - - self.assertEqual(2, mock_logging.call_count) - self.assertEqual(mock_logging.call_args_list[0], - mock.call(enums.LogLevels.ERROR, 'Provided attributes are in an invalid format.')) - self.assertEqual(mock_logging.call_args_list[1], - mock.call(enums.LogLevels.INFO, 'Not activating user "test_user".')) + self.assertEqual(mock.call(enums.LogLevels.INFO, + 'There are no valid experiments for event "%s" to track.' % event_key), + mock_logging.call_args_list[3]) def test_activate__experiment_not_running(self): """ Test that expected log messages are logged during activate when experiment is not running. """ - with mock.patch('optimizely.logger.SimpleLogger.log') as mock_logging,\ - mock.patch('optimizely.helpers.experiment.is_experiment_running', - return_value=False) as mock_is_experiment_running: + with mock.patch('optimizely.logger.SimpleLogger.log') as mock_logging, \ + mock.patch('optimizely.helpers.experiment.is_experiment_running', + return_value=False) as mock_is_experiment_running: self.optimizely.activate('test_experiment', 'test_user', attributes={'test_attribute': 'test_value'}) self.assertEqual(2, mock_logging.call_count) @@ -659,21 +845,30 @@ def test_activate__experiment_not_running(self): def test_activate__no_audience_match(self): """ Test that expected log messages are logged during activate when audience conditions are not met. """ + experiment_key = 'test_experiment' + user_id = 'test_user' + with mock.patch('optimizely.logger.SimpleLogger.log') as mock_logging: self.optimizely.activate('test_experiment', 'test_user', attributes={'test_attribute': 'wrong_test_value'}) - self.assertEqual(2, mock_logging.call_count) + self.assertEqual(3, mock_logging.call_count) + self.assertEqual(mock_logging.call_args_list[0], - mock.call(enums.LogLevels.INFO, - 'User "test_user" does not meet conditions to be in experiment "test_experiment".')) + mock.call(enums.LogLevels.DEBUG, + 'User "%s" is not in the forced variation map.' % user_id)) self.assertEqual(mock_logging.call_args_list[1], - mock.call(enums.LogLevels.INFO, 'Not activating user "test_user".')) + mock.call(enums.LogLevels.INFO, + 'User "%s" does not meet conditions to be in experiment "%s".' + % (user_id, experiment_key))) + self.assertEqual(mock_logging.call_args_list[2], + mock.call(enums.LogLevels.INFO, 'Not activating user "%s".' % user_id)) def test_activate__dispatch_raises_exception(self): """ Test that activate logs dispatch failure gracefully. """ - with mock.patch('optimizely.logger.SimpleLogger.log') as mock_logging,\ - mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event', side_effect=Exception('Failed to send')): + with mock.patch('optimizely.logger.SimpleLogger.log') as mock_logging, \ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event', + side_effect=Exception('Failed to send')): self.assertEqual('control', self.optimizely.activate('test_experiment', 'user_1')) mock_logging.assert_any_call(enums.LogLevels.ERROR, 'Unable to dispatch impression event. Error: Failed to send') @@ -707,8 +902,9 @@ def test_track__invalid_event_tag(self): def test_track__dispatch_raises_exception(self): """ Test that track logs dispatch failure gracefully. """ - with mock.patch('optimizely.logger.SimpleLogger.log') as mock_logging,\ - mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event', side_effect=Exception('Failed to send')): + with mock.patch('optimizely.logger.SimpleLogger.log') as mock_logging, \ + mock.patch('optimizely.event_dispatcher.EventDispatcher.dispatch_event', + side_effect=Exception('Failed to send')): self.optimizely.track('test_event', 'user_1') mock_logging.assert_any_call(enums.LogLevels.ERROR, 'Unable to dispatch conversion event. Error: Failed to send') @@ -721,12 +917,24 @@ def test_get_variation__invalid_attributes(self): mock_logging.assert_called_once_with(enums.LogLevels.ERROR, 'Provided attributes are in an invalid format.') + def test_activate__invalid_attributes(self): + """ Test that expected log messages are logged during activate when attributes are in invalid format. """ + + with mock.patch('optimizely.logger.SimpleLogger.log') as mock_logging: + self.optimizely.activate('test_experiment', 'test_user', attributes='invalid') + + self.assertEqual(2, mock_logging.call_count) + self.assertEqual(mock.call(enums.LogLevels.ERROR, 'Provided attributes are in an invalid format.'), + mock_logging.call_args_list[0]) + self.assertEqual(mock.call(enums.LogLevels.INFO, 'Not activating user "test_user".'), + mock_logging.call_args_list[1]) + def test_get_variation__experiment_not_running(self): """ Test that expected log messages are logged during get variation when experiment is not running. """ - with mock.patch('optimizely.logger.SimpleLogger.log') as mock_logging,\ - mock.patch('optimizely.helpers.experiment.is_experiment_running', - return_value=False) as mock_is_experiment_running: + with mock.patch('optimizely.logger.SimpleLogger.log') as mock_logging, \ + mock.patch('optimizely.helpers.experiment.is_experiment_running', + return_value=False) as mock_is_experiment_running: self.optimizely.get_variation('test_experiment', 'test_user', attributes={'test_attribute': 'test_value'}) mock_logging.assert_called_once_with(enums.LogLevels.INFO, 'Experiment "test_experiment" is not running.') @@ -735,10 +943,73 @@ def test_get_variation__experiment_not_running(self): def test_get_variation__no_audience_match(self): """ Test that expected log messages are logged during get variation when audience conditions are not met. """ + experiment_key = 'test_experiment' + user_id = 'test_user' + with mock.patch('optimizely.logger.SimpleLogger.log') as mock_logging: - self.optimizely.get_variation('test_experiment', 'test_user', attributes={'test_attribute': 'wrong_test_value'}) + self.optimizely.get_variation(experiment_key, + user_id, + attributes={'test_attribute': 'wrong_test_value'}) - mock_logging.assert_called_once_with( - enums.LogLevels.INFO, - 'User "test_user" does not meet conditions to be in experiment "test_experiment".' - ) + self.assertEqual(2, mock_logging.call_count) + self.assertEqual(mock.call(enums.LogLevels.DEBUG, 'User "%s" is not in the forced variation map.' % user_id), \ + mock_logging.call_args_list[0]) + + self.assertEqual(mock.call(enums.LogLevels.INFO, 'User "%s" does not meet conditions to be in experiment "%s".' + % (user_id, experiment_key)), + mock_logging.call_args_list[1]) + + def test_get_variation__forced_bucketing(self): + """ Test that the expected forced variation is called for a valid experiment and attributes """ + + self.assertTrue(self.optimizely.set_forced_variation('test_experiment', 'test_user', 'variation')) + self.assertEqual('variation', self.optimizely.get_forced_variation('test_experiment', 'test_user')) + variation_key = self.optimizely.get_variation('test_experiment', + 'test_user', + attributes={'test_attribute': 'test_value'}) + self.assertEqual('variation', variation_key) + + def test_get_variation__experiment_not_running__forced_bucketing(self): + """ Test that the expected forced variation is called if an experiment is not running """ + + with mock.patch('optimizely.helpers.experiment.is_experiment_running', + return_value=False) as mock_is_experiment_running: + self.optimizely.set_forced_variation('test_experiment', 'test_user', 'variation') + self.assertEqual('variation', self.optimizely.get_forced_variation('test_experiment', 'test_user')) + variation_key = self.optimizely.get_variation('test_experiment', + 'test_user', + attributes={'test_attribute': 'test_value'}) + self.assertIsNone(variation_key) + mock_is_experiment_running.assert_called_once_with(self.project_config.get_experiment_from_key('test_experiment')) + + def test_get_variation__whitelisted_user_forced_bucketing(self): + """ Test that the expected forced variation is called if a user is whitelisted """ + + self.assertTrue(self.optimizely.set_forced_variation('group_exp_1', 'user_1', 'group_exp_1_variation')) + forced_variation = self.optimizely.get_forced_variation('group_exp_1', 'user_1') + self.assertEqual('group_exp_1_variation', forced_variation) + variation_key = self.optimizely.get_variation('group_exp_1', + 'user_1', + attributes={'test_attribute': 'test_value'}) + self.assertEqual('group_exp_1_variation', variation_key) + + def test_get_variation__user_profile__forced_bucketing(self): + """ Test that the expected forced variation is called if a user profile exists """ + with mock.patch('optimizely.decision_service.DecisionService.get_stored_variation', + return_value=entities.Variation('111128', 'control')) as mock_get_stored_variation: + self.assertTrue(self.optimizely.set_forced_variation('test_experiment', 'test_user', 'variation')) + self.assertEqual('variation', self.optimizely.get_forced_variation('test_experiment', 'test_user')) + variation_key = self.optimizely.get_variation('test_experiment', + 'test_user', + attributes={'test_attribute': 'test_value'}) + self.assertEqual('variation', variation_key) + + def test_get_variation__invalid_attributes__forced_bucketing(self): + """ Test that the expected forced variation is called if the user does not pass audience evaluation """ + + self.assertTrue(self.optimizely.set_forced_variation('test_experiment', 'test_user', 'variation')) + self.assertEqual('variation', self.optimizely.get_forced_variation('test_experiment', 'test_user')) + variation_key = self.optimizely.get_variation('test_experiment', + 'test_user', + attributes={'test_attribute': 'test_value_invalid'}) + self.assertEqual('variation', variation_key) diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..deb283de --- /dev/null +++ b/tox.ini @@ -0,0 +1,17 @@ +[pep8] +# E111 - indentation is not a multiple of four +# E114 - indentation is not a multiple of four (comment) +# E121 - continuation line indentation is not a multiple of four +# E122 - continuation line missing indentation or outdented +# E124 - closing bracket does not match visual indentation +# E125 - continuation line does not distinguish itself from next logical line +# E126 - continuation line over-indented for hanging indent +# E127 - continuation line over-indented for visual indent +# E128 - continuation line under-indented for visual indent +# E129 - visually indented line with same indent as next logical line +# E131 - continuation line unaligned for hanging indent +# E265 - block comment should start with '# ' +# E502 - the backslash is redundant between brackets +ignore = E111,E114,E121,E122,E123,E124,E125,E126,E127,E128,E129,E131,E265,E502 +exclude = optimizely/lib/pymmh3.py,*virtualenv* +max-line-length = 120