Repository: Unleash/unleash-client-python (python)

Issue:
`UnleashClient().get_variant()` returns the same result when the feature is disabled vs enabled and there are no variants defined
**Is your feature request related to a problem? Please describe.**
It's unclear how to differentiate two cases:
- when the feature is enabled, but there are no variants defined for the feature and for the activated strategy
- when the feature is disabled

In both cases `DISABLED_VARIATION` is returned (`{"name": "disabled", "enabled": False}`).

**Describe the solution you'd like**
Could include "is_feature_enabled" field in the return value of `UnleashClient().get_variant()` and `Feature().get_variant()`

**Describe alternatives you've considered**
Now we either have to be aware if there are variants for a specific feature (and also for an activated strategy) or not or call both
`UnleashClient().get_variant()` and `UnleashClient().is_enabled()`, but that messes up the counting. 

**Additional context**
Seems that in versions before 5.8.0 the result used to be `{"name": "disabled", "enabled": True}` if the feature is _enabled_ but there is no variant.


Pull request:
feat: add is_feature_enabled to variant response
# Description

Adds a `is_feature_enabled` property to the variant response. This can be especially useful to differentiate between:
 - Feature flag that is **disabled**
 - Feature flag that is **enabled** but has **no variants**

Fixes #284

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update

# How Has This Been Tested?

Added new tests and/or adapted existing tests to assume the new behavior.

- [x] Unit tests
- [x] Spec Tests
- [x] Integration tests / Manual Tests

# Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream modules


Patch:
diff --git a/UnleashClient/constants.py b/UnleashClient/constants.py
index 8d263b4..1f8b83b 100644
--- a/UnleashClient/constants.py
+++ b/UnleashClient/constants.py
@@ -13,7 +13,7 @@ APPLICATION_HEADERS = {
     "Content-Type": "application/json",
     "Unleash-Client-Spec": CLIENT_SPEC_VERSION,
 }
-DISABLED_VARIATION = {"name": "disabled", "enabled": False}
+DISABLED_VARIATION = {"name": "disabled", "enabled": False, "feature_enabled": False}
 
 # Paths
 REGISTER_URL = "/client/register"
diff --git a/UnleashClient/features/Feature.py b/UnleashClient/features/Feature.py
index c2b8f7f..0687d17 100644
--- a/UnleashClient/features/Feature.py
+++ b/UnleashClient/features/Feature.py
@@ -1,5 +1,4 @@
 # pylint: disable=invalid-name
-import copy
 from typing import Dict, Optional, cast
 
 from UnleashClient.constants import DISABLED_VARIATION
@@ -111,14 +110,15 @@ class Feature:
                 variant = (
                     self.variants.get_variant(context, is_feature_enabled)
                     if is_feature_enabled
-                    else copy.deepcopy(DISABLED_VARIATION)
+                    else DISABLED_VARIATION
                 )
 
             except Exception as variant_exception:
                 LOGGER.warning("Error selecting variant: %s", variant_exception)
         if not skip_stats:
             self._count_variant(cast(str, variant["name"]))
-        return variant
+
+        return {**variant, "feature_enabled": is_feature_enabled}
 
     def _get_evaluation_result(
         self, context: dict = None, skip_stats: bool = False
diff --git a/UnleashClient/variants/Variants.py b/UnleashClient/variants/Variants.py
index c5ee0bb..1307f3a 100644
--- a/UnleashClient/variants/Variants.py
+++ b/UnleashClient/variants/Variants.py
@@ -84,8 +84,6 @@ class Variants:
         :param flag_status:
         :return:
         """
-        fallback_variant = copy.deepcopy(DISABLED_VARIATION)
-
         if self.variants:
             override_variant = self._apply_overrides(context)
             if override_variant:
@@ -93,7 +91,7 @@ class Variants:
 
             total_weight = sum(x["weight"] for x in self.variants)
             if total_weight <= 0:
-                return fallback_variant
+                return DISABLED_VARIATION
 
             stickiness_selector = (
                 self.variants[0]["stickiness"]
@@ -115,4 +113,4 @@ class Variants:
                     return self._format_variation(variation, flag_status)
 
         # Catch all return.
-        return fallback_variant
+        return DISABLED_VARIATION


Test patch:
diff --git a/tests/specification_tests/test_client_specs.py b/tests/specification_tests/test_client_specs.py
index 8ca0afc..9394afe 100644
--- a/tests/specification_tests/test_client_specs.py
+++ b/tests/specification_tests/test_client_specs.py
@@ -92,4 +92,8 @@ def test_spec(spec):
         expected = test_data["expectedResult"]
         context = test_data.get("context")
         variant = unleash_client.get_variant(toggle_name, context)
-        assert variant == expected
+        # Remove the feature_enabled key from the variant, as it is not part of the expected result in the spec
+        variant_without_feature_enabled = {
+            k: v for k, v in variant.items() if k != "feature_enabled"
+        }
+        assert variant_without_feature_enabled == expected
diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py
index 292ee31..c35b409 100644
--- a/tests/unit_tests/test_client.py
+++ b/tests/unit_tests/test_client.py
@@ -11,6 +11,7 @@ from blinker import signal
 
 from tests.utilities.mocks.mock_all_features import MOCK_ALL_FEATURES
 from tests.utilities.mocks.mock_features import (
+    MOCK_FEATURE_ENABLED_NO_VARIANTS_RESPONSE,
     MOCK_FEATURE_RESPONSE,
     MOCK_FEATURE_RESPONSE_PROJECT,
     MOCK_FEATURE_WITH_DEPENDENCIES_RESPONSE,
@@ -399,11 +400,36 @@ def test_uc_get_variant():
     variant = unleash_client.get_variant("testVariations", context={"userId": "2"})
     assert variant["name"] == "VarA"
     assert variant["enabled"]
+    assert variant["feature_enabled"]
 
     # If feature flag is not.
     variant = unleash_client.get_variant("testVariations", context={"userId": "3"})
     assert variant["name"] == "disabled"
     assert not variant["enabled"]
+    assert not variant["feature_enabled"]
+
+    unleash_client.destroy()
+
+
+@responses.activate
+def test_uc_get_variant_feature_enabled_no_variants():
+    cache = FileCache("MOCK_CACHE")
+    cache.bootstrap_from_dict(MOCK_FEATURE_ENABLED_NO_VARIANTS_RESPONSE)
+    unleash_client = UnleashClient(
+        url=URL,
+        app_name=APP_NAME,
+        disable_metrics=True,
+        disable_registration=True,
+        cache=cache,
+        environment="default",
+    )
+    unleash_client.initialize_client(fetch_toggles=False)
+
+    # If feature is enabled but has no variants, should return disabled variant with feature_enabled=True
+    variant = unleash_client.get_variant("EnabledNoVariants")
+    assert variant["name"] == "disabled"
+    assert not variant["enabled"]
+    assert variant["feature_enabled"]
 
     unleash_client.destroy()
 
@@ -414,6 +440,7 @@ def test_uc_not_initialized_getvariant():
     variant = unleash_client.get_variant("ThisFlagDoesn'tExist")
     assert not variant["enabled"]
     assert variant["name"] == "disabled"
+    assert not variant["feature_enabled"]
 
 
 @responses.activate
diff --git a/tests/unit_tests/test_features.py b/tests/unit_tests/test_features.py
index f8a8470..4904776 100644
--- a/tests/unit_tests/test_features.py
+++ b/tests/unit_tests/test_features.py
@@ -46,6 +46,17 @@ def test_feature_strategy_variants():
     yield Feature("My Feature", True, strategies, variants)
 
 
+@pytest.fixture()
+def test_feature_no_variants():
+    strategies = [
+        FlexibleRollout(
+            BASE_FLEXIBLE_ROLLOUT_DICT["constraints"],
+            BASE_FLEXIBLE_ROLLOUT_DICT["parameters"],
+        )
+    ]
+    yield Feature("My Feature", True, strategies)
+
+
 @pytest.fixture()
 def test_feature_dependencies():
     strategies = [Default()]
@@ -108,6 +119,7 @@ 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"] == "VarC"
+    assert selected_variant["feature_enabled"]
 
 
 def test_variant_metrics_are_reset(test_feature_variants):
@@ -151,6 +163,23 @@ def test_strategy_variant_is_returned(test_feature_strategy_variants):
         "enabled": True,
         "name": "VarC",
         "payload": {"type": "string", "value": "Test 3"},
+        "feature_enabled": True,
+    }
+
+
+def test_feature_enabled_when_no_variants(test_feature_no_variants):
+    context = {
+        "userId": "122",
+        "appName": "test",
+        "environment": "prod",
+        "customField": "1",
+    }
+    variant = test_feature_no_variants.get_variant(context)
+
+    assert variant == {
+        "enabled": False,
+        "name": "disabled",
+        "feature_enabled": True,
     }
 
 
diff --git a/tests/utilities/mocks/mock_features.py b/tests/utilities/mocks/mock_features.py
index ee61015..c6f240b 100644
--- a/tests/utilities/mocks/mock_features.py
+++ b/tests/utilities/mocks/mock_features.py
@@ -252,3 +252,22 @@ MOCK_FEATURE_WITH_DEPENDENCIES_RESPONSE = {
         },
     ],
 }
+
+MOCK_FEATURE_ENABLED_NO_VARIANTS_RESPONSE = {
+    "version": 1,
+    "features": [
+        {
+            "name": "EnabledNoVariants",
+            "description": "Enabled with no variants",
+            "enabled": True,
+            "strategies": [
+                {
+                    "name": "default",
+                    "parameters": {},
+                }
+            ],
+            "createdAt": "2018-10-09T06:04:05.667Z",
+            "impressionData": False,
+        },
+    ],
+}