Skip to content

Commit

Permalink
Merge branch 'master' into oakbani/validate-inputs
Browse files Browse the repository at this point in the history
  • Loading branch information
oakbani committed Jul 3, 2018
2 parents 691c9ea + 210e9c6 commit 2bd8ece
Show file tree
Hide file tree
Showing 10 changed files with 428 additions and 37 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 2.1.0
July 2nd, 2018

- Introduced support for bot filtering ([#121](https://github.com/optimizely/python-sdk/pull/121)).
- Overhauled logging to use standard Python logging ([#123](https://github.com/optimizely/python-sdk/pull/123)).

## 2.0.1
June 19th, 2018

Expand Down
5 changes: 2 additions & 3 deletions optimizely/decision_service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2017, Optimizely
# Copyright 2017-2018, Optimizely
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
Expand All @@ -23,7 +23,6 @@
Decision = namedtuple('Decision', 'experiment variation source')
DECISION_SOURCE_EXPERIMENT = 'experiment'
DECISION_SOURCE_ROLLOUT = 'rollout'
RESERVED_BUCKETING_ID_ATTRIBUTE = '$opt_bucketing_id'


class DecisionService(object):
Expand All @@ -48,7 +47,7 @@ def _get_bucketing_id(user_id, attributes):
"""

attributes = attributes or {}
return attributes.get(RESERVED_BUCKETING_ID_ATTRIBUTE, user_id)
return attributes.get(enums.ControlAttributes.BUCKETING_ID, user_id)

def get_forced_variation(self, experiment, user_id):
""" Determine if a user is forced into a variation for the given experiment and return that variation.
Expand Down
50 changes: 34 additions & 16 deletions optimizely/event_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from abc import abstractproperty

from . import version
from .helpers import enums
from .helpers import event_tag_utils


Expand Down Expand Up @@ -80,11 +81,20 @@ def _get_anonymize_ip(self):
""" Get IP anonymization bool
Returns:
bool 'anonymizeIP' value in the datafile.
Boolean representing whether IP anonymization is enabled or not.
"""

return self.config.get_anonymize_ip_value()

def _get_bot_filtering(self):
""" Get bot filtering bool
Returns:
Boolean representing whether bot filtering is enabled or not.
"""

return self.config.get_bot_filtering_value()

@abstractmethod
def _get_time(self):
""" Get time in milliseconds to be added.
Expand Down Expand Up @@ -169,21 +179,29 @@ def _get_attributes(self, attributes):

params = []

if not attributes:
return []

for attribute_key in attributes.keys():
attribute_value = attributes.get(attribute_key)
# Omit falsy attribute values
if attribute_value:
attribute = self.config.get_attribute(attribute_key)
if attribute:
params.append({
self.EventParams.EVENT_ID: attribute.id,
'key': attribute_key,
'type': self.EventParams.CUSTOM,
'value': attribute_value,
})
if isinstance(attributes, dict):
for attribute_key in attributes.keys():
attribute_value = attributes.get(attribute_key)
# Omit falsy attribute values
if attribute_value:
attribute_id = self.config.get_attribute_id(attribute_key)
if attribute_id:
params.append({
'entity_id': attribute_id,
'key': attribute_key,
'type': self.EventParams.CUSTOM,
'value': attribute_value
})

# Append Bot Filtering Attribute
bot_filtering_value = self._get_bot_filtering()
if isinstance(bot_filtering_value, bool):
params.append({
'entity_id': enums.ControlAttributes.BOT_FILTERING,
'key': enums.ControlAttributes.BOT_FILTERING,
'type': self.EventParams.CUSTOM,
'value': bot_filtering_value
})

return params

Expand Down
8 changes: 7 additions & 1 deletion optimizely/helpers/enums.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2016-2017, Optimizely
# Copyright 2016-2018, Optimizely
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
Expand Down Expand Up @@ -59,3 +59,9 @@ class NotificationTypes(object):
"""
ACTIVATE = "ACTIVATE:experiment, user_id, attributes, variation, event"
TRACK = "TRACK:event_key, user_id, attributes, event_tags, event"


class ControlAttributes(object):
BOT_FILTERING = '$opt_bot_filtering'
BUCKETING_ID = '$opt_bucketing_id'
USER_AGENT = '$opt_user_agent'
28 changes: 24 additions & 4 deletions optimizely/project_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
SUPPORTED_VERSIONS = [V2_CONFIG_VERSION]
UNSUPPORTED_VERSIONS = [V1_CONFIG_VERSION]

RESERVED_ATTRIBUTE_PREFIX = '$opt_'


class ProjectConfig(object):
""" Representation of the Optimizely project config. """
Expand Down Expand Up @@ -56,6 +58,7 @@ def __init__(self, datafile, logger, error_handler):
self.feature_flags = config.get('featureFlags', [])
self.rollouts = config.get('rollouts', [])
self.anonymize_ip = config.get('anonymizeIP', False)
self.bot_filtering = config.get('botFiltering', None)

# Utility maps for quick lookup
self.group_id_map = self._generate_key_map(self.groups, 'id', entities.Group)
Expand Down Expand Up @@ -363,20 +366,28 @@ def get_event(self, event_key):
self.error_handler.handle_error(exceptions.InvalidEventException(enums.Errors.INVALID_EVENT_KEY_ERROR))
return None

def get_attribute(self, attribute_key):
""" Get attribute for the provided attribute key.
def get_attribute_id(self, attribute_key):
""" Get attribute ID for the provided attribute key.
Args:
attribute_key: Attribute key for which attribute is to be fetched.
Returns:
Attribute corresponding to the provided attribute key.
Attribute ID corresponding to the provided attribute key.
"""

attribute = self.attribute_key_map.get(attribute_key)
has_reserved_prefix = attribute_key.startswith(RESERVED_ATTRIBUTE_PREFIX)

if attribute:
return attribute
if has_reserved_prefix:
self.logger.warning(('Attribute %s unexpectedly has reserved prefix %s; using attribute ID '
'instead of reserved attribute name.' % (attribute_key, RESERVED_ATTRIBUTE_PREFIX)))

return attribute.id

if has_reserved_prefix:
return attribute_key

self.logger.error('Attribute "%s" is not in datafile.' % attribute_key)
self.error_handler.handle_error(exceptions.InvalidAttributeException(enums.Errors.INVALID_ATTRIBUTE_ERROR))
Expand Down Expand Up @@ -595,3 +606,12 @@ def get_anonymize_ip_value(self):
"""

return self.anonymize_ip

def get_bot_filtering_value(self):
""" Gets the bot filtering value.
Returns:
A boolean value that indicates if bot filtering should be enabled.
"""

return self.bot_filtering
2 changes: 1 addition & 1 deletion optimizely/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@
# See the License for the specific language governing permissions and
# limitations under the License.

version_info = (2, 0, 1)
version_info = (2, 1, 0)
__version__ = '.'.join(str(v) for v in version_info)
1 change: 1 addition & 0 deletions tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ def setUp(self):
'accountId': '12001',
'projectId': '111111',
'version': '4',
'botFiltering': True,
'events': [{
'key': 'test_event',
'experimentIds': ['111127'],
Expand Down
60 changes: 50 additions & 10 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ def test_init__with_v4_datafile(self):
'revision': '42',
'version': '4',
'anonymizeIP': False,
'botFiltering': True,
'events': [{
'key': 'test_event',
'experimentIds': ['111127'],
Expand Down Expand Up @@ -387,6 +388,7 @@ def test_init__with_v4_datafile(self):
self.assertEqual(config_dict['revision'], project_config.revision)
self.assertEqual(config_dict['experiments'], project_config.experiments)
self.assertEqual(config_dict['events'], project_config.events)
self.assertEqual(config_dict['botFiltering'], project_config.bot_filtering)

expected_group_id_map = {
'19228': entities.Group(
Expand Down Expand Up @@ -679,6 +681,21 @@ def test_get_project_id(self):

self.assertEqual(self.config_dict['projectId'], self.project_config.get_project_id())

def test_get_bot_filtering(self):
""" Test that bot filtering is retrieved correctly when using get_bot_filtering_value. """

# Assert bot filtering is None when not provided in data file
self.assertTrue('botFiltering' not in self.config_dict)
self.assertIsNone(self.project_config.get_bot_filtering_value())

# Assert bot filtering is retrieved as provided in the data file
opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features))
project_config = opt_obj.config
self.assertEqual(
self.config_dict_with_features['botFiltering'],
project_config.get_bot_filtering_value()
)

def test_get_experiment_from_key__valid_key(self):
""" Test that experiment is retrieved correctly for valid experiment key. """

Expand Down Expand Up @@ -787,16 +804,27 @@ def test_get_event__invalid_key(self):

self.assertIsNone(self.project_config.get_event('invalid_key'))

def test_get_attribute__valid_key(self):
""" Test that attribute is retrieved correctly for valid attribute key. """
def test_get_attribute_id__valid_key(self):
""" Test that attribute ID is retrieved correctly for valid attribute key. """

self.assertEqual(entities.Attribute('111094', 'test_attribute'),
self.project_config.get_attribute('test_attribute'))
self.assertEqual('111094',
self.project_config.get_attribute_id('test_attribute'))

def test_get_attribute__invalid_key(self):
def test_get_attribute_id__invalid_key(self):
""" Test that None is returned when provided attribute key is invalid. """

self.assertIsNone(self.project_config.get_attribute('invalid_key'))
self.assertIsNone(self.project_config.get_attribute_id('invalid_key'))

def test_get_attribute_id__reserved_key(self):
""" Test that Attribute Key is returned as ID when provided attribute key is reserved key. """
self.assertEqual('$opt_user_agent',
self.project_config.get_attribute_id('$opt_user_agent'))

def test_get_attribute_id__unknown_key_with_opt_prefix(self):
""" Test that Attribute Key is returned as ID when provided attribute key is not
present in the datafile but has $opt prefix. """
self.assertEqual('$opt_interesting',
self.project_config.get_attribute_id('$opt_interesting'))

def test_get_group__valid_id(self):
""" Test that group is retrieved correctly for valid group ID. """
Expand Down Expand Up @@ -1074,6 +1102,7 @@ def test_set_forced_variation_when_called_to_remove_forced_variation(self):


class ConfigLoggingTest(base.BaseTest):

def setUp(self):
base.BaseTest.setUp(self)
self.optimizely = optimizely.Optimizely(json.dumps(self.config_dict),
Expand Down Expand Up @@ -1136,14 +1165,25 @@ def test_get_event__invalid_key(self):

mock_config_logging.error.assert_called_once_with('Event "invalid_key" is not in datafile.')

def test_get_attribute__invalid_key(self):
def test_get_attribute_id__invalid_key(self):
""" Test that message is logged when provided attribute key is invalid. """

with mock.patch.object(self.project_config, 'logger') as mock_config_logging:
self.project_config.get_attribute('invalid_key')
self.project_config.get_attribute_id('invalid_key')

mock_config_logging.error.assert_called_once_with('Attribute "invalid_key" is not in datafile.')

def test_get_attribute_id__key_with_opt_prefix_but_not_a_control_attribute(self):
""" Test that message is logged when provided attribute key has $opt_ in prefix and
key is not one of the control attributes. """
self.project_config.attribute_key_map['$opt_abc'] = entities.Attribute('007', '$opt_abc')

with mock.patch.object(self.project_config, 'logger') as mock_config_logging:
self.project_config.get_attribute_id('$opt_abc')

mock_config_logging.warning.assert_called_once_with(("Attribute $opt_abc unexpectedly has reserved prefix $opt_; "
"using attribute ID instead of reserved attribute name."))

def test_get_group__invalid_id(self):
""" Test that message is logged when provided group ID is invalid. """

Expand Down Expand Up @@ -1210,12 +1250,12 @@ def test_get_event__invalid_key(self):
enums.Errors.INVALID_EVENT_KEY_ERROR,
self.project_config.get_event, 'invalid_key')

def test_get_attribute__invalid_key(self):
def test_get_attribute_id__invalid_key(self):
""" Test that exception is raised when provided attribute key is invalid. """

self.assertRaisesRegexp(exceptions.InvalidAttributeException,
enums.Errors.INVALID_ATTRIBUTE_ERROR,
self.project_config.get_attribute, 'invalid_key')
self.project_config.get_attribute_id, 'invalid_key')

def test_get_group__invalid_id(self):
""" Test that exception is raised when provided group ID is invalid. """
Expand Down
Loading

0 comments on commit 2bd8ece

Please sign in to comment.