Skip to content

Commit 1264f8e

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 66c3c36 commit 1264f8e

File tree

5 files changed

+432
-33
lines changed

5 files changed

+432
-33
lines changed

optimizely/decision_service.py

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

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

optimizely/optimizely.py

Lines changed: 35 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,
@@ -275,3 +276,33 @@ def get_variation(self, experiment_key, user_id, attributes=None):
275276
return variation.key
276277

277278
return None
279+
280+
def set_forced_variation(self, experiment_key, user_id, variation_key):
281+
""" Force a user into a variation for a given experiment.
282+
283+
Args:
284+
experiment_key: A string key identifying the experiment.
285+
user_id: The user ID.
286+
variation_key: A string variation key that specifies the variation which the user.
287+
will be forced into. If null, then clear the existing experiment-to-variation mapping.
288+
289+
Returns:
290+
A boolean value that indicates if the set completed successfully.
291+
"""
292+
293+
return self.config.set_forced_variation(experiment_key, user_id, variation_key)
294+
295+
def get_forced_variation(self, experiment_key, user_id):
296+
""" Gets the forced variation for a given user and experiment.
297+
298+
Args:
299+
experiment_key: A string key identifying the experiment.
300+
user_id: The user ID.
301+
302+
Returns:
303+
The forced variation key. None if no forced variation key.
304+
"""
305+
306+
forced_variation = self.config.get_forced_variation(experiment_key, user_id)
307+
return forced_variation.key if forced_variation else None
308+

optimizely/project_config.py

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

8585
self.parsing_succeeded = True
8686

87+
# Map of user IDs to another map of experiments to variations.
88+
# This contains all the forced variations set by the user
89+
# by calling set_forced_variation (it is not the same as the
90+
# whitelisting forcedVariations data structure).
91+
self.forced_variation_map = {}
92+
8793
@staticmethod
8894
def _generate_key_map(list, key, entity_class):
8995
""" Helper method to generate map from key to entity object for given list of dicts.
@@ -340,3 +346,107 @@ def get_attribute(self, attribute_key):
340346
self.logger.log(enums.LogLevels.ERROR, 'Attribute "%s" is not in datafile.' % attribute_key)
341347
self.error_handler.handle_error(exceptions.InvalidAttributeException(enums.Errors.INVALID_ATTRIBUTE_ERROR))
342348
return None
349+
350+
def set_forced_variation(self, experiment_key, user_id, variation_key):
351+
""" Sets users to a map of experiments to forced variations.
352+
353+
Args:
354+
experiment_key: Key for experiment.
355+
user_id: The user ID.
356+
variation_key: Key for variation. If None, then clear the existing experiment-to-variation mapping.
357+
358+
Returns:
359+
A boolean value that indicates if the set completed successfully.
360+
"""
361+
if not user_id:
362+
self.logger.log(enums.LogLevels.DEBUG, 'User ID is invalid.')
363+
return False
364+
365+
experiment = self.get_experiment_from_key(experiment_key)
366+
if not experiment:
367+
# The invalid experiment key will be logged inside this call.
368+
return False
369+
370+
experiment_id = experiment.id
371+
if not variation_key:
372+
if user_id in self.forced_variation_map:
373+
experiment_to_variation_map = self.forced_variation_map.get(user_id)
374+
if experiment_id in experiment_to_variation_map:
375+
del(self.forced_variation_map[user_id][experiment_id])
376+
self.logger.log(enums.LogLevels.DEBUG,
377+
'Variation mapped to experiment "%s" has been removed for user "%s".'
378+
% (experiment_key, user_id))
379+
else:
380+
self.logger.log(enums.LogLevels.DEBUG,
381+
'Nothing to remove. Variation mapped to experiment "%s" for user "%s" does not exist.'
382+
% (experiment_key, user_id))
383+
else:
384+
self.logger.log(enums.LogLevels.DEBUG,
385+
'Nothing to remove. User "%s" does not exist in the forced variation map.' % user_id)
386+
return True
387+
388+
forced_variation = self.get_variation_from_key(experiment_key, variation_key)
389+
if not forced_variation:
390+
# The invalid variation key will be logged inside this call.
391+
return False
392+
393+
variation_id = forced_variation.id
394+
395+
if user_id not in self.forced_variation_map:
396+
self.forced_variation_map[user_id] = {experiment_id: variation_id}
397+
else:
398+
self.forced_variation_map[user_id][experiment_id] = variation_id
399+
400+
self.logger.log(enums.LogLevels.DEBUG,
401+
'Set variation "%s" for experiment "%s" and user "%s" in the forced variation map.'
402+
% (variation_id, experiment_id, user_id))
403+
return True
404+
405+
def get_forced_variation(self, experiment_key, user_id):
406+
""" Gets the forced variation key for the given user and experiment.
407+
408+
Args:
409+
experiment_key: Key for experiment.
410+
user_id: The user ID.
411+
412+
Returns:
413+
The variation which the given user and experiment should be forced into.
414+
"""
415+
if not user_id:
416+
self.logger.log(enums.LogLevels.DEBUG, 'User ID is invalid.')
417+
return None
418+
419+
if user_id not in self.forced_variation_map:
420+
self.logger.log(enums.LogLevels.DEBUG, 'User "%s" is not in the forced variation map.' % user_id)
421+
return None
422+
423+
experiment = self.get_experiment_from_key(experiment_key)
424+
if not experiment:
425+
# The invalid experiment key will be logged inside this call.
426+
return None
427+
428+
experiment_to_variation_map = self.forced_variation_map.get(user_id)
429+
430+
if not experiment_to_variation_map:
431+
self.logger.log(enums.LogLevels.DEBUG,
432+
'No experiment "%s" mapped to user "%s" in the forced variation map.'
433+
% (experiment_key, user_id))
434+
return None
435+
436+
variation_id = experiment_to_variation_map.get(experiment.id)
437+
if variation_id is None:
438+
self.logger.log(enums.LogLevels.DEBUG,
439+
'No variation mapped to experiment "%s" in the forced variation map.'
440+
% experiment_key)
441+
return None
442+
443+
variation = self.get_variation_from_id(experiment_key, variation_id)
444+
if not variation:
445+
# The invalid variation ID will be logged inside this call.
446+
return None
447+
448+
self.logger.log(enums.LogLevels.DEBUG,
449+
'Variation "%s" is mapped to experiment "%s" and user "%s" in the forced variation map'
450+
% (variation.key, experiment_key, user_id))
451+
return variation
452+

tests/test_config.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,68 @@ def test_get_group__invalid_id(self):
459459

460460
self.assertIsNone(self.project_config.get_group('42'))
461461

462+
# get_forced_variation tests
463+
def test_get_forced_variation__invalid_user_id(self):
464+
""" Test invalid user IDs return a null variation. """
465+
self.project_config.forced_variation_map['test_user'] = {}
466+
self.project_config.forced_variation_map['test_user']['test_experiment'] = 'test_variation'
467+
468+
self.assertIsNone(self.project_config.get_forced_variation('test_experiment', None))
469+
self.assertIsNone(self.project_config.get_forced_variation('test_experiment', ''))
470+
471+
def test_get_forced_variation__invalid_experiment_key(self):
472+
""" Test invalid experiment keys return a null variation. """
473+
self.project_config.forced_variation_map['test_user'] = {}
474+
self.project_config.forced_variation_map['test_user']['test_experiment'] = 'test_variation'
475+
476+
self.assertIsNone(self.project_config.get_forced_variation('test_experiment', None))
477+
self.assertIsNone(self.project_config.get_forced_variation('test_experiment', ''))
478+
479+
# set_forced_variation tests
480+
def test_set_forced_variation__invalid_user_id(self):
481+
""" Test invalid user IDs set fail to set a forced variation """
482+
483+
self.assertFalse(self.project_config.set_forced_variation('test_experiment', None, 'variation'))
484+
self.assertFalse(self.project_config.set_forced_variation('test_experiment', '', 'variation'))
485+
486+
def test_set_forced_variation__invalid_experiment_key(self):
487+
""" Test invalid experiment keys set fail to set a forced variation """
488+
489+
self.assertFalse(self.project_config.set_forced_variation('test_experiment_not_in_datafile',
490+
'test_user', 'variation'))
491+
self.assertFalse(self.project_config.set_forced_variation('', 'test_user', 'variation'))
492+
self.assertFalse(self.project_config.set_forced_variation(None, 'test_user', 'variation'))
493+
494+
def test_set_forced_variation__invalid_variation_key(self):
495+
""" Test invalid variation keys set fail to set a forced variation """
496+
497+
self.assertFalse(self.project_config.set_forced_variation('test_experiment', 'test_user',
498+
'variation_not_in_datafile'))
499+
self.assertTrue(self.project_config.set_forced_variation('test_experiment', 'test_user', ''))
500+
self.assertTrue(self.project_config.set_forced_variation('test_experiment', 'test_user', None))
501+
502+
def test_set_forced_variation__multiple_sets(self):
503+
""" Test multiple sets of experiments for one and multiple users work """
504+
505+
self.assertTrue(self.project_config.set_forced_variation('test_experiment', 'test_user_1', 'variation'))
506+
self.assertEqual(self.project_config.get_forced_variation('test_experiment', 'test_user_1').key, 'variation')
507+
# same user, same experiment, different variation
508+
self.assertTrue(self.project_config.set_forced_variation('test_experiment', 'test_user_1', 'control'))
509+
self.assertEqual(self.project_config.get_forced_variation('test_experiment', 'test_user_1').key, 'control')
510+
# same user, different experiment
511+
self.assertTrue(self.project_config.set_forced_variation('group_exp_1', 'test_user_1', 'group_exp_1_control'))
512+
self.assertEqual(self.project_config.get_forced_variation('group_exp_1', 'test_user_1').key, 'group_exp_1_control')
513+
514+
# different user
515+
self.assertTrue(self.project_config.set_forced_variation('test_experiment', 'test_user_2', 'variation'))
516+
self.assertEqual(self.project_config.get_forced_variation('test_experiment', 'test_user_2').key, 'variation')
517+
# different user, different experiment
518+
self.assertTrue(self.project_config.set_forced_variation('group_exp_1', 'test_user_2', 'group_exp_1_control'))
519+
self.assertEqual(self.project_config.get_forced_variation('group_exp_1', 'test_user_2').key, 'group_exp_1_control')
520+
521+
# make sure the first user forced variations are still valid
522+
self.assertEqual(self.project_config.get_forced_variation('test_experiment', 'test_user_1').key, 'control')
523+
self.assertEqual(self.project_config.get_forced_variation('group_exp_1', 'test_user_1').key, 'group_exp_1_control')
462524

463525
class ConfigLoggingTest(base.BaseTest):
464526
def setUp(self):

0 commit comments

Comments
 (0)