From 5e3686c90485062ec2aeae0357c1c8b750eb416c Mon Sep 17 00:00:00 2001 From: Ivan Lee Date: Thu, 25 Mar 2021 18:16:14 -0400 Subject: [PATCH] Client support for custom stickiness (#143) * Allow custom fields in FlexibleRollout Strategy. * Allow custom fields in Variants. * Add spec tests for "11-strategy-constraints-edge-cases.json" * Add spec tests for "12-custom-stickiness.json". * Update changelog. * Add spec tests to Makefile. --- Makefile | 6 +- .../strategies/FlexibleRolloutStrategy.py | 2 +- UnleashClient/variants/Variants.py | 23 +- docs/changelog.md | 3 + ...test_11_strategy_constraints_edge_cases.py | 165 +++++++++ .../test_12_custom_stickiness.py | 315 ++++++++++++++++++ .../strategies/test_flexiblerollout.py | 9 + tests/unit_tests/test_variants.py | 27 +- tests/utilities/mocks/mock_variants.py | 39 +++ 9 files changed, 578 insertions(+), 11 deletions(-) create mode 100644 tests/specification_tests/test_11_strategy_constraints_edge_cases.py create mode 100644 tests/specification_tests/test_12_custom_stickiness.py diff --git a/Makefile b/Makefile index dae3515e..f8ebd5f1 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ PROJECT_NAME = UnleashClient #----------------------------------------------------------------------- # Rules of Rules : Grouped rules that _doathing_ #----------------------------------------------------------------------- -test: lint pytest +test: lint pytest specification-test precommit: clean generate-requirements @@ -32,6 +32,10 @@ pytest: export PYTHONPATH=${ROOT_DIR}: $$PYTHONPATH && \ py.test --flake8 --cov ${PROJECT_NAME} tests/unit_tests +specification-test: + export PYTHONPATH=${ROOT_DIR}: $$PYTHONPATH && \ + py.test tests/specification_tests + tox-osx: tox -c tox-osx.ini --parallel auto diff --git a/UnleashClient/strategies/FlexibleRolloutStrategy.py b/UnleashClient/strategies/FlexibleRolloutStrategy.py index 0055769e..a78c9fe9 100644 --- a/UnleashClient/strategies/FlexibleRolloutStrategy.py +++ b/UnleashClient/strategies/FlexibleRolloutStrategy.py @@ -25,7 +25,7 @@ def apply(self, context: dict = None) -> bool: calculated_percentage = normalized_hash(context['sessionId'], activation_group) else: calculated_percentage = self.random_hash() - elif stickiness in ['userId', 'sessionId']: + elif stickiness in context.keys(): calculated_percentage = normalized_hash(context[stickiness], activation_group) else: # This also handles the stickiness == random scenario. diff --git a/UnleashClient/variants/Variants.py b/UnleashClient/variants/Variants.py index ccf4b05b..eef7fa17 100644 --- a/UnleashClient/variants/Variants.py +++ b/UnleashClient/variants/Variants.py @@ -34,16 +34,19 @@ def _apply_overrides(self, context: dict) -> dict: return override_variant @staticmethod - def _get_seed(context: dict) -> str: + def _get_seed(context: dict, stickiness_selector: str = "default") -> str: """Grabs seed value from context.""" seed = str(random.random() * 10000) - if 'userId' in context: - seed = context['userId'] - elif 'sessionId' in context: - seed = context['sessionId'] - elif 'remoteAddress' in context: - seed = context['remoteAddress'] + if stickiness_selector == "default": + if 'userId' in context: + seed = context['userId'] + elif 'sessionId' in context: + seed = context['sessionId'] + elif 'remoteAddress' in context: + seed = context['remoteAddress'] + elif stickiness_selector in context.keys(): + seed = context[stickiness_selector] return seed @@ -53,6 +56,8 @@ def _format_variation(variation: dict) -> dict: del formatted_variation['weight'] if 'overrides' in formatted_variation: del formatted_variation['overrides'] + if 'stickiness' in formatted_variation: + del formatted_variation['stickiness'] return formatted_variation def get_variant(self, context: dict) -> dict: @@ -73,7 +78,9 @@ def get_variant(self, context: dict) -> dict: if total_weight <= 0: return fallback_variant - target = utils.normalized_hash(self._get_seed(context), self.feature_name, total_weight) + stickiness_selector = self.variants[0]['stickiness'] if 'stickiness' in self.variants[0].keys() else "default" + + target = utils.normalized_hash(self._get_seed(context, stickiness_selector), self.feature_name, total_weight) counter = 0 for variation in self.variants: counter += variation['weight'] diff --git a/docs/changelog.md b/docs/changelog.md index b1561ccb..4ea4f99b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,8 @@ ## Next version +## Next +* (Minor) Support custom stickiness for FlexibleRollout strategy and varients. + ## v4.1.0 * (Minor) Support project-based feature flag loading. diff --git a/tests/specification_tests/test_11_strategy_constraints_edge_cases.py b/tests/specification_tests/test_11_strategy_constraints_edge_cases.py new file mode 100644 index 00000000..5542320e --- /dev/null +++ b/tests/specification_tests/test_11_strategy_constraints_edge_cases.py @@ -0,0 +1,165 @@ +import uuid +import json +import pytest +import responses +from UnleashClient import UnleashClient +from UnleashClient.constants import FEATURES_URL, METRICS_URL, REGISTER_URL +from tests.utilities.testing_constants import URL, APP_NAME + + +MOCK_JSON = """ +{ + "version": 1, + "features": [ + { + "name": "Feature.constraints.no_values", + "description": "Not enabled with constraints and no values", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "environment", + "operator": "IN", + "values": [] + + } + ] + } + ] + }, + { + "name": "Feature.constraints.no_values_NOT_IN", + "description": "Is enabled with constraints and NOT_IN empty values", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [ + { + "contextName": "environment", + "operator": "NOT_IN", + "values": [] + + } + ] + } + ] + }, + { + "name": "Feature.constraints.empty", + "description": "Is enabled with empty constraints array", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {}, + "constraints": [] + } + ] + } + ] +} +""" + + +@pytest.fixture() +def unleash_client(): + unleash_client = UnleashClient(url=URL, + app_name=APP_NAME, + instance_id='pytest_%s' % uuid.uuid4()) + yield unleash_client + unleash_client.destroy() + + +@responses.activate +def test_feature_constraints_novalue_defaultenv(unleash_client): + """ + Feature.constraints.no_value should not be enabled in default environment + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("Feature.constraints.no_values", {'environment': 'default'}) + + +@responses.activate +def test_feature_constraints_novalue_emptyenv(unleash_client): + """ + Feature.constraints.no_value should not be enabled in empty environment + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("Feature.constraints.no_values", {}) + + +@responses.activate +def test_feature_constraints_novalue_defaultenv_notin(unleash_client): + """ + Feature.constraints.no_values_NOT_IN should be enabled in default environment + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("Feature.constraints.no_values_NOT_IN", {'environment': 'default'}) + + +@responses.activate +def test_feature_constraints_novalue_emptyenv_notin(unleash_client): + """ + Feature.constraints.no_values_NOT_IN should be enabled in empty environment + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("Feature.constraints.no_values_NOT_IN", {}) + + +@responses.activate +def test_feature_constraints_empty_defaultenv(unleash_client): + """ + Feature.constraints.empty should be enabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("Feature.constraints.no_values_NOT_IN", {'environment': 'default'}) + + +@responses.activate +def test_feature_constraints_empty_emptyenv(unleash_client): + """ + Feature.constraints.empty should be enabled + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("Feature.constraints.no_values_NOT_IN", {}) \ No newline at end of file diff --git a/tests/specification_tests/test_12_custom_stickiness.py b/tests/specification_tests/test_12_custom_stickiness.py new file mode 100644 index 00000000..a3928d78 --- /dev/null +++ b/tests/specification_tests/test_12_custom_stickiness.py @@ -0,0 +1,315 @@ +import uuid +import json +import pytest +import responses +from UnleashClient import UnleashClient +from UnleashClient.constants import FEATURES_URL, METRICS_URL, REGISTER_URL +from tests.utilities.testing_constants import URL, APP_NAME + + +MOCK_JSON = """ + { + "version": 1, + "features": [ + { + "name": "Feature.flexible.rollout.custom.stickiness_100", + "description": "Should support custom stickiness as option", + "enabled": true, + "strategies": [ + { + "name": "flexibleRollout", + "parameters": { + "rollout": "100", + "stickiness": "customField", + "groupId": "Feature.flexible.rollout.custom.stickiness_100" + }, + "constraints": [] + } + ], + "variants": [ + { + "name": "blue", + "weight": 25, + "stickiness": "customField", + "payload": { + "type": "string", + "value": "val1" + } + }, + { + "name": "red", + "weight": 25, + "stickiness": "customField", + "payload": { + "type": "string", + "value": "val1" + } + }, + { + "name": "green", + "weight": 25, + "stickiness": "customField", + "payload": { + "type": "string", + "value": "val1" + } + }, + { + "name": "yellow", + "weight": 25, + "stickiness": "customField", + "payload": { + "type": "string", + "value": "val1" + } + } + ] + }, + { + "name": "Feature.flexible.rollout.custom.stickiness_50", + "description": "Should support custom stickiness as option", + "enabled": true, + "strategies": [ + { + "name": "flexibleRollout", + "parameters": { + "rollout": "50", + "stickiness": "customField", + "groupId": "Feature.flexible.rollout.custom.stickiness_50" + }, + "constraints": [] + } + ] + } + ] +} +""" + + +@pytest.fixture() +def unleash_client(): + unleash_client = UnleashClient(url=URL, + app_name=APP_NAME, + instance_id='pytest_%s' % uuid.uuid4()) + yield unleash_client + unleash_client.destroy() + + +@responses.activate +def test_feature_flexiblerollout_stickiness_100(unleash_client): + """ + Feature.flexible.rollout.custom.stickiness_100 should be enabled without field defined for 100% + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("Feature.flexible.rollout.custom.stickiness_100", {'customField': 'any_value'}) + + +@responses.activate +def test_feature_flexiblerollout_stickiness_50_nocontext(unleash_client): + """ + Feature.flexible.rollout.custom.stickiness_50 should not be enabled without custom field + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("Feature.flexible.rollout.custom.stickiness_50", {}) + + +@responses.activate +def test_feature_flexiblerollout_stickiness_50_customfield_402(unleash_client): + """ + Feature.flexible.rollout.custom.stickiness_50 should not enabled without customField=402 + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert not unleash_client.is_enabled("Feature.flexible.rollout.custom.stickiness_50", {'customField': '402'}) + + +@responses.activate +def test_feature_flexiblerollout_stickiness_50_customfield_388(unleash_client): + """ + Feature.flexible.rollout.custom.stickiness_50 should be enabled for customField=388 + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("Feature.flexible.rollout.custom.stickiness_50", {'customField': '388'}) + + +@responses.activate +def test_feature_flexiblerollout_stickiness_50_customfield_39(unleash_client): + """ + Feature.flexible.rollout.custom.stickiness_50 should be enabled without customField=39 + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + assert unleash_client.is_enabled("Feature.flexible.rollout.custom.stickiness_50", {'customField': '39'}) + + +@responses.activate +def test_variant_flexiblerollout_stickiness_100_customfield_528(unleash_client): + """ + Feature.flexible.rollout.custom.stickiness_100 and customField=528 yields blue + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + context = { + 'customField': "528" + } + + expected_result = { + "name": "blue", + "payload": { + "type": "string", + "value": "val1" + }, + "enabled": True + } + + actual_result = unleash_client.get_variant("Feature.flexible.rollout.custom.stickiness_100", context) + assert actual_result == expected_result + + +@responses.activate +def test_variant_flexiblerollout_stickiness_100_customfield_16(unleash_client): + """ + Feature.flexible.rollout.custom.stickiness_100 and customField=16 yields blue + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + context = { + 'customField': "16" + } + + expected_result = { + "name": "blue", + "payload": { + "type": "string", + "value": "val1" + }, + "enabled": True + } + + actual_result = unleash_client.get_variant("Feature.flexible.rollout.custom.stickiness_100", context) + assert actual_result == expected_result + + +@responses.activate +def test_variant_flexiblerollout_stickiness_100_customfield_198(unleash_client): + """ + Feature.flexible.rollout.custom.stickiness_100 and customField=198 yields red + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + context = { + 'customField': "198" + } + + expected_result = { + "name": "red", + "payload": { + "type": "string", + "value": "val1" + }, + "enabled": True + } + + actual_result = unleash_client.get_variant("Feature.flexible.rollout.custom.stickiness_100", context) + assert actual_result == expected_result + + +@responses.activate +def test_variant_flexiblerollout_stickiness_100_customfield_43(unleash_client): + """ + Feature.flexible.rollout.custom.stickiness_100 and customField=43 yields green + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + context = { + 'customField': "43" + } + + expected_result = { + "name": "green", + "payload": { + "type": "string", + "value": "val1" + }, + "enabled": True + } + + actual_result = unleash_client.get_variant("Feature.flexible.rollout.custom.stickiness_100", context) + assert actual_result == expected_result + + +@responses.activate +def test_variant_flexiblerollout_stickiness_100_customfield_112(unleash_client): + """ + Feature.flexible.rollout.custom.stickiness_100 and customField=112 yields yellow + """ + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + + # Tests + unleash_client.initialize_client() + context = { + 'customField': "112" + } + + expected_result = { + "name": "yellow", + "payload": { + "type": "string", + "value": "val1" + }, + "enabled": True + } + + actual_result = unleash_client.get_variant("Feature.flexible.rollout.custom.stickiness_100", context) + assert actual_result == expected_result diff --git a/tests/unit_tests/strategies/test_flexiblerollout.py b/tests/unit_tests/strategies/test_flexiblerollout.py index 434e56fe..834ebdce 100644 --- a/tests/unit_tests/strategies/test_flexiblerollout.py +++ b/tests/unit_tests/strategies/test_flexiblerollout.py @@ -92,6 +92,15 @@ def test_flexiblerollout_random(strategy): assert strategy.execute(base_context) in [True, False] +def test_flexiblerollout_customfield(strategy): + BASE_FLEXIBLE_ROLLOUT_DICT['parameters']['stickiness'] = 'customField' + base_context = dict(appName='test', environment='prod', userId="9") + base_context['customField'] = "122" + assert strategy.execute(base_context) + base_context['customField'] = "155" + assert not strategy.execute(base_context) + + def test_flexiblerollout_default(): BASE_FLEXIBLE_ROLLOUT_DICT['parameters']['stickiness'] = 'default' BASE_FLEXIBLE_ROLLOUT_DICT['constraints'] = [x for x in BASE_FLEXIBLE_ROLLOUT_DICT['constraints'] if x['contextName'] != 'userId'] diff --git a/tests/unit_tests/test_variants.py b/tests/unit_tests/test_variants.py index c23c18e5..a0a5a320 100644 --- a/tests/unit_tests/test_variants.py +++ b/tests/unit_tests/test_variants.py @@ -1,6 +1,6 @@ import pytest from UnleashClient.variants import Variants -from tests.utilities.mocks.mock_variants import VARIANTS +from tests.utilities.mocks.mock_variants import VARIANTS, VARIANTS_WITH_STICKINESS @pytest.fixture() @@ -8,6 +8,11 @@ def variations(): yield Variants(VARIANTS, "TestFeature") +@pytest.fixture() +def variations_with_stickiness(): + yield Variants(VARIANTS_WITH_STICKINESS, "TestFeature") + + def test_variations_override_match(variations): override_variant = variations._apply_overrides({'userId': '1'}) assert override_variant['name'] == 'VarA' @@ -37,6 +42,18 @@ def test_variations_seed(variations): assert context['remoteAddress'] == variations._get_seed(context) +def test_variations_seed_override(variations): + # UserId, SessionId, and remoteAddress + context = { + 'userId': '1', + 'sessionId': '1', + 'remoteAddress': '1.1.1.1', + 'customField': "ActuallyAmAHamster" + } + + assert context['customField'] == variations._get_seed(context, 'customField') + + def test_variation_selectvariation_happypath(variations): variant = variations.get_variant({'userId': '2'}) assert variant @@ -44,6 +61,14 @@ def test_variation_selectvariation_happypath(variations): assert variant['name'] == 'VarC' +def test_variation_customvariation(variations_with_stickiness): + variations = variations_with_stickiness + variant = variations.get_variant({'customField': 'ActuallyAmAHamster1234'}) + assert variant + assert 'payload' in variant + assert variant['name'] == 'VarC' + + def test_variation_selectvariation_multi(variations): tracker = {} for x in range(100): diff --git a/tests/utilities/mocks/mock_variants.py b/tests/utilities/mocks/mock_variants.py index 990ff1ae..2c0ab2c3 100644 --- a/tests/utilities/mocks/mock_variants.py +++ b/tests/utilities/mocks/mock_variants.py @@ -33,3 +33,42 @@ } } ] + +VARIANTS_WITH_STICKINESS = \ + [ + { + "name": "VarA", + "weight": 34, + "stickiness": "customField", + "payload": { + "type": "string", + "value": "Test1" + }, + "overrides": [ + { + "contextName": "userId", + "values": [ + "1" + ] + } + ] + }, + { + "name": "VarB", + "weight": 33, + "stickiness": "customField", + "payload": { + "type": "string", + "value": "Test 2" + } + }, + { + "name": "VarC", + "weight": 33, +"stickiness": "customField", + "payload": { + "type": "string", + "value": "Test 3" + } + } + ]