Skip to content

Commit 42d74b2

Browse files
oakbanimikeproeng37
authored andcommitted
feat(api): Accept all types of Attribute values (#142)
1 parent c2ea908 commit 42d74b2

File tree

11 files changed

+348
-13
lines changed

11 files changed

+348
-13
lines changed

optimizely/decision_service.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# limitations under the License.
1313

1414
from collections import namedtuple
15+
from six import string_types
1516

1617
from . import bucketer
1718
from .helpers import audience as audience_helper
@@ -34,20 +35,27 @@ def __init__(self, config, user_profile_service):
3435
self.config = config
3536
self.logger = config.logger
3637

37-
@staticmethod
38-
def _get_bucketing_id(user_id, attributes):
38+
def _get_bucketing_id(self, user_id, attributes):
3939
""" Helper method to determine bucketing ID for the user.
4040
4141
Args:
4242
user_id: ID for user.
4343
attributes: Dict representing user attributes. May consist of bucketing ID to be used.
4444
4545
Returns:
46-
String representing bucketing ID for the user. Fallback to user's ID if not provided.
46+
String representing bucketing ID if it is a String type in attributes else return user ID.
4747
"""
4848

4949
attributes = attributes or {}
50-
return attributes.get(enums.ControlAttributes.BUCKETING_ID, user_id)
50+
bucketing_id = attributes.get(enums.ControlAttributes.BUCKETING_ID)
51+
52+
if bucketing_id is not None:
53+
if isinstance(bucketing_id, string_types):
54+
return bucketing_id
55+
56+
self.logger.warning('Bucketing ID attribute is not a string. Defaulted to user_id.')
57+
58+
return user_id
5159

5260
def get_forced_variation(self, experiment, user_id):
5361
""" Determine if a user is forced into a variation for the given experiment and return that variation.

optimizely/event_builder.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from . import version
2020
from .helpers import enums
2121
from .helpers import event_tag_utils
22+
from .helpers import validator
2223

2324

2425
class Event(object):
@@ -182,8 +183,8 @@ def _get_attributes(self, attributes):
182183
if isinstance(attributes, dict):
183184
for attribute_key in attributes.keys():
184185
attribute_value = attributes.get(attribute_key)
185-
# Omit falsy attribute values
186-
if attribute_value:
186+
# Omit attribute values that are not supported by the log endpoint.
187+
if validator.is_attribute_valid(attribute_key, attribute_value):
187188
attribute_id = self.config.get_attribute_id(attribute_key)
188189
if attribute_id:
189190
params.append({

optimizely/helpers/validator.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,25 @@ def is_non_empty_string(input_id_key):
167167
return True
168168

169169
return False
170+
171+
172+
def is_attribute_valid(attribute_key, attribute_value):
173+
""" Determine if given attribute is valid.
174+
175+
Args:
176+
attribute_key: Variable which needs to be validated
177+
attribute_value: Variable which needs to be validated
178+
179+
Returns:
180+
False if attribute_key is not a string
181+
False if attribute_value is not one of the supported attribute types
182+
True otherwise
183+
"""
184+
185+
if not isinstance(attribute_key, string_types):
186+
return False
187+
188+
if isinstance(attribute_value, string_types) or type(attribute_value) in (int, float, bool):
189+
return True
190+
191+
return False

optimizely/optimizely.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,9 @@ def _get_feature_variable_for_type(self, feature_key, variable_key, variable_typ
212212
self.logger.error(enums.Errors.NONE_USER_ID_PARAMETER)
213213
return None
214214

215+
if not self._validate_user_inputs(attributes):
216+
return None
217+
215218
feature_flag = self.config.get_feature_from_key(feature_key)
216219
if not feature_flag:
217220
return None
@@ -407,6 +410,9 @@ def is_feature_enabled(self, feature_key, user_id, attributes=None):
407410
self.logger.error(enums.Errors.INVALID_INPUT_ERROR.format('user_id'))
408411
return False
409412

413+
if not self._validate_user_inputs(attributes):
414+
return False
415+
410416
feature = self.config.get_feature_from_key(feature_key)
411417
if not feature:
412418
return False
@@ -447,6 +453,9 @@ def get_enabled_features(self, user_id, attributes=None):
447453
self.logger.error(enums.Errors.INVALID_INPUT_ERROR.format('user_id'))
448454
return enabled_features
449455

456+
if not self._validate_user_inputs(attributes):
457+
return enabled_features
458+
450459
for feature in self.config.feature_key_map.values():
451460
if self.is_feature_enabled(feature.key, user_id, attributes):
452461
enabled_features.append(feature.key)

tests/base.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,15 @@ def setUp(self, config_dict='config_dict'):
124124
'attributes': [{
125125
'key': 'test_attribute',
126126
'id': '111094'
127+
}, {
128+
'key': 'boolean_key',
129+
'id': '111196'
130+
}, {
131+
'key': 'integer_key',
132+
'id': '111197'
133+
}, {
134+
'key': 'double_key',
135+
'id': '111198'
127136
}],
128137
'audiences': [{
129138
'name': 'Test attribute users 1',
@@ -506,6 +515,15 @@ def setUp(self, config_dict='config_dict'):
506515
'attributes': [{
507516
'key': 'test_attribute',
508517
'id': '111094'
518+
}, {
519+
'key': 'boolean_key',
520+
'id': '111196'
521+
}, {
522+
'key': 'integer_key',
523+
'id': '111197'
524+
}, {
525+
'key': 'double_key',
526+
'id': '111198'
509527
}],
510528
'audiences': [{
511529
'name': 'Test attribute users 1',

tests/helpers_tests/test_condition.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2016-2017, Optimizely
1+
# Copyright 2016-2018, Optimizely
22
# Licensed under the Apache License, Version 2.0 (the "License");
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
@@ -33,10 +33,29 @@ def setUp(self):
3333
self.condition_evaluator = condition_helper.ConditionEvaluator(self.condition_list, attributes)
3434

3535
def test_evaluator__returns_true(self):
36-
""" Test that evaluator correctly returns True when there is a match. """
36+
""" Test that evaluator correctly returns True when there is an exact match.
37+
Also test that evaluator works for falsy values. """
3738

39+
# string attribute value
40+
condition_list = [['test_attribute', '']]
41+
condition_evaluator = condition_helper.ConditionEvaluator(condition_list, {'test_attribute': ''})
3842
self.assertTrue(self.condition_evaluator.evaluator(0))
3943

44+
# boolean attribute value
45+
condition_list = [['boolean_key', False]]
46+
condition_evaluator = condition_helper.ConditionEvaluator(condition_list, {'boolean_key': False})
47+
self.assertTrue(condition_evaluator.evaluator(0))
48+
49+
# integer attribute value
50+
condition_list = [['integer_key', 0]]
51+
condition_evaluator = condition_helper.ConditionEvaluator(condition_list, {'integer_key': 0})
52+
self.assertTrue(condition_evaluator.evaluator(0))
53+
54+
# double attribute value
55+
condition_list = [['double_key', 0.0]]
56+
condition_evaluator = condition_helper.ConditionEvaluator(condition_list, {'double_key': 0.0})
57+
self.assertTrue(condition_evaluator.evaluator(0))
58+
4059
def test_evaluator__returns_false(self):
4160
""" Test that evaluator correctly returns False when there is no match. """
4261

tests/helpers_tests/test_validator.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,28 @@ def test_is_non_empty_string(self):
146146
self.assertTrue(validator.is_non_empty_string('0'))
147147
self.assertTrue(validator.is_non_empty_string('test_user'))
148148

149+
def test_is_attribute_valid(self):
150+
""" Test that non-string attribute key or unsupported attribute value returns False."""
151+
152+
# test invalid attribute keys
153+
self.assertFalse(validator.is_attribute_valid(5, 'test_value'))
154+
self.assertFalse(validator.is_attribute_valid(True, 'test_value'))
155+
self.assertFalse(validator.is_attribute_valid(5.5, 'test_value'))
156+
157+
# test invalid attribute values
158+
self.assertFalse(validator.is_attribute_valid('test_attribute', None))
159+
self.assertFalse(validator.is_attribute_valid('test_attribute', {}))
160+
self.assertFalse(validator.is_attribute_valid('test_attribute', []))
161+
self.assertFalse(validator.is_attribute_valid('test_attribute', ()))
162+
163+
# test valid attribute values
164+
self.assertTrue(validator.is_attribute_valid('test_attribute', False))
165+
self.assertTrue(validator.is_attribute_valid('test_attribute', True))
166+
self.assertTrue(validator.is_attribute_valid('test_attribute', 0))
167+
self.assertTrue(validator.is_attribute_valid('test_attribute', 0.0))
168+
self.assertTrue(validator.is_attribute_valid('test_attribute', ""))
169+
self.assertTrue(validator.is_attribute_valid('test_attribute', 'test_value'))
170+
149171

150172
class DatafileValidationTests(base.BaseTest):
151173

tests/test_config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ def test_init(self):
110110
'Total Revenue': entities.Event('111096', 'Total Revenue', ['111127'])
111111
}
112112
expected_attribute_key_map = {
113+
'boolean_key': entities.Attribute('111196', 'boolean_key'),
114+
'double_key': entities.Attribute('111198', 'double_key'),
115+
'integer_key': entities.Attribute('111197', 'integer_key'),
113116
'test_attribute': entities.Attribute('111094', 'test_attribute', segmentId='11133')
114117
}
115118
expected_audience_id_map = {

tests/test_decision_service.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,18 +34,42 @@ def test_get_bucketing_id__no_bucketing_id_attribute(self):
3434
""" Test that _get_bucketing_id returns correct bucketing ID when there is no bucketing ID attribute. """
3535

3636
# No attributes
37-
self.assertEqual('test_user', decision_service.DecisionService._get_bucketing_id('test_user', None))
37+
self.assertEqual('test_user', self.decision_service._get_bucketing_id('test_user', None))
3838

3939
# With attributes, but no bucketing ID
40-
self.assertEqual('test_user', decision_service.DecisionService._get_bucketing_id('test_user',
40+
self.assertEqual('test_user', self.decision_service._get_bucketing_id('test_user',
4141
{'random_key': 'random_value'}))
4242

4343
def test_get_bucketing_id__bucketing_id_attribute(self):
4444
""" Test that _get_bucketing_id returns correct bucketing ID when there is bucketing ID attribute. """
45+
with mock.patch.object(self.decision_service, 'logger') as mock_decision_logging:
46+
self.assertEqual('user_bucket_value',
47+
self.decision_service._get_bucketing_id('test_user',
48+
{'$opt_bucketing_id': 'user_bucket_value'}))
49+
mock_decision_logging.debug.assert_not_called()
4550

46-
self.assertEqual('user_bucket_value',
47-
decision_service.DecisionService._get_bucketing_id('test_user',
48-
{'$opt_bucketing_id': 'user_bucket_value'}))
51+
def test_get_bucketing_id__bucketing_id_attribute_not_a_string(self):
52+
""" Test that _get_bucketing_id returns user ID as bucketing ID when bucketing ID attribute is not a string"""
53+
with mock.patch.object(self.decision_service, 'logger') as mock_decision_logging:
54+
self.assertEqual('test_user',
55+
self.decision_service._get_bucketing_id('test_user',
56+
{'$opt_bucketing_id': True}))
57+
mock_decision_logging.warning.assert_called_once_with(
58+
'Bucketing ID attribute is not a string. Defaulted to user_id.')
59+
mock_decision_logging.reset_mock()
60+
61+
self.assertEqual('test_user',
62+
self.decision_service._get_bucketing_id('test_user',
63+
{'$opt_bucketing_id': 5.9}))
64+
mock_decision_logging.warning.assert_called_once_with(
65+
'Bucketing ID attribute is not a string. Defaulted to user_id.')
66+
mock_decision_logging.reset_mock()
67+
68+
self.assertEqual('test_user',
69+
self.decision_service._get_bucketing_id('test_user',
70+
{'$opt_bucketing_id': 5}))
71+
mock_decision_logging.warning.assert_called_once_with(
72+
'Bucketing ID attribute is not a string. Defaulted to user_id.')
4973

5074
def test_get_forced_variation__user_in_forced_variation(self):
5175
""" Test that expected variation is returned if user is forced in a variation. """

tests/test_event_builder.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,75 @@ def test_create_impression_event_when_attribute_is_not_in_datafile(self):
189189
event_builder.EventBuilder.HTTP_VERB,
190190
event_builder.EventBuilder.HTTP_HEADERS)
191191

192+
def test_create_impression_event_calls_is_attribute_valid(self):
193+
""" Test that create_impression_event calls is_attribute_valid and
194+
creates Event object with only those attributes for which is_attribute_valid is True."""
195+
196+
expected_params = {
197+
'account_id': '12001',
198+
'project_id': '111001',
199+
'visitors': [{
200+
'visitor_id': 'test_user',
201+
'attributes': [{
202+
'type': 'custom',
203+
'value': 5.5,
204+
'entity_id': '111198',
205+
'key': 'double_key'
206+
}, {
207+
'type': 'custom',
208+
'value': True,
209+
'entity_id': '111196',
210+
'key': 'boolean_key'
211+
}],
212+
'snapshots': [{
213+
'decisions': [{
214+
'variation_id': '111129',
215+
'experiment_id': '111127',
216+
'campaign_id': '111182'
217+
}],
218+
'events': [{
219+
'timestamp': 42123,
220+
'entity_id': '111182',
221+
'uuid': 'a68cf1ad-0393-4e18-af87-efe8f01a7c9c',
222+
'key': 'campaign_activated'
223+
}]
224+
}]
225+
}],
226+
'client_name': 'python-sdk',
227+
'client_version': version.__version__,
228+
'anonymize_ip': False,
229+
'revision': '42'
230+
}
231+
232+
def side_effect(*args, **kwargs):
233+
attribute_key = args[0]
234+
if attribute_key == 'boolean_key' or attribute_key == 'double_key':
235+
return True
236+
237+
return False
238+
239+
attributes = {
240+
'test_attribute': 'test_value',
241+
'boolean_key': True,
242+
'integer_key': 0,
243+
'double_key': 5.5
244+
}
245+
246+
with mock.patch('time.time', return_value=42.123), \
247+
mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'),\
248+
mock.patch('optimizely.helpers.validator.is_attribute_valid', side_effect=side_effect):
249+
250+
event_obj = self.event_builder.create_impression_event(
251+
self.project_config.get_experiment_from_key('test_experiment'),
252+
'111129', 'test_user', attributes
253+
)
254+
255+
self._validate_event_object(event_obj,
256+
event_builder.EventBuilder.EVENTS_URL,
257+
expected_params,
258+
event_builder.EventBuilder.HTTP_VERB,
259+
event_builder.EventBuilder.HTTP_HEADERS)
260+
192261
def test_create_impression_event__with_user_agent_when_bot_filtering_is_enabled(self):
193262
""" Test that create_impression_event creates Event object
194263
with right params when user agent attribute is provided and

0 commit comments

Comments
 (0)