Repository: Unleash/unleash-client-python
Pull request #282 (2023-10-30 10:43:39)

fix: update variants to use a seed value for their hash
This forces the variant distribution to use a seeded hash, rather than falling back to the default of 0.

This fixes an issue in the variant distribution when gradual rollout is at play, since that narrows down the potential buckets before variants kick in. Previously, we'd see very non random distributions of variants in those cases. This change allows for a more statistically sane distribution

coveralls: 
[![Coverage Status](https://coveralls.io/builds/63620745/badge)](https://coveralls.io/builds/63620745)

coverage: 96.684% (+0.003%) from 96.681% when pulling **e5b63b41eaa608c1e949207042ef52bbd16ca2c8 on fix/variant-hashing** into **4285bb5872c4d1cc6891a800642f92bd4d5e9674 on main**.


Patch:
diff --git a/UnleashClient/constants.py b/UnleashClient/constants.py
index 9401ddf..8d263b4 100644
--- a/UnleashClient/constants.py
+++ b/UnleashClient/constants.py
@@ -6,7 +6,7 @@ SDK_VERSION = version("UnleashClient")
 REQUEST_TIMEOUT = 30
 REQUEST_RETRIES = 3
 METRIC_LAST_SENT_TIME = "mlst"
-CLIENT_SPEC_VERSION = "4.5.0"
+CLIENT_SPEC_VERSION = "5.0.2"
 
 # =Unleash=
 APPLICATION_HEADERS = {
diff --git a/UnleashClient/utils.py b/UnleashClient/utils.py
index 51f1265..2dc403e 100644
--- a/UnleashClient/utils.py
+++ b/UnleashClient/utils.py
@@ -40,9 +40,13 @@ class InstanceCounter:
 
 
 def normalized_hash(
-    identifier: str, activation_group: str, normalizer: int = 100
+    identifier: str, activation_group: str, normalizer: int = 100, seed: int = 0
 ) -> int:
-    return mmh3.hash(f"{activation_group}:{identifier}", signed=False) % normalizer + 1
+    return (
+        mmh3.hash(f"{activation_group}:{identifier}", signed=False, seed=seed)
+        % normalizer
+        + 1
+    )
 
 
 def get_identifier(context_key_name: str, context: dict) -> Any:
diff --git a/UnleashClient/variants/Variants.py b/UnleashClient/variants/Variants.py
index de54ef8..c5ee0bb 100644
--- a/UnleashClient/variants/Variants.py
+++ b/UnleashClient/variants/Variants.py
@@ -6,6 +6,8 @@ from typing import Dict, Optional  # noqa: F401
 from UnleashClient import utils
 from UnleashClient.constants import DISABLED_VARIATION
 
+VARIANT_HASH_SEED = 86028157
+
 
 class Variants:
     def __init__(
@@ -103,6 +105,7 @@ class Variants:
                 self._get_seed(context, stickiness_selector),
                 self.group_id,
                 total_weight,
+                seed=VARIANT_HASH_SEED,
             )
             counter = 0
             for variation in self.variants:


Test patch:
diff --git a/tests/unit_tests/strategies/test_strategy_variants.py b/tests/unit_tests/strategies/test_strategy_variants.py
index 927a4d5..95a62ac 100644
--- a/tests/unit_tests/strategies/test_strategy_variants.py
+++ b/tests/unit_tests/strategies/test_strategy_variants.py
@@ -36,8 +36,8 @@ def test_flexiblerollout_satisfies_constraints_returns_variant(strategy):
     assert result.enabled
     assert result.variant == {
         "enabled": True,
-        "name": "VarB",
-        "payload": {"type": "string", "value": "Test 2"},
+        "name": "VarC",
+        "payload": {"type": "string", "value": "Test 3"},
     }
 
 
diff --git a/tests/unit_tests/test_features.py b/tests/unit_tests/test_features.py
index c49417c..f8a8470 100644
--- a/tests/unit_tests/test_features.py
+++ b/tests/unit_tests/test_features.py
@@ -107,12 +107,12 @@ def test_select_variation_novariation(test_feature):
 def test_select_variation_variation(test_feature_variants):
     selected_variant = test_feature_variants.get_variant({"userId": "2"})
     assert selected_variant["enabled"]
-    assert selected_variant["name"] == "VarB"
+    assert selected_variant["name"] == "VarC"
 
 
 def test_variant_metrics_are_reset(test_feature_variants):
     test_feature_variants.get_variant({"userId": "2"})
-    assert test_feature_variants.variant_counts["VarB"] == 1
+    assert test_feature_variants.variant_counts["VarC"] == 1
 
     test_feature_variants.reset_stats()
     assert not test_feature_variants.variant_counts
@@ -121,7 +121,7 @@ def test_variant_metrics_are_reset(test_feature_variants):
 def test_variant_metrics_with_existing_variant(test_feature_variants):
     for iteration in range(1, 7):
         test_feature_variants.get_variant({"userId": "2"})
-        assert test_feature_variants.variant_counts["VarB"] == iteration
+        assert test_feature_variants.variant_counts["VarC"] == iteration
 
 
 def test_variant_metrics_with_disabled_feature(test_feature_variants):
@@ -149,8 +149,8 @@ def test_strategy_variant_is_returned(test_feature_strategy_variants):
 
     assert variant == {
         "enabled": True,
-        "name": "VarB",
-        "payload": {"type": "string", "value": "Test 2"},
+        "name": "VarC",
+        "payload": {"type": "string", "value": "Test 3"},
     }
 
 
diff --git a/tests/unit_tests/test_variants.py b/tests/unit_tests/test_variants.py
index 83483e5..84b7650 100644
--- a/tests/unit_tests/test_variants.py
+++ b/tests/unit_tests/test_variants.py
@@ -55,7 +55,7 @@ def test_variation_selectvariation_happypath(variations):
     variant = variations.get_variant({"userId": "2"})
     assert variant
     assert "payload" in variant
-    assert variant["name"] == "VarC"
+    assert variant["name"] == "VarA"
 
 
 def test_variation_customvariation(variations_with_stickiness):