Skip to content

Commit

Permalink
Client support for custom stickiness (#143)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
ivanklee86 committed Mar 25, 2021
1 parent f85ca07 commit 5e3686c
Show file tree
Hide file tree
Showing 9 changed files with 578 additions and 11 deletions.
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion UnleashClient/strategies/FlexibleRolloutStrategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 15 additions & 8 deletions UnleashClient/variants/Variants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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']
Expand Down
3 changes: 3 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
165 changes: 165 additions & 0 deletions tests/specification_tests/test_11_strategy_constraints_edge_cases.py
Original file line number Diff line number Diff line change
@@ -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", {})

0 comments on commit 5e3686c

Please sign in to comment.