Repository: Unleash/unleash-client-python
Pull request #212 (2022-06-21 06:41:40)

Feature/global segments
This adds support for reusable global segments, reference implementation in node can be viewed here: https://github.com/Unleash/unleash-client-node/pull/329

This also includes the header for turning on segments

chriswk: ![](https://github.trello.services/images/mini-trello-icon.png) [Segmentation SDK support: Python](https://trello.com/c/vDW3UZxF/1285-segmentation-sdk-support-python)
coveralls: 
[![Coverage Status](https://coveralls.io/builds/50283523/badge)](https://coveralls.io/builds/50283523)

Coverage increased (+0.07%) to 96.159% when pulling **e651cc67eb4614a00e4792875dc3d146c18a547e on feature/global-segments** into **736b13f555f5f9d852401992bfeaba148d4b7d72 on main**.

ivanklee86: Could I get a link to a document outlining Segmentation (or maybe the Trello ticket)?  Just wanna 👀 to make sure I understand what's supposed to be happening. 😄 

(Sorry, busy this week 'cause going on PTO next week.  Should offer more feedback 

Patch:
diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
index b370987..ee4816e 100644
--- a/.github/workflows/pull_request.yml
+++ b/.github/workflows/pull_request.yml
@@ -41,14 +41,14 @@ jobs:
         env:
           GITHUB_TOKEN: ${{ secrets.github_slack_token }}
           SLACK_WEBHOOK_URL: ${{ secrets.slack_webhook }}
-        if: always() 
+        if: always()
 
   tox:
     runs-on: ubuntu-latest
     strategy:
       matrix:
         os: [ubuntu-latest, macos-latest, windows-latest]
-        python: ["3.6", "3.7", "3.8", "3.9", "3.10"]
+        python: ["3.7", "3.8", "3.9", "3.10"]
     steps:
       - uses: actions/checkout@v1
       - name: Setup Python
@@ -69,4 +69,4 @@ jobs:
         env:
           GITHUB_TOKEN: ${{ secrets.github_slack_token }}
           SLACK_WEBHOOK_URL: ${{ secrets.slack_webhook }}
-        if: failure() 
+        if: failure()
diff --git a/UnleashClient/constants.py b/UnleashClient/constants.py
index 8dfed52..9bbc91f 100644
--- a/UnleashClient/constants.py
+++ b/UnleashClient/constants.py
@@ -7,7 +7,7 @@ REQUEST_TIMEOUT = 30
 METRIC_LAST_SENT_TIME = "mlst"
 
 # =Unleash=
-APPLICATION_HEADERS = {"Content-Type": "application/json"}
+APPLICATION_HEADERS = {"Content-Type": "application/json", "Unleash-Client-Spec": "4.2.0"}
 DISABLED_VARIATION = {
     'name': 'disabled',
     'enabled': False
diff --git a/UnleashClient/loader.py b/UnleashClient/loader.py
index c6575a4..7af7251 100644
--- a/UnleashClient/loader.py
+++ b/UnleashClient/loader.py
@@ -1,3 +1,4 @@
+from typing import Optional
 from UnleashClient.features.Feature import Feature
 from UnleashClient.variants.Variants import Variants
 from UnleashClient.constants import FEATURES_URL, FAILED_STRATEGIES
@@ -8,7 +9,8 @@ from UnleashClient.cache import BaseCache
 # pylint: disable=broad-except
 def _create_strategies(provisioning: dict,
                        strategy_mapping: dict,
-                       cache: BaseCache) -> list:
+                       cache: BaseCache,
+                       global_segments: Optional[dict]) -> list:
     feature_strategies = []
 
     for strategy in provisioning["strategies"]:
@@ -23,8 +25,13 @@ def _create_strategies(provisioning: dict,
             else:
                 constraint_provisioning = {}
 
+            if "segments" in strategy.keys():
+                segment_provisioning = strategy['segments']
+            else:
+                segment_provisioning = []
+
             feature_strategies.append(strategy_mapping[strategy['name']](
-                constraints=constraint_provisioning, parameters=strategy_provisioning
+                constraints=constraint_provisioning, parameters=strategy_provisioning, global_segments=global_segments, segment_ids=segment_provisioning
             ))
         except Exception as excep:
             strategies = cache.get(FAILED_STRATEGIES, [])
@@ -40,9 +47,10 @@ def _create_strategies(provisioning: dict,
 
 def _create_feature(provisioning: dict,
                     strategy_mapping: dict,
-                    cache: BaseCache) -> Feature:
+                    cache: BaseCache,
+                    global_segments: Optional[dict]) -> Feature:
     if "strategies" in provisioning.keys():
-        parsed_strategies = _create_strategies(provisioning, strategy_mapping, cache)
+        parsed_strategies = _create_strategies(provisioning, strategy_mapping, cache, global_segments)
     else:
         parsed_strategies = []
 
@@ -60,7 +68,8 @@ def _create_feature(provisioning: dict,
 
 def load_features(cache: BaseCache,
                   feature_toggles: dict,
-                  strategy_mapping: dict) -> None:
+                  strategy_mapping: dict,
+                  global_segments: Optional[dict] = None) -> None:
     """
     Caching
 
@@ -80,6 +89,12 @@ def load_features(cache: BaseCache,
     parsed_features = {}
     feature_names = [d["name"] for d in feature_provisioning["features"]]
 
+    if "segments" in feature_provisioning.keys():
+        segments = feature_provisioning["segments"]
+        global_segments = {segment["id"]: segment for segment in segments}
+    else:
+        global_segments = {}
+
     for provisioning in feature_provisioning["features"]:
         parsed_features[provisioning["name"]] = provisioning
 
@@ -95,7 +110,7 @@ def load_features(cache: BaseCache,
 
         feature_for_update.enabled = parsed_features[feature]["enabled"]
         if strategies:
-            parsed_strategies = _create_strategies(parsed_features[feature], strategy_mapping, cache)
+            parsed_strategies = _create_strategies(parsed_features[feature], strategy_mapping, cache, global_segments)
             feature_for_update.strategies = parsed_strategies
 
         if 'variants' in parsed_features[feature]:
@@ -108,4 +123,4 @@ def load_features(cache: BaseCache,
     new_features = list(set(feature_names) - set(feature_toggles.keys()))
 
     for feature in new_features:
-        feature_toggles[feature] = _create_feature(parsed_features[feature], strategy_mapping, cache)
+        feature_toggles[feature] = _create_feature(parsed_features[feature], strategy_mapping, cache, global_segments)
diff --git a/UnleashClient/strategies/Strategy.py b/UnleashClient/strategies/Strategy.py
index 04f138d..c8bb635 100644
--- a/UnleashClient/strategies/Strategy.py
+++ b/UnleashClient/strategies/Strategy.py
@@ -1,4 +1,5 @@
 # pylint: disable=invalid-name,dangerous-default-value
+from typing import Iterator
 import warnings
 from UnleashClient.constraints import Constraint
 
@@ -15,13 +16,17 @@ class Strategy:
     :param constraints: List of 'constraints' objects derived from strategy section (...from feature section) of `/api/clients/features` Unleash server response.
     :param parameters: The 'parameter' objects from the strategy section (...from feature section) of `/api/clients/features` Unleash server response.
     """
+
     def __init__(self,
                  constraints: list = [],
                  parameters: dict = {},
+                 segment_ids: list = None,
+                 global_segments: dict = None
                  ) -> None:
         self.parameters = parameters
         self.constraints = constraints
-        self.parsed_constraints = self.load_constraints(constraints)
+        self.segment_ids = segment_ids or []
+        self.global_segments = global_segments or {}
         self.parsed_provisioning = self.load_provisioning()
 
     def __call__(self, context: dict = None):
@@ -49,16 +54,15 @@ class Strategy:
 
         return flag_state
 
-    def load_constraints(self, constraints_list: list) -> list:  # pylint: disable=no-self-use
-        """
-        Loads constraints from provisioning.
-        """
-        parsed_constraints_list = []
-
-        for constraint_dict in constraints_list:
-            parsed_constraints_list.append(Constraint(constraint_dict=constraint_dict))
+    @property
+    def parsed_constraints(self) -> Iterator[Constraint]:
+        for constraint_dict in self.constraints:
+            yield Constraint(constraint_dict=constraint_dict)
 
-        return parsed_constraints_list
+        for segment_id in self.segment_ids:
+            segment = self.global_segments[segment_id]
+            for constraint in segment["constraints"]:
+                yield Constraint(constraint_dict=constraint)
 
     def load_provisioning(self) -> list:  # pylint: disable=no-self-use
         """
diff --git a/UnleashClient/variants/Variants.py b/UnleashClient/variants/Variants.py
index 4ef30dc..acc5334 100644
--- a/UnleashClient/variants/Variants.py
+++ b/UnleashClient/variants/Variants.py
@@ -79,7 +79,7 @@ class Variants:
             if override_variant:
                 return self._format_variation(override_variant)
 
-            total_weight = sum([x['weight'] for x in self.variants])
+            total_weight = sum(x['weight'] for x in self.variants)
             if total_weight <= 0:
                 return fallback_variant
 
diff --git a/docs/development.rst b/docs/development.rst
index bf01318..8ad84b5 100644
--- a/docs/development.rst
+++ b/docs/development.rst
@@ -2,7 +2,7 @@
 Development
 ****************************************
 
-Contributions welcome!  
+Contributions welcome!
 
 Here are some notes about common tools and tasks you'll run into when working on `unleash-client-python`.
 
@@ -21,7 +21,7 @@ Running Tox locally
 #######################################
 1. Install Python versions for each supported version.
 2. Deactivate your local virtualenv (if it's activated).
-3. Run ``pyenv local 3.10.X 3.9.Y 3.8.Z 3.7.12 3.6.15`` (inserting appropriate patch versions).
+3. Run ``pyenv local 3.10.X 3.9.Y 3.8.Z 3.7.12`` (inserting appropriate patch versions).
 4. Run ``make install`` to get latest local dependencies.
 5. Run ``make tox`` to run tox.
 
diff --git a/pyproject.toml b/pyproject.toml
index ca83e7f..65a16b8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,6 +15,9 @@ addopts= """
 """
 log_file_level="INFO"
 
+[tool.pylint]
+load-plugins="pylint.extensions.no_self_use"
+
 [tool.pylint.messages_control]
 disable = [
     "line-too-long",
diff --git a/setup.py b/setup.py
index 0ef84ad..a502bc3 100644
--- a/setup.py
+++ b/setup.py
@@ -19,10 +19,10 @@ setup(
     packages=find_packages(exclude=["tests*"]),
     package_data={"UnleashClient": ["py.typed"]},
     install_requires=[
-        "requests", 
-        "fcache", 
-        "mmh3", 
-        "apscheduler", 
+        "requests",
+        "fcache",
+        "mmh3",
+        "apscheduler",
         "importlib_metadata",
         "python-dateutil",
         "semver < 3.0.0"
@@ -34,7 +34,6 @@ setup(
         "Development Status :: 5 - Production/Stable",
         "Intended Audience :: Developers",
         "License :: OSI Approved :: MIT License",
-        "Programming Language :: Python :: 3.6",
         "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
diff --git a/tox.ini b/tox.ini
index cf23818..f634a1e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,6 @@
 
 [tox]
-envlist = py36,py37,py38,py39,py310
+envlist = py37,py38,py39,py310
 
 [testenv]
 deps = -rrequirements.txt


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index d556f62..d772e08 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -4,6 +4,7 @@ from datetime import datetime, timezone
 from UnleashClient.constants import FEATURES_URL, METRIC_LAST_SENT_TIME, ETAG
 from tests.utilities.mocks import MOCK_ALL_FEATURES, MOCK_CUSTOM_STRATEGY
 from UnleashClient.cache import FileCache
+from tests.utilities.mocks.mock_features import MOCK_FEATURES_WITH_SEGMENTS_RESPONSE
 
 
 @pytest.fixture()
@@ -42,3 +43,16 @@ def cache_custom():
     })
     yield temporary_cache
     temporary_cache.destroy()
+
+
+@pytest.fixture()
+def cache_segments():
+    cache_name = 'pytest_%s' % uuid.uuid4()
+    temporary_cache = FileCache(cache_name)
+    temporary_cache.mset({
+        FEATURES_URL: MOCK_FEATURES_WITH_SEGMENTS_RESPONSE,
+        METRIC_LAST_SENT_TIME: datetime.now(timezone.utc),
+        ETAG: ''
+    })
+    yield temporary_cache
+    temporary_cache.destroy()
\ No newline at end of file
diff --git a/tests/specification_tests/test_15-global-constraints.py b/tests/specification_tests/test_15-global-constraints.py
new file mode 100644
index 0000000..0714749
--- /dev/null
+++ b/tests/specification_tests/test_15-global-constraints.py
@@ -0,0 +1,241 @@
+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": 2,
+  "features": [
+    {
+      "name": "F9.globalSegmentOn",
+      "description": "With global segment referencing constraint in on state",
+      "enabled": true,
+      "strategies": [
+        {
+          "name": "default",
+          "parameters": {},
+          "segments": [1]
+        }
+      ]
+    },
+    {
+      "name": "F9.globalSegmentOff",
+      "description": "With global segment referencing constraint in off state",
+      "enabled": true,
+      "strategies": [
+        {
+          "name": "default",
+          "parameters": {},
+          "segments": [2]
+        }
+      ]
+    },
+    {
+      "name": "F9.globalSegmentAndConstraint",
+      "description": "With global segment and constraint both on",
+      "enabled": true,
+      "strategies": [
+        {
+          "name": "default",
+          "parameters": {},
+          "constraints": [
+            {
+              "contextName": "version",
+              "operator": "SEMVER_EQ",
+              "value": "1.2.2"
+            }
+          ],
+          "segments": [1]
+        }
+      ]
+    },
+    {
+      "name": "F9.withMissingSegment",
+      "description": "With global segment that doesn't exist",
+      "enabled": true,
+      "strategies": [
+        {
+          "name": "default",
+          "parameters": {},
+          "constraints": [
+            {
+              "contextName": "version",
+              "operator": "SEMVER_EQ",
+              "value": "1.2.2"
+            }
+          ],
+          "segments": [3]
+        }
+      ]
+    },
+    {
+      "name": "F9.withSeveralConstraintsAndSegments",
+      "description": "With several segments and constraints",
+      "enabled": true,
+      "strategies": [
+        {
+          "name": "default",
+          "parameters": {},
+          "constraints": [
+            {
+              "contextName": "customNumber",
+              "operator": "NUM_LT",
+              "value": "10"
+            },
+            {
+              "contextName": "version",
+              "operator": "SEMVER_LT",
+              "value": "3.2.2"
+            }
+          ],
+          "segments": [1, 4, 5]
+        }
+      ]
+    }
+  ],
+  "segments": [
+    {
+      "id": 1,
+      "constraints": [
+        {
+          "contextName": "version",
+          "operator": "SEMVER_EQ",
+          "value": "1.2.2"
+        }
+      ]
+    },
+    {
+      "id": 2,
+      "constraints": [
+        {
+          "contextName": "version",
+          "operator": "SEMVER_EQ",
+          "value": "3.1.4"
+        }
+      ]
+    },
+    {
+      "id": 3,
+      "constraints": [
+        {
+          "contextName": "version",
+          "operator": "SEMVER_EQ",
+          "value": "3.1.4"
+        }
+      ]
+    },
+    {
+      "id": 4,
+      "constraints": [
+        {
+          "contextName": "customName",
+          "operator": "STR_CONTAINS",
+          "values": ["Pi"]
+        }
+      ]
+    },
+    {
+      "id": 5,
+      "constraints": [
+        {
+          "contextName": "slicesLeft",
+          "operator": "NUM_LTE",
+          "value": "4"
+        }
+      ]
+    }
+  ]
+}
+"""
+
+
+@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_globalSegmentOff_should_be_disabled(unleash_client):
+    """
+    F9.globalSegmentOff should be disabled
+    """
+    # 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("F9.globalSegmentOff", {'version': '1.2.2'})
+
+
+@responses.activate
+def test_feature_globalSegmentOn_should_be_enabled(unleash_client):
+    """
+    F9.globalSegmentOn 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("F9.globalSegmentOn", {'version': '1.2.2'})
+
+
+@responses.activate
+def test_feature_globalSegmentAndConstraint_should_be_enabled(unleash_client):
+    """
+    F9.globalSegmentAndConstraint 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("F9.globalSegmentAndConstraint", {'version': '1.2.2'})
+
+
+@responses.activate
+def test_feature_withMissingSegment_should_force_evaluation_to_false(unleash_client):
+    """
+    F9.withMissingSegment should force evaluation to false
+    """
+    # 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("F9.withMissingSegment", {'version': '1.2.2'})
+
+
+@responses.activate
+def test_feature_withSeveralConstraintsAndSegments_should_be_enabled(unleash_client):
+    """
+    F9.withSeveralConstraintsAndSegments 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 not unleash_client.is_enabled("F9.withSeveralConstraintsAndSegments", {"version": "1.2.2",
+                                                                                  "customNumber": "3.14",
+                                                                                  "customName": "Pie",
+                                                                                  "slicesLeft": "4"})
diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py
index 2bb8e53..36a7676 100644
--- a/tests/unit_tests/test_loader.py
+++ b/tests/unit_tests/test_loader.py
@@ -31,7 +31,7 @@ def test_loader_initialization(cache_full):  # noqa: F811
             assert len(strategy.parsed_provisioning)
 
         if isinstance(strategy, FlexibleRollout):
-            len(strategy.parsed_constraints) > 0
+            len(list(strategy.parsed_constraints)) > 0
 
         if isinstance(strategy, Variants):
             assert strategy.variants
@@ -80,3 +80,14 @@ def test_loader_initialization_failure(cache_custom):  # noqa: F811
     # Tests
     load_features(temp_cache, in_memory_features, DEFAULT_STRATEGY_MAPPING)
     assert isinstance(in_memory_features["UserWithId"], Feature)
+
+
+def test_loader_segments(cache_segments):
+    # Set up variables
+    in_memory_features = {}
+    temp_cache = cache_segments
+
+    load_features(temp_cache, in_memory_features, DEFAULT_STRATEGY_MAPPING)
+    feature = in_memory_features["Test"]
+    loaded_constraints = list(feature.strategies[0].parsed_constraints)
+    assert(len(loaded_constraints) == 2)
diff --git a/tests/utilities/mocks/mock_features.py b/tests/utilities/mocks/mock_features.py
index 526c56c..62d6860 100644
--- a/tests/utilities/mocks/mock_features.py
+++ b/tests/utilities/mocks/mock_features.py
@@ -113,6 +113,72 @@ MOCK_FEATURE_RESPONSE = {
     ]
 }
 
+MOCK_FEATURES_WITH_SEGMENTS_RESPONSE = {
+    "version": 2,
+    "features": [
+        {
+            "strategies": [
+                {
+                    "name": "default",
+                    "constraints": [],
+                    "parameters": {},
+                    "segments": [
+                        1, 2
+                    ]
+                }
+            ],
+            "impressionData": False,
+            "enabled": True,
+            "name": "Test",
+            "description": "",
+            "project": "default",
+            "stale": False,
+            "type": "release",
+            "variants": []
+        }
+    ],
+    "query": {
+        "environment": "development",
+        "inlineSegmentConstraints": False
+    },
+    "segments": [
+        {
+            "id": 1,
+            "name": "TestSegment1",
+            "description": "test",
+            "constraints": [
+                {
+                    "value": "2022-06-14T06:40:17.766Z",
+                    "values": [],
+                    "inverted": False,
+                    "operator": "DATE_BEFORE",
+                    "contextName": "currentTime",
+                    "caseInsensitive": False
+                }
+            ],
+            "createdBy": "admin",
+            "createdAt": "2022-06-14T06:40:25.331Z"
+        },
+        {
+            "id": 2,
+            "name": "TestSegment2",
+            "description": "test",
+            "constraints": [
+                {
+                    "value": "2022-06-14T06:40:17.766Z",
+                    "values": [],
+                    "inverted": False,
+                    "operator": "DATE_AFTER",
+                    "contextName": "currentTime",
+                    "caseInsensitive": False
+                }
+            ],
+            "createdBy": "admin",
+            "createdAt": "2022-06-14T06:40:25.331Z"
+        }
+    ]
+}
+
 MOCK_FEATURE_RESPONSE_PROJECT = {
    "version":1,
    "features":[
@@ -125,12 +191,12 @@ MOCK_FEATURE_RESPONSE_PROJECT = {
             {
                "name":"default",
                "parameters":{
-                  
+
                }
             }
          ],
          "variants":[
-            
+
          ]
       }
    ]