diff --git a/optimizely/helpers/condition.py b/optimizely/helpers/condition.py index 0676aecb..713d7ca6 100644 --- a/optimizely/helpers/condition.py +++ b/optimizely/helpers/condition.py @@ -32,6 +32,11 @@ class ConditionMatchTypes(object): GREATER_THAN = 'gt' LESS_THAN = 'lt' SUBSTRING = 'substring' + SEMVER_EQ = 'semver_eq' + SEMVER_GT = 'semver_gt' + SEMVER_LT = 'semver_lt' + SEMVER_GE = 'semver_ge' + SEMVER_LE = 'semver_le' class CustomAttributeConditionEvaluator(object): @@ -233,12 +238,75 @@ def substring_evaluator(self, index): return condition_value in user_value + def semver_equal_evaluator(self, index): + + condition_name = self.condition_data[index][0] + condition_value = self.condition_data[index][1] + user_value = self.attributes.get(condition_name) + + return self.compare_user_version_with_target_version(user_value, condition_value) is 0 + + def semver_greater_than_evaluator(self, index): + + condition_name = self.condition_data[index][0] + condition_value = self.condition_data[index][1] + user_value = self.attributes.get(condition_name) + + return self.compare_user_version_with_target_version(user_value, condition_value) is 1 + + def semver_less_than_evaluator(self, index): + + condition_name = self.condition_data[index][0] + condition_value = self.condition_data[index][1] + user_value = self.attributes.get(condition_name) + + return self.compare_user_version_with_target_version(user_value, condition_value) is -1 + + def semver_less_than_and_equal_evaluator(self, index): + return self.semver_less_than_evaluator(index) is True or self.semver_equal_evaluator(index) is True + + def semver_greater_than_and_equal_evaluator(self, index): + return self.semver_greater_than_evaluator(index) is True or self.semver_equal_evaluator(index) is True + + def compare_user_version_with_target_version(self, user_version, target_version): + + condition_version_parts = target_version.split(".") + user_version_parts = user_version.split(".") + + condition_version_parts_len = len(condition_version_parts) + user_version_parts_len = len(user_version_parts) + + # fill smaller version with 0s + if condition_version_parts_len > user_version_parts_len: + for i in range(user_version_parts_len, condition_version_parts_len): + user_version_parts.append("0") + elif user_version_parts_len > condition_version_parts_len: + for i in range(condition_version_parts_len, user_version_parts_len): + condition_version_parts.append("0") + + for (idx, _) in enumerate(condition_version_parts): + # compare strings e.g: n1.n2.n3-alpha/beta + if not user_version_parts[idx].isnumeric(): + if user_version_parts[idx] != condition_version_parts[idx]: + return -1 + # compare numbers e.g: n1.n2.n3 + elif int(user_version_parts[idx]) > int(condition_version_parts[idx]): + return 1 + elif int(user_version_parts[idx]) < int(condition_version_parts[idx]): + return -1 + return 0 + EVALUATORS_BY_MATCH_TYPE = { ConditionMatchTypes.EXACT: exact_evaluator, ConditionMatchTypes.EXISTS: exists_evaluator, ConditionMatchTypes.GREATER_THAN: greater_than_evaluator, ConditionMatchTypes.LESS_THAN: less_than_evaluator, ConditionMatchTypes.SUBSTRING: substring_evaluator, + ConditionMatchTypes.SEMVER_EQ: semver_equal_evaluator, + ConditionMatchTypes.SEMVER_GT: semver_greater_than_evaluator, + ConditionMatchTypes.SEMVER_LT: semver_less_than_evaluator, + ConditionMatchTypes.SEMVER_LE: semver_less_than_and_equal_evaluator, + ConditionMatchTypes.SEMVER_GE: semver_greater_than_and_equal_evaluator } def evaluate(self, index): diff --git a/tests/base.py b/tests/base.py index 432d5287..0e3f3995 100644 --- a/tests/base.py +++ b/tests/base.py @@ -518,6 +518,7 @@ def setUp(self, config_dict='config_dict'): '3468206647', '3468206644', '3468206643', + '18278344267' ], 'variations': [ {'variables': [], 'id': '11557362669', 'key': '11557362669', 'featureEnabled': True} @@ -556,7 +557,7 @@ def setUp(self, config_dict='config_dict'): 'audienceConditions': [ 'and', ['or', '3468206642', '3988293898'], - ['or', '3988293899', '3468206646', '3468206647', '3468206644', '3468206643'], + ['or', '3988293899', '3468206646', '3468206647', '3468206644', '3468206643', '18278344267'], ], 'variations': [ {'variables': [], 'id': '11557362670', 'key': '11557362670', 'featureEnabled': True} @@ -626,6 +627,7 @@ def setUp(self, config_dict='config_dict'): '3468206647', '3468206644', '3468206643', + '18278344267' ], 'variations': [ { @@ -653,6 +655,7 @@ def setUp(self, config_dict='config_dict'): '3468206647', '3468206644', '3468206643', + '18278344267' ], 'forcedVariations': {}, }, @@ -667,7 +670,7 @@ def setUp(self, config_dict='config_dict'): 'audienceConditions': [ 'and', ['or', '3468206642', '3988293898'], - ['or', '3988293899', '3468206646', '3468206647', '3468206644', '3468206643'], + ['or', '3988293899', '3468206646', '3468206647', '3468206644', '3468206643', '18278344267'], ], 'forcedVariations': {}, }, @@ -689,7 +692,7 @@ def setUp(self, config_dict='config_dict'): 'audienceConditions': [ 'and', ['or', '3468206642', '3988293898'], - ['or', '3988293899', '3468206646', '3468206647', '3468206644', '3468206643'], + ['or', '3988293899', '3468206646', '3468206647', '3468206644', '3468206643', '18278344267'], ], 'forcedVariations': {}, }, @@ -837,6 +840,37 @@ def setUp(self, config_dict='config_dict'): ], ], }, + { + "id": "18278344267", + "name": "semver_audience", + "conditions": [ + "and", + [ + "or", + [ + "or", + { + "value": "1.2.3", + "type": "custom_attribute", + "name": "Android", + "match": "semver_lt" + } + ] + ], + [ + "or", + [ + "or", + { + "value": "1.0.0", + "type": "custom_attribute", + "name": "Android", + "match": "semver_gt" + } + ] + ] + ] + } ], 'groups': [], 'attributes': [ @@ -844,6 +878,8 @@ def setUp(self, config_dict='config_dict'): {'key': 'lasers', 'id': '594016'}, {'key': 'should_do_it', 'id': '594017'}, {'key': 'favorite_ice_cream', 'id': '594018'}, + {'key': 'Android', 'id': '594019'}, + ], 'botFiltering': False, 'accountId': '4879520872', diff --git a/tests/helpers_tests/test_condition.py b/tests/helpers_tests/test_condition.py index b4dee368..2261f803 100644 --- a/tests/helpers_tests/test_condition.py +++ b/tests/helpers_tests/test_condition.py @@ -24,6 +24,18 @@ integerCondition = ['num_users', 10, 'custom_attribute', 'exact'] doubleCondition = ['pi_value', 3.14, 'custom_attribute', 'exact'] +eq_semver_condition_list_variation_1 = [['Android', "2.0", 'custom_attribute', 'semver_eq']] +eq_semver_condition_list_variation_2 = [['Android', "2.0.1-beta.0", 'custom_attribute', 'semver_eq']] +eq_semver_condition_list_variation_3 = [['Android', "2.0.0", 'custom_attribute', 'semver_eq']] +eq_semver_condition_list_variation_4 = [['Android', "2.0.1-beta", 'custom_attribute', 'semver_eq']] + +lt_semver_condition_list = [['Android', "2.0", 'custom_attribute', 'semver_lt']] +gt_semver_condition_list = [['Android', "2.0.0", 'custom_attribute', 'semver_gt']] +ge_semver_condition_list_variation_1 = [['Android', "2.0.9", 'custom_attribute', 'semver_ge']] +ge_semver_condition_list_variation_2 = [['Android', "2.0.9-beta", 'custom_attribute', 'semver_ge']] +le_semver_condition_list_variation_1 = [['Android', "2.0.1", 'custom_attribute', 'semver_le']] +le_semver_condition_list_variation_2 = [['Android', "2.0.1-apha", 'custom_attribute', 'semver_le']] + exists_condition_list = [['input_value', None, 'custom_attribute', 'exists']] exact_string_condition_list = [['favorite_constellation', 'Lacerta', 'custom_attribute', 'exact']] exact_int_condition_list = [['lasers_count', 9000, 'custom_attribute', 'exact']] @@ -108,6 +120,128 @@ def test_evaluate__returns_null__when_condition_has_an_invalid_type_property(sel self.assertIsNone(evaluator.evaluate(0)) + def test_evaluate__returns_true__when_audience_version_matches_condition_version(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + eq_semver_condition_list_variation_1, {'Android': '2.0.0'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + eq_semver_condition_list_variation_2, {'Android': '2.0.1-beta'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + eq_semver_condition_list_variation_3, {'Android': '2.0'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + eq_semver_condition_list_variation_4, {'Android': '2.0.1-beta.0.0'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_audience_version_does_not_match_condition_version(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + eq_semver_condition_list_variation_1, {'Android': '2.1.1'}, self.mock_client_logger + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_audience_version_is_gt_condition_version(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + gt_semver_condition_list, {'Android': '2.1.1'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_audience_version_is_not_gt_condition_version(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + gt_semver_condition_list, {'Android': '1.1.1'}, self.mock_client_logger + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_audience_version_is_lt_condition_version(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + lt_semver_condition_list, {'Android': '1.9.1'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_audience_version_is_not_lt_condition_version(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + lt_semver_condition_list, {'Android': '2.9.1'}, self.mock_client_logger + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_audience_version_is_ge_condition_version(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_semver_condition_list_variation_1, {'Android': '2.0.9'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_semver_condition_list_variation_1, {'Android': '2.9'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_semver_condition_list_variation_1, {'Android': '2.0.9-beta'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_audience_version_is_not_ge_condition_version(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + ge_semver_condition_list_variation_1, {'Android': '1.0.0'}, self.mock_client_logger + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_audience_version_is_le_condition_version(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_semver_condition_list_variation_1, {'Android': '2.0.1'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_semver_condition_list_variation_1, {'Android': '1.1'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_semver_condition_list_variation_2, {'Android': '2.0.1-beta'}, self.mock_client_logger + ) + + self.assertStrictTrue(evaluator.evaluate(0)) + + def test_evaluate__returns_true__when_audience_version_is_not_le_condition_version(self): + + evaluator = condition_helper.CustomAttributeConditionEvaluator( + le_semver_condition_list_variation_1, {'Android': '3.0.1'}, self.mock_client_logger + ) + + self.assertStrictFalse(evaluator.evaluate(0)) + def test_exists__returns_false__when_no_user_provided_value(self): evaluator = condition_helper.CustomAttributeConditionEvaluator( diff --git a/tests/test_optimizely.py b/tests/test_optimizely.py index 94783a7a..713ac2fe 100644 --- a/tests/test_optimizely.py +++ b/tests/test_optimizely.py @@ -844,6 +844,48 @@ def test_activate__with_attributes__typed_audience_match(self): self.assertTrue(expected_attr in [x.__dict__ for x in mock_process.call_args[0][0].visitor_attributes]) + def test_activate__with_attributes__typed_audience_with_semver_match(self): + """ Test that activate calls process with right params and returns expected + variation when attributes are provided and typed audience conditions are met. """ + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_typed_audiences)) + + with mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process: + # Should be included via exact match string audience with id '18278344267' + self.assertEqual( + 'A', opt_obj.activate('typed_audience_experiment', 'test_user', {'Android': '1.0.1'}), + ) + expected_attr = { + 'type': 'custom', + 'value': '1.0.1', + 'entity_id': '594019', + 'key': 'Android', + } + + self.assertTrue(expected_attr in [x.__dict__ for x in mock_process.call_args[0][0].visitor_attributes]) + + mock_process.reset() + + with mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process: + self.assertEqual( + 'A', opt_obj.activate('typed_audience_experiment', 'test_user', {'Android': "1.2.2"}), + ) + expected_attr = { + 'type': 'custom', + 'value': "1.2.2", + 'entity_id': '594019', + 'key': 'Android', + } + + self.assertTrue(expected_attr in [x.__dict__ for x in mock_process.call_args[0][0].visitor_attributes]) + + def test_activate__with_attributes__typed_audience_with_semver_mismatch(self): + """ Test that activate returns None when typed audience conditions do not match. """ + opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_typed_audiences)) + + with mock.patch('optimizely.event.event_processor.ForwardingEventProcessor.process') as mock_process: + self.assertIsNone(opt_obj.activate('typed_audience_experiment', 'test_user', {'Android': '1.2.9'})) + self.assertEqual(0, mock_process.call_count) + def test_activate__with_attributes__typed_audience_mismatch(self): """ Test that activate returns None when typed audience conditions do not match. """ opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_typed_audiences))