Skip to content

feat (audience match types): Update audience evaluator and project config for new audience match types #147

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions optimizely/helpers/audience.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ def is_user_in_experiment(config, experiment, attributes):
Args:
config: project_config.ProjectConfig object representing the project.
experiment: Object representing the experiment.
attributes: Dict representing user attributes which will be used in determining if the audience conditions are met.
attributes: Dict representing user attributes which will be used in determining
if the audience conditions are met. If not provided, default to an empty dict.

Returns:
Boolean representing if user satisfies audience conditions for any of the audiences or not.
Expand All @@ -52,9 +53,8 @@ def is_user_in_experiment(config, experiment, attributes):
if not experiment.audienceIds:
return True

# Return False if there are audiences, but no attributes
if not attributes:
return False
if attributes is None:
attributes = {}

# Return True if conditions for any one audience are met
for audience_id in experiment.audienceIds:
Expand Down
10 changes: 10 additions & 0 deletions optimizely/project_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def __init__(self, datafile, logger, error_handler):
self.events = config.get('events', [])
self.attributes = config.get('attributes', [])
self.audiences = config.get('audiences', [])
self.typed_audiences = config.get('typedAudiences', [])
self.feature_flags = config.get('featureFlags', [])
self.rollouts = config.get('rollouts', [])
self.anonymize_ip = config.get('anonymizeIP', False)
Expand All @@ -63,7 +64,16 @@ def __init__(self, datafile, logger, error_handler):
self.experiment_key_map = self._generate_key_map(self.experiments, 'key', entities.Experiment)
self.event_key_map = self._generate_key_map(self.events, 'key', entities.Event)
self.attribute_key_map = self._generate_key_map(self.attributes, 'key', entities.Attribute)

self.audience_id_map = self._generate_key_map(self.audiences, 'id', entities.Audience)

# Conditions of audiences in typedAudiences are not expected
# to be string-encoded as they are in audiences.
for typed_audience in self.typed_audiences:
typed_audience['conditions'] = json.dumps(typed_audience['conditions'])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's kinda weird that we're calling json.dumps on typedAudiences instead of just calling json.loads on audiences, but it looks like ConditionDecoder is the wrench in the works. Hopefully we can reconsider something like #145 in the future!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: might be clearer to do this during the initial assignment to self.typedAudiences (currently on line 50) instead of mutating self.typedAudiences later. Not a big deal though

typed_audience_id_map = self._generate_key_map(self.typed_audiences, 'id', entities.Audience)
self.audience_id_map.update(typed_audience_id_map)

self.rollout_id_map = self._generate_key_map(self.rollouts, 'id', entities.Layer)
for layer in self.rollout_id_map.values():
for experiment in layer.experiments:
Expand Down
248 changes: 248 additions & 0 deletions tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,254 @@ def setUp(self, config_dict='config_dict'):
'revision': '1337'
}

self.config_dict_with_typed_audiences = {
'version': '4',
'rollouts': [
{
'experiments': [
{
'status': 'Running',
'key': '11488548027',
'layerId': '11551226731',
'trafficAllocation': [
{
'entityId': '11557362669',
'endOfRange': 10000
}
],
'audienceIds': ['3468206642', '3988293898', '3988293899', '3468206646',
'3468206647', '3468206644', '3468206643'],
'variations': [
{
'variables': [],
'id': '11557362669',
'key': '11557362669',
'featureEnabled':True
}
],
'forcedVariations': {},
'id': '11488548027'
}
],
'id': '11551226731'
},
{
'experiments': [
{
'status': 'Paused',
'key': '11630490911',
'layerId': '11638870867',
'trafficAllocation': [
{
'entityId': '11475708558',
'endOfRange': 0
}
],
'audienceIds': [],
'variations': [
{
'variables': [],
'id': '11475708558',
'key': '11475708558',
'featureEnabled':False
}
],
'forcedVariations': {},
'id': '11630490911'
}
],
'id': '11638870867'
}
],
'anonymizeIP': False,
'projectId': '11624721371',
'variables': [],
'featureFlags': [
{
'experimentIds': [],
'rolloutId': '11551226731',
'variables': [],
'id': '11477755619',
'key': 'feat'
},
{
'experimentIds': [
'11564051718'
],
'rolloutId': '11638870867',
'variables': [
{
'defaultValue': 'x',
'type': 'string',
'id': '11535264366',
'key': 'x'
}
],
'id': '11567102051',
'key': 'feat_with_var'
}
],
'experiments': [
{
'status': 'Running',
'key': 'feat_with_var_test',
'layerId': '11504144555',
'trafficAllocation': [
{
'entityId': '11617170975',
'endOfRange': 10000
}
],
'audienceIds': ['3468206642', '3988293898', '3988293899', '3468206646',
'3468206647', '3468206644', '3468206643'],
'variations': [
{
'variables': [
{
'id': '11535264366',
'value': 'xyz'
}
],
'id': '11617170975',
'key': 'variation_2',
'featureEnabled': True
}
],
'forcedVariations': {},
'id': '11564051718'
},
{
'id': '1323241597',
'key': 'typed_audience_experiment',
'layerId': '1630555627',
'status': 'Running',
'variations': [
{
'id': '1423767503',
'key': 'A',
'variables': []
}
],
'trafficAllocation': [
{
'entityId': '1423767503',
'endOfRange': 10000
}
],
'audienceIds': ['3468206642', '3988293898', '3988293899', '3468206646',
'3468206647', '3468206644', '3468206643'],
'forcedVariations': {}
}
],
'audiences': [
{
'id': '3468206642',
'name': 'exactString',
'conditions': '["and", ["or", ["or", {"name": "house", "type": "custom_attribute", "value": "Gryffindor"}]]]'
},
{
'id': '3988293898',
'name': '$$dummySubstringString',
'conditions': '{ "type": "custom_attribute", "name": "$opt_dummy_attribute", "value": "impossible_value" }'
},
{
'id': '3988293899',
'name': '$$dummyExists',
'conditions': '{ "type": "custom_attribute", "name": "$opt_dummy_attribute", "value": "impossible_value" }'
},
{
'id': '3468206646',
'name': '$$dummyExactNumber',
'conditions': '{ "type": "custom_attribute", "name": "$opt_dummy_attribute", "value": "impossible_value" }'
},
{
'id': '3468206647',
'name': '$$dummyGtNumber',
'conditions': '{ "type": "custom_attribute", "name": "$opt_dummy_attribute", "value": "impossible_value" }'
},
{
'id': '3468206644',
'name': '$$dummyLtNumber',
'conditions': '{ "type": "custom_attribute", "name": "$opt_dummy_attribute", "value": "impossible_value" }'
},
{
'id': '3468206643',
'name': '$$dummyExactBoolean',
'conditions': '{ "type": "custom_attribute", "name": "$opt_dummy_attribute", "value": "impossible_value" }'
}
],
'typedAudiences': [
{
'id': '3988293898',
'name': 'substringString',
'conditions': ['and', ['or', ['or', {'name': 'house', 'type': 'custom_attribute',
'match': 'substring', 'value': 'Slytherin'}]]]
},
{
'id': '3988293899',
'name': 'exists',
'conditions': ['and', ['or', ['or', {'name': 'favorite_ice_cream', 'type': 'custom_attribute',
'match': 'exists'}]]]
},
{
'id': '3468206646',
'name': 'exactNumber',
'conditions': ['and', ['or', ['or', {'name': 'lasers', 'type': 'custom_attribute',
'match': 'exact', 'value': 45.5}]]]
},
{
'id': '3468206647',
'name': 'gtNumber',
'conditions': ['and', ['or', ['or', {'name': 'lasers', 'type': 'custom_attribute',
'match': 'gt', 'value': 70}]]]
},
{
'id': '3468206644',
'name': 'ltNumber',
'conditions': ['and', ['or', ['or', {'name': 'lasers', 'type': 'custom_attribute',
'match': 'lt', 'value': 1.0}]]]
},
{
'id': '3468206643',
'name': 'exactBoolean',
'conditions': ['and', ['or', ['or', {'name': 'should_do_it', 'type': 'custom_attribute',
'match': 'exact', 'value': True}]]]
}
],
'groups': [],
'attributes': [
{
'key': 'house',
'id': '594015'
},
{
'key': 'lasers',
'id': '594016'
},
{
'key': 'should_do_it',
'id': '594017'
},
{
'key': 'favorite_ice_cream',
'id': '594018'
}
],
'botFiltering': False,
'accountId': '4879520872',
'events': [
{
'key': 'item_bought',
'id': '594089',
'experimentIds': [
'11564051718',
'1323241597'
]
}
],
'revision': '3'
}

config = getattr(self, config_dict)
self.optimizely = optimizely.Optimizely(json.dumps(config))
self.project_config = self.optimizely.config
23 changes: 18 additions & 5 deletions tests/helpers_tests/test_audience.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,27 @@ def test_is_user_in_experiment__no_audience(self):
self.assertTrue(audience.is_user_in_experiment(self.project_config, experiment, user_attributes))

def test_is_user_in_experiment__no_attributes(self):
""" Test that is_user_in_experiment returns True when experiment is using no audience. """
""" Test that is_user_in_experiment defaults attributes to empty Dict and
is_match does get called with empty attributes. """

with mock.patch('optimizely.helpers.audience.is_match') as mock_is_match:
audience.is_user_in_experiment(
self.project_config,
self.project_config.get_experiment_from_key('test_experiment'), None
)

self.assertFalse(audience.is_user_in_experiment(
self.project_config, self.project_config.get_experiment_from_key('test_experiment'), None)
mock_is_match.assert_called_once_with(
self.optimizely.config.get_audience('11154'), {}
)

self.assertFalse(audience.is_user_in_experiment(
self.project_config, self.project_config.get_experiment_from_key('test_experiment'), {})
with mock.patch('optimizely.helpers.audience.is_match') as mock_is_match:
audience.is_user_in_experiment(
self.project_config,
self.project_config.get_experiment_from_key('test_experiment'), {}
)

mock_is_match.assert_called_once_with(
self.optimizely.config.get_audience('11154'), {}
)

def test_is_user_in_experiment__audience_conditions_are_met(self):
Expand Down
37 changes: 37 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,43 @@ def test_get_audience__invalid_id(self):

self.assertIsNone(self.project_config.get_audience('42'))

def test_get_audience__prefers_typedAudiences_over_audiences(self):
opt = optimizely.Optimizely(json.dumps(self.config_dict_with_typed_audiences))
config = opt.config

audiences = self.config_dict_with_typed_audiences['audiences']
typed_audiences = self.config_dict_with_typed_audiences['typedAudiences']

audience_3988293898 = {
'id': '3988293898',
'name': '$$dummySubstringString',
'conditions': '{ "type": "custom_attribute", "name": "$opt_dummy_attribute", "value": "impossible_value" }'
}

self.assertTrue(audience_3988293898 in audiences)

typed_audience_3988293898 = {
'id': '3988293898',
'name': 'substringString',
'conditions': ['and', ['or', ['or', {'name': 'house', 'type': 'custom_attribute',
'match': 'substring', 'value': 'Slytherin'}]]]
}

self.assertTrue(typed_audience_3988293898 in typed_audiences)

audience = config.get_audience('3988293898')

self.assertEqual('3988293898', audience.id)
self.assertEqual('substringString', audience.name)

# compare parsed JSON as conditions for typedAudiences is generated via json.dumps
# which can be different for python versions.
self.assertEqual(json.loads(
'["and", ["or", ["or", {"match": "substring", "type": "custom_attribute",'
' "name": "house", "value": "Slytherin"}]]]'),
json.loads(audience.conditions)
)

def test_get_variation_from_key__valid_experiment_key(self):
""" Test that variation is retrieved correctly when valid experiment key and variation key are provided. """

Expand Down
Loading