diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index 3e424302..6b50f77b 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -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 @@ -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): @@ -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. diff --git a/optimizely/event_builder.py b/optimizely/event_builder.py index 4f4be6f0..087dc1bf 100644 --- a/optimizely/event_builder.py +++ b/optimizely/event_builder.py @@ -17,6 +17,7 @@ from abc import abstractproperty from . import version +from .helpers import enums from .helpers import event_tag_utils @@ -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. @@ -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 diff --git a/optimizely/helpers/enums.py b/optimizely/helpers/enums.py index e0f0fd0c..a8ff454a 100644 --- a/optimizely/helpers/enums.py +++ b/optimizely/helpers/enums.py @@ -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 @@ -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' diff --git a/optimizely/project_config.py b/optimizely/project_config.py index a42cf85f..49ea28c1 100644 --- a/optimizely/project_config.py +++ b/optimizely/project_config.py @@ -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. """ @@ -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) @@ -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)) @@ -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 diff --git a/tests/base.py b/tests/base.py index 7af62c99..72b78c7a 100644 --- a/tests/base.py +++ b/tests/base.py @@ -145,6 +145,7 @@ def setUp(self): 'accountId': '12001', 'projectId': '111111', 'version': '4', + 'botFiltering': True, 'events': [{ 'key': 'test_event', 'experimentIds': ['111127'], diff --git a/tests/test_config.py b/tests/test_config.py index a22929d6..8bc6ee37 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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'], @@ -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( @@ -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. """ @@ -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. """ @@ -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), @@ -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. """ @@ -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. """ diff --git a/tests/test_event_builder.py b/tests/test_event_builder.py index 9334e6db..4a74929a 100644 --- a/tests/test_event_builder.py +++ b/tests/test_event_builder.py @@ -13,6 +13,7 @@ import mock import unittest +from operator import itemgetter from optimizely import event_builder from optimizely import version @@ -48,7 +49,13 @@ def _validate_event_object(self, event_obj, expected_url, expected_params, expec """ Helper method to validate properties of the event object. """ self.assertEqual(expected_url, event_obj.url) + + expected_params['visitors'][0]['attributes'] = \ + sorted(expected_params['visitors'][0]['attributes'], key=itemgetter('key')) + event_obj.params['visitors'][0]['attributes'] = \ + sorted(event_obj.params['visitors'][0]['attributes'], key=itemgetter('key')) self.assertEqual(expected_params, event_obj.params) + self.assertEqual(expected_verb, event_obj.http_verb) self.assertEqual(expected_headers, event_obj.headers) @@ -182,6 +189,166 @@ def test_create_impression_event_when_attribute_is_not_in_datafile(self): event_builder.EventBuilder.HTTP_VERB, event_builder.EventBuilder.HTTP_HEADERS) + def test_create_impression_event__with_user_agent_when_bot_filtering_is_enabled(self): + """ Test that create_impression_event creates Event object + with right params when user agent attribute is provided and + bot filtering is enabled """ + + expected_params = { + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [{ + 'type': 'custom', + 'value': 'Edge', + 'entity_id': '$opt_user_agent', + 'key': '$opt_user_agent' + }, { + 'type': 'custom', + 'value': True, + 'entity_id': '$opt_bot_filtering', + 'key': '$opt_bot_filtering' + }], + '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__, + 'anonymize_ip': False, + 'revision': '42' + } + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'),\ + mock.patch('optimizely.event_builder.EventBuilder._get_bot_filtering', return_value=True): + event_obj = self.event_builder.create_impression_event( + self.project_config.get_experiment_from_key('test_experiment'), + '111129', 'test_user', {'$opt_user_agent': 'Edge'} + ) + + self._validate_event_object(event_obj, + event_builder.EventBuilder.EVENTS_URL, + expected_params, + event_builder.EventBuilder.HTTP_VERB, + event_builder.EventBuilder.HTTP_HEADERS) + + def test_create_impression_event__with_empty_attributes_when_bot_filtering_is_enabled(self): + """ Test that create_impression_event creates Event object + with right params when empty attributes are provided and + bot filtering is enabled """ + + expected_params = { + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [{ + 'type': 'custom', + 'value': True, + 'entity_id': '$opt_bot_filtering', + 'key': '$opt_bot_filtering' + }], + '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__, + 'anonymize_ip': False, + 'revision': '42' + } + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'),\ + mock.patch('optimizely.event_builder.EventBuilder._get_bot_filtering', return_value=True): + 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.EVENTS_URL, + expected_params, + event_builder.EventBuilder.HTTP_VERB, + event_builder.EventBuilder.HTTP_HEADERS) + + def test_create_impression_event__with_user_agent_when_bot_filtering_is_disabled(self): + """ Test that create_impression_event creates Event object + with right params when user agent attribute is provided and + bot filtering is disabled """ + + expected_params = { + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [{ + 'type': 'custom', + 'value': 'Chrome', + 'entity_id': '$opt_user_agent', + 'key': '$opt_user_agent' + }, { + 'type': 'custom', + 'value': False, + 'entity_id': '$opt_bot_filtering', + 'key': '$opt_bot_filtering' + }], + '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__, + 'anonymize_ip': False, + 'revision': '42' + } + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'),\ + mock.patch('optimizely.event_builder.EventBuilder._get_bot_filtering', return_value=False): + event_obj = self.event_builder.create_impression_event( + self.project_config.get_experiment_from_key('test_experiment'), + '111129', 'test_user', {'$opt_user_agent': 'Chrome'} + ) + + self._validate_event_object(event_obj, + event_builder.EventBuilder.EVENTS_URL, + expected_params, + event_builder.EventBuilder.HTTP_VERB, + event_builder.EventBuilder.HTTP_HEADERS) + def test_create_conversion_event(self): """ Test that create_conversion_event creates Event object with right params when no attributes are provided. """ @@ -269,6 +436,114 @@ def test_create_conversion_event__with_attributes(self): event_builder.EventBuilder.HTTP_VERB, event_builder.EventBuilder.HTTP_HEADERS) + def test_create_conversion_event__with_user_agent_when_bot_filtering_is_enabled(self): + """ Test that create_conversion_event creates Event object + with right params when user agent attribute is provided and + bot filtering is enabled """ + + expected_params = { + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [{ + 'type': 'custom', + 'value': 'Edge', + 'entity_id': '$opt_user_agent', + 'key': '$opt_user_agent' + }, { + 'type': 'custom', + 'value': True, + 'entity_id': '$opt_bot_filtering', + 'key': '$opt_bot_filtering' + }], + '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' + }] + }] + }], + 'client_name': 'python-sdk', + 'client_version': version.__version__, + 'anonymize_ip': False, + 'revision': '42' + } + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'), \ + mock.patch('optimizely.event_builder.EventBuilder._get_bot_filtering', return_value=True): + event_obj = self.event_builder.create_conversion_event( + 'test_event', 'test_user', {'$opt_user_agent': 'Edge'}, None, [('111127', '111129')] + ) + + self._validate_event_object(event_obj, + event_builder.EventBuilder.EVENTS_URL, + expected_params, + event_builder.EventBuilder.HTTP_VERB, + event_builder.EventBuilder.HTTP_HEADERS) + + def test_create_conversion_event__with_user_agent_when_bot_filtering_is_disabled(self): + """ Test that create_conversion_event creates Event object + with right params when user agent attribute is provided and + bot filtering is disabled """ + + expected_params = { + 'account_id': '12001', + 'project_id': '111001', + 'visitors': [{ + 'visitor_id': 'test_user', + 'attributes': [{ + 'type': 'custom', + 'value': 'Chrome', + 'entity_id': '$opt_user_agent', + 'key': '$opt_user_agent' + }, { + 'type': 'custom', + 'value': False, + 'entity_id': '$opt_bot_filtering', + 'key': '$opt_bot_filtering' + }], + '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' + }] + }] + }], + 'client_name': 'python-sdk', + 'client_version': version.__version__, + 'anonymize_ip': False, + 'revision': '42' + } + + with mock.patch('time.time', return_value=42.123), \ + mock.patch('uuid.uuid4', return_value='a68cf1ad-0393-4e18-af87-efe8f01a7c9c'), \ + mock.patch('optimizely.event_builder.EventBuilder._get_bot_filtering', return_value=False): + event_obj = self.event_builder.create_conversion_event( + 'test_event', 'test_user', {'$opt_user_agent': 'Chrome'}, None, [('111127', '111129')] + ) + + self._validate_event_object(event_obj, + event_builder.EventBuilder.EVENTS_URL, + expected_params, + event_builder.EventBuilder.HTTP_VERB, + event_builder.EventBuilder.HTTP_HEADERS) + def test_create_conversion_event__with_event_tags(self): """ Test that create_conversion_event creates Event object with right params when event tags are provided. """ diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index dd25aa70..f065b051 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -13,6 +13,7 @@ import json import mock +from operator import itemgetter from optimizely import decision_service from optimizely import entities @@ -52,6 +53,11 @@ def _validate_event_object(self, event_obj, expected_url, expected_params, expec """ Helper method to validate properties of the event object. """ self.assertEqual(expected_url, event_obj.url) + + expected_params['visitors'][0]['attributes'] = \ + sorted(expected_params['visitors'][0]['attributes'], key=itemgetter('key')) + event_obj.params['visitors'][0]['attributes'] = \ + sorted(event_obj.params['visitors'][0]['attributes'], key=itemgetter('key')) self.assertEqual(expected_params, event_obj.params) self.assertEqual(expected_verb, event_obj.http_verb) self.assertEqual(expected_headers, event_obj.headers) @@ -605,6 +611,11 @@ def test_activate__with_attributes__audience_match__bucketing_id_provided(self): 'visitors': [{ 'visitor_id': 'test_user', 'attributes': [{ + 'type': 'custom', + 'value': 'user_bucket_value', + 'entity_id': '$opt_bucketing_id', + 'key': '$opt_bucketing_id' + }, { 'type': 'custom', 'value': 'test_value', 'entity_id': '111094', @@ -773,6 +784,11 @@ def test_track__with_attributes__bucketing_id_provided(self): 'visitors': [{ 'visitor_id': 'test_user', 'attributes': [{ + 'type': 'custom', + 'value': 'user_bucket_value', + 'entity_id': '$opt_bucketing_id', + 'key': '$opt_bucketing_id' + }, { 'type': 'custom', 'value': 'test_value', 'entity_id': '111094', @@ -1212,7 +1228,12 @@ def test_is_feature_enabled__returns_true_for_feature_experiment_if_feature_enab 'project_id': '111111', 'visitors': [{ 'visitor_id': 'test_user', - 'attributes': [], + 'attributes': [{ + 'type': 'custom', + 'value': True, + 'entity_id': '$opt_bot_filtering', + 'key': '$opt_bot_filtering' + }], 'snapshots': [{ 'decisions': [{ 'variation_id': '111129', @@ -1271,7 +1292,12 @@ def test_is_feature_enabled__returns_false_for_feature_experiment_if_feature_dis 'project_id': '111111', 'visitors': [{ 'visitor_id': 'test_user', - 'attributes': [], + 'attributes': [{ + 'type': 'custom', + 'value': True, + 'entity_id': '$opt_bot_filtering', + 'key': '$opt_bot_filtering' + }], 'snapshots': [{ 'decisions': [{ 'variation_id': '111128', @@ -1794,6 +1820,7 @@ def test_get_feature_variable__returns_none_if_unable_to_cast(self): class OptimizelyWithExceptionTest(base.BaseTest): + def setUp(self): base.BaseTest.setUp(self) self.optimizely = optimizely.Optimizely(json.dumps(self.config_dict), @@ -1825,6 +1852,7 @@ def test_get_variation__with_attributes__invalid_attributes(self): class OptimizelyWithLoggingTest(base.BaseTest): + def setUp(self): base.BaseTest.setUp(self) self.optimizely = optimizely.Optimizely(