Skip to content

Commit b76340c

Browse files
Alda/forced bucketing (#71)
* First pass at the forced bucketing feature. * First pass the forced bucketing unit tests. * Fixed the unit tests. * Fixed lint errors. * Responded to PR feeback. * Responded to PR feeback. This file got left out from the previous commit. * Code reduction per Ali. * Fixed all of Ali's nits in the PR. * Fixed lint error.
1 parent 2f0fa68 commit b76340c

File tree

5 files changed

+431
-33
lines changed

5 files changed

+431
-33
lines changed

optimizely/decision_service.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ def get_variation(self, experiment, user_id, attributes, ignore_user_profile=Fal
100100
self.logger.log(enums.LogLevels.INFO, 'Experiment "%s" is not running.' % experiment.key)
101101
return None
102102

103+
# Check if the user is forced into a variation
104+
variation = self.config.get_forced_variation(experiment.key, user_id)
105+
if variation:
106+
return variation
107+
103108
# Check to see if user is white-listed for a certain variation
104109
variation = self.get_forced_variation(experiment, user_id)
105110
if variation:

optimizely/optimizely.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,16 +92,16 @@ def _validate_instantiation_options(self, datafile, skip_json_validation):
9292
"""
9393

9494
if not skip_json_validation and not validator.is_datafile_valid(datafile):
95-
raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT_ERROR.format('datafile'))
95+
raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT_ERROR.format('datafile'))
9696

9797
if not validator.is_event_dispatcher_valid(self.event_dispatcher):
98-
raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT_ERROR.format('event_dispatcher'))
98+
raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT_ERROR.format('event_dispatcher'))
9999

100100
if not validator.is_logger_valid(self.logger):
101-
raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT_ERROR.format('logger'))
101+
raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT_ERROR.format('logger'))
102102

103103
if not validator.is_error_handler_valid(self.error_handler):
104-
raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT_ERROR.format('error_handler'))
104+
raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT_ERROR.format('error_handler'))
105105

106106
def _validate_user_inputs(self, attributes=None, event_tags=None):
107107
""" Helper method to validate user inputs.
@@ -261,6 +261,7 @@ def get_variation(self, experiment_key, user_id, attributes=None):
261261
return None
262262

263263
experiment = self.config.get_experiment_from_key(experiment_key)
264+
264265
if not experiment:
265266
self.logger.log(enums.LogLevels.INFO,
266267
'Experiment key "%s" is invalid. Not activating user "%s".' % (experiment_key,
@@ -325,3 +326,32 @@ def get_enabled_features(self, user_id, attributes=None):
325326
enabled_features.append(feature.key)
326327

327328
return enabled_features
329+
330+
def set_forced_variation(self, experiment_key, user_id, variation_key):
331+
""" Force a user into a variation for a given experiment.
332+
333+
Args:
334+
experiment_key: A string key identifying the experiment.
335+
user_id: The user ID.
336+
variation_key: A string variation key that specifies the variation which the user.
337+
will be forced into. If null, then clear the existing experiment-to-variation mapping.
338+
339+
Returns:
340+
A boolean value that indicates if the set completed successfully.
341+
"""
342+
343+
return self.config.set_forced_variation(experiment_key, user_id, variation_key)
344+
345+
def get_forced_variation(self, experiment_key, user_id):
346+
""" Gets the forced variation for a given user and experiment.
347+
348+
Args:
349+
experiment_key: A string key identifying the experiment.
350+
user_id: The user ID.
351+
352+
Returns:
353+
The forced variation key. None if no forced variation key.
354+
"""
355+
356+
forced_variation = self.config.get_forced_variation(experiment_key, user_id)
357+
return forced_variation.key if forced_variation else None

optimizely/project_config.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ def __init__(self, datafile, logger, error_handler):
108108

109109
self.parsing_succeeded = True
110110

111+
# Map of user IDs to another map of experiments to variations.
112+
# This contains all the forced variations set by the user
113+
# by calling set_forced_variation (it is not the same as the
114+
# whitelisting forcedVariations data structure).
115+
self.forced_variation_map = {}
116+
111117
@staticmethod
112118
def _generate_key_map(list, key, entity_class):
113119
""" Helper method to generate map from key to entity object for given list of dicts.
@@ -456,3 +462,106 @@ def get_variable_for_feature(self, feature_key, variable_key):
456462
return None
457463

458464
return feature.variables.get(variable_key)
465+
466+
def set_forced_variation(self, experiment_key, user_id, variation_key):
467+
""" Sets users to a map of experiments to forced variations.
468+
469+
Args:
470+
experiment_key: Key for experiment.
471+
user_id: The user ID.
472+
variation_key: Key for variation. If None, then clear the existing experiment-to-variation mapping.
473+
474+
Returns:
475+
A boolean value that indicates if the set completed successfully.
476+
"""
477+
if not user_id:
478+
self.logger.log(enums.LogLevels.DEBUG, 'User ID is invalid.')
479+
return False
480+
481+
experiment = self.get_experiment_from_key(experiment_key)
482+
if not experiment:
483+
# The invalid experiment key will be logged inside this call.
484+
return False
485+
486+
experiment_id = experiment.id
487+
if not variation_key:
488+
if user_id in self.forced_variation_map:
489+
experiment_to_variation_map = self.forced_variation_map.get(user_id)
490+
if experiment_id in experiment_to_variation_map:
491+
del(self.forced_variation_map[user_id][experiment_id])
492+
self.logger.log(enums.LogLevels.DEBUG,
493+
'Variation mapped to experiment "%s" has been removed for user "%s".'
494+
% (experiment_key, user_id))
495+
else:
496+
self.logger.log(enums.LogLevels.DEBUG,
497+
'Nothing to remove. Variation mapped to experiment "%s" for user "%s" does not exist.'
498+
% (experiment_key, user_id))
499+
else:
500+
self.logger.log(enums.LogLevels.DEBUG,
501+
'Nothing to remove. User "%s" does not exist in the forced variation map.' % user_id)
502+
return True
503+
504+
forced_variation = self.get_variation_from_key(experiment_key, variation_key)
505+
if not forced_variation:
506+
# The invalid variation key will be logged inside this call.
507+
return False
508+
509+
variation_id = forced_variation.id
510+
511+
if user_id not in self.forced_variation_map:
512+
self.forced_variation_map[user_id] = {experiment_id: variation_id}
513+
else:
514+
self.forced_variation_map[user_id][experiment_id] = variation_id
515+
516+
self.logger.log(enums.LogLevels.DEBUG,
517+
'Set variation "%s" for experiment "%s" and user "%s" in the forced variation map.'
518+
% (variation_id, experiment_id, user_id))
519+
return True
520+
521+
def get_forced_variation(self, experiment_key, user_id):
522+
""" Gets the forced variation key for the given user and experiment.
523+
524+
Args:
525+
experiment_key: Key for experiment.
526+
user_id: The user ID.
527+
528+
Returns:
529+
The variation which the given user and experiment should be forced into.
530+
"""
531+
if not user_id:
532+
self.logger.log(enums.LogLevels.DEBUG, 'User ID is invalid.')
533+
return None
534+
535+
if user_id not in self.forced_variation_map:
536+
self.logger.log(enums.LogLevels.DEBUG, 'User "%s" is not in the forced variation map.' % user_id)
537+
return None
538+
539+
experiment = self.get_experiment_from_key(experiment_key)
540+
if not experiment:
541+
# The invalid experiment key will be logged inside this call.
542+
return None
543+
544+
experiment_to_variation_map = self.forced_variation_map.get(user_id)
545+
546+
if not experiment_to_variation_map:
547+
self.logger.log(enums.LogLevels.DEBUG,
548+
'No experiment "%s" mapped to user "%s" in the forced variation map.'
549+
% (experiment_key, user_id))
550+
return None
551+
552+
variation_id = experiment_to_variation_map.get(experiment.id)
553+
if variation_id is None:
554+
self.logger.log(enums.LogLevels.DEBUG,
555+
'No variation mapped to experiment "%s" in the forced variation map.'
556+
% experiment_key)
557+
return None
558+
559+
variation = self.get_variation_from_id(experiment_key, variation_id)
560+
if not variation:
561+
# The invalid variation ID will be logged inside this call.
562+
return None
563+
564+
self.logger.log(enums.LogLevels.DEBUG,
565+
'Variation "%s" is mapped to experiment "%s" and user "%s" in the forced variation map'
566+
% (variation.key, experiment_key, user_id))
567+
return variation

tests/test_config.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1221,6 +1221,69 @@ def test_get_variable_for_feature__invalid_variable_key(self):
12211221

12221222
self.assertIsNone(project_config.get_variable_for_feature('test_feature_1', 'invalid_variable_key'))
12231223

1224+
# get_forced_variation tests
1225+
def test_get_forced_variation__invalid_user_id(self):
1226+
""" Test invalid user IDs return a null variation. """
1227+
self.project_config.forced_variation_map['test_user'] = {}
1228+
self.project_config.forced_variation_map['test_user']['test_experiment'] = 'test_variation'
1229+
1230+
self.assertIsNone(self.project_config.get_forced_variation('test_experiment', None))
1231+
self.assertIsNone(self.project_config.get_forced_variation('test_experiment', ''))
1232+
1233+
def test_get_forced_variation__invalid_experiment_key(self):
1234+
""" Test invalid experiment keys return a null variation. """
1235+
self.project_config.forced_variation_map['test_user'] = {}
1236+
self.project_config.forced_variation_map['test_user']['test_experiment'] = 'test_variation'
1237+
1238+
self.assertIsNone(self.project_config.get_forced_variation('test_experiment', None))
1239+
self.assertIsNone(self.project_config.get_forced_variation('test_experiment', ''))
1240+
1241+
# set_forced_variation tests
1242+
def test_set_forced_variation__invalid_user_id(self):
1243+
""" Test invalid user IDs set fail to set a forced variation """
1244+
1245+
self.assertFalse(self.project_config.set_forced_variation('test_experiment', None, 'variation'))
1246+
self.assertFalse(self.project_config.set_forced_variation('test_experiment', '', 'variation'))
1247+
1248+
def test_set_forced_variation__invalid_experiment_key(self):
1249+
""" Test invalid experiment keys set fail to set a forced variation """
1250+
1251+
self.assertFalse(self.project_config.set_forced_variation('test_experiment_not_in_datafile',
1252+
'test_user', 'variation'))
1253+
self.assertFalse(self.project_config.set_forced_variation('', 'test_user', 'variation'))
1254+
self.assertFalse(self.project_config.set_forced_variation(None, 'test_user', 'variation'))
1255+
1256+
def test_set_forced_variation__invalid_variation_key(self):
1257+
""" Test invalid variation keys set fail to set a forced variation """
1258+
1259+
self.assertFalse(self.project_config.set_forced_variation('test_experiment', 'test_user',
1260+
'variation_not_in_datafile'))
1261+
self.assertTrue(self.project_config.set_forced_variation('test_experiment', 'test_user', ''))
1262+
self.assertTrue(self.project_config.set_forced_variation('test_experiment', 'test_user', None))
1263+
1264+
def test_set_forced_variation__multiple_sets(self):
1265+
""" Test multiple sets of experiments for one and multiple users work """
1266+
1267+
self.assertTrue(self.project_config.set_forced_variation('test_experiment', 'test_user_1', 'variation'))
1268+
self.assertEqual(self.project_config.get_forced_variation('test_experiment', 'test_user_1').key, 'variation')
1269+
# same user, same experiment, different variation
1270+
self.assertTrue(self.project_config.set_forced_variation('test_experiment', 'test_user_1', 'control'))
1271+
self.assertEqual(self.project_config.get_forced_variation('test_experiment', 'test_user_1').key, 'control')
1272+
# same user, different experiment
1273+
self.assertTrue(self.project_config.set_forced_variation('group_exp_1', 'test_user_1', 'group_exp_1_control'))
1274+
self.assertEqual(self.project_config.get_forced_variation('group_exp_1', 'test_user_1').key, 'group_exp_1_control')
1275+
1276+
# different user
1277+
self.assertTrue(self.project_config.set_forced_variation('test_experiment', 'test_user_2', 'variation'))
1278+
self.assertEqual(self.project_config.get_forced_variation('test_experiment', 'test_user_2').key, 'variation')
1279+
# different user, different experiment
1280+
self.assertTrue(self.project_config.set_forced_variation('group_exp_1', 'test_user_2', 'group_exp_1_control'))
1281+
self.assertEqual(self.project_config.get_forced_variation('group_exp_1', 'test_user_2').key, 'group_exp_1_control')
1282+
1283+
# make sure the first user forced variations are still valid
1284+
self.assertEqual(self.project_config.get_forced_variation('test_experiment', 'test_user_1').key, 'control')
1285+
self.assertEqual(self.project_config.get_forced_variation('group_exp_1', 'test_user_1').key, 'group_exp_1_control')
1286+
12241287

12251288
class ConfigLoggingTest(base.BaseTest):
12261289
def setUp(self):

0 commit comments

Comments
 (0)