├── tests ├── __init__.py ├── unit_tests │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── test_metrics.py │ │ ├── test_feature.py │ │ └── test_register.py │ ├── periodic │ │ ├── __init__.py │ │ ├── test_aggregate_and_send_metrics.py │ │ └── test_fetch_and_load.py │ ├── strategies │ │ ├── test_defaultstrategy.py │ │ ├── test_gradualrolloutrandom.py │ │ ├── test_gradualrolloutwithuserid.py │ │ ├── test_gradualrolloutwithsessionid.py │ │ ├── test_userwithids.py │ │ ├── test_applicationhostname.py │ │ ├── test_remoteaddress.py │ │ └── test_flexiblerollout.py │ ├── test_constraints.py │ ├── test_variants.py │ ├── test_features.py │ ├── test_loader.py │ ├── test_custom_strategy.py │ └── test_client.py ├── specification_tests │ ├── __init__.py │ ├── test_05_gradual_rollout_random_strategy.py │ ├── test_01_simple_examples.py │ ├── test_02_user_with_id_strategy.py │ ├── test_03_gradual_rollout_user_id_strategy.py │ ├── test_04_gradual_rollout_session_id_strategy.py │ ├── test_06_remote_address_strategy.py │ ├── test_07_multiple_strategies.py │ └── test_10_flexible_rollout.py ├── utilities │ ├── old_code │ │ ├── __init__.py │ │ └── StrategyV2.py │ ├── __init__.py │ ├── mocks │ │ ├── __init__.py │ │ ├── mock_metrics.py │ │ ├── mock_variants.py │ │ ├── mock_custom_strategy.py │ │ ├── mock_features.py │ │ └── mock_all_features.py │ ├── data_generator.py │ ├── decorators.py │ └── testing_constants.py └── integration_tests │ ├── integration.py │ ├── integration_gitlab.py │ ├── integration_unleashheroku.py │ └── integration_unleashhosted.py ├── MANIFEST.in ├── setup.config ├── pytest.ini ├── UnleashClient ├── features │ ├── __init__.py │ └── Feature.py ├── variants │ ├── __init__.py │ └── Variants.py ├── constraints │ ├── __init__.py │ └── Constraint.py ├── periodic_tasks │ ├── __init__.py │ ├── fetch_and_load.py │ └── send_metrics.py ├── api │ ├── __init__.py │ ├── metrics.py │ ├── features.py │ └── register.py ├── strategies │ ├── Default.py │ ├── GradualRolloutRandom.py │ ├── ApplicationHostname.py │ ├── GradualRolloutUserId.py │ ├── GradualRolloutSessionId.py │ ├── UserWithId.py │ ├── __init__.py │ ├── EnableForExpertStrategy.py │ ├── EnableForDomainStrategy.py │ ├── EnableForTeamStrategy.py │ ├── EnableForPartnerStrategy.py │ ├── EnableForBusinessStrategy.py │ ├── FlexibleRolloutStrategy.py │ ├── RemoteAddress.py │ └── Strategy.py ├── utils.py ├── deprecation_warnings.py ├── constants.py ├── loader.py └── __init__.py ├── requirements-package.txt ├── mypy.ini ├── .flake8 ├── docs ├── resources.md ├── strategy.md ├── customstrategies.md ├── development.md ├── index.md ├── unleashclient.md └── changelog.md ├── .bumpversion.cfg ├── tox.ini ├── mkdocs.yml ├── requirements-dev.txt ├── tox-osx.ini ├── .github ├── codecov.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── release-drafter.yml ├── PULL_REQUEST_TEMPLATE.md ├── mergeable.yml └── workflows │ ├── release.yml │ └── pull_request.yml ├── requirements.txt ├── requirements-local.txt ├── FeatureToggle ├── utils.py └── redis_utils.py ├── LICENSE.md ├── setup.py ├── Makefile ├── README.md ├── .gitignore ├── CONTRIBUTING.md ├── .circleci-archive └── config.yml └── CODE_OF_CONDUCT.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | -------------------------------------------------------------------------------- /tests/unit_tests/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/specification_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit_tests/periodic/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/utilities/old_code/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.config: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts=--flake8 --log-level DEBUG 3 | -------------------------------------------------------------------------------- /UnleashClient/features/__init__.py: -------------------------------------------------------------------------------- 1 | from .Feature import Feature 2 | -------------------------------------------------------------------------------- /UnleashClient/variants/__init__.py: -------------------------------------------------------------------------------- 1 | from .Variants import Variants 2 | -------------------------------------------------------------------------------- /requirements-package.txt: -------------------------------------------------------------------------------- 1 | requests 2 | fcache 3 | mmh3 4 | apscheduler 5 | -------------------------------------------------------------------------------- /UnleashClient/constraints/__init__.py: -------------------------------------------------------------------------------- 1 | from .Constraint import Constraint 2 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | strict_optional = False 3 | ignore_missing_imports = True -------------------------------------------------------------------------------- /tests/utilities/__init__.py: -------------------------------------------------------------------------------- 1 | from .data_generator import generate_context, generate_email_list -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E501 3 | exclude = docs/source/conf.py,build,dist,__init__.py,tests/utilities/mocks/* 4 | 5 | -------------------------------------------------------------------------------- /UnleashClient/periodic_tasks/__init__.py: -------------------------------------------------------------------------------- 1 | from .fetch_and_load import fetch_and_load_features 2 | from .send_metrics import aggregate_and_send_metrics 3 | -------------------------------------------------------------------------------- /UnleashClient/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .features import get_feature_toggles 2 | from .register import register_client 3 | from .metrics import send_metrics 4 | -------------------------------------------------------------------------------- /docs/resources.md: -------------------------------------------------------------------------------- 1 | ## Unleash 2 | 3 | * [Unleash Server](https://github.com/unleash/unleash) 4 | * [unleash-client-node](https://github.com/Unleash/unleash-client-node) 5 | * [unleash-docker](https://github.com/Unleash/unleash-docker) -------------------------------------------------------------------------------- /tests/utilities/mocks/__init__.py: -------------------------------------------------------------------------------- 1 | from .mock_metrics import MOCK_METRICS_REQUEST 2 | from .mock_all_features import MOCK_ALL_FEATURES 3 | from .mock_features import MOCK_FEATURE_RESPONSE 4 | from .mock_custom_strategy import MOCK_CUSTOM_STRATEGY 5 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 3.5.0 3 | commit = True 4 | tag = True 5 | message = Automatic version bump via bumpversion. 6 | 7 | [bumpversion:file:setup.py] 8 | 9 | [bumpversion:file:UnleashClient/constants.py] 10 | 11 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | 2 | [tox] 3 | envlist = py35,py36,py37,py38 4 | 5 | [testenv] 6 | deps = -rrequirements.txt 7 | commands = 8 | mypy UnleashClient 9 | py.test --cov UnleashClient tests/unit_tests 10 | py.test --cov UnleashClient tests/specification_tests 11 | -------------------------------------------------------------------------------- /tests/unit_tests/strategies/test_defaultstrategy.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from UnleashClient.strategies import Default 3 | 4 | 5 | @pytest.fixture() 6 | def strategy(): 7 | yield Default() 8 | 9 | 10 | def test_defaultstrategy(strategy): 11 | assert isinstance(strategy.execute(), bool) 12 | -------------------------------------------------------------------------------- /UnleashClient/strategies/Default.py: -------------------------------------------------------------------------------- 1 | from UnleashClient.strategies.Strategy import Strategy 2 | 3 | 4 | class Default(Strategy): 5 | def apply(self, context: dict = None) -> bool: 6 | """ 7 | Return true if enabled. 8 | 9 | :return: 10 | """ 11 | return True 12 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: unleash-client-python 2 | 3 | nav: 4 | - Home: index.md 5 | - UnleashClient: unleashclient.md 6 | - Strategy: strategy.md 7 | - Custom Strategies: customstrategies.md 8 | - Changelog: changelog.md 9 | - Development: development.md 10 | - Resources: resources.md 11 | 12 | theme: readthedocs 13 | -------------------------------------------------------------------------------- /tests/unit_tests/strategies/test_gradualrolloutrandom.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from UnleashClient.strategies import GradualRolloutRandom 3 | 4 | 5 | @pytest.fixture() 6 | def strategy(): 7 | yield GradualRolloutRandom(parameters={"percentage": 50}) 8 | 9 | 10 | def test_userwithid(strategy): 11 | assert isinstance(strategy.execute(), bool) 12 | -------------------------------------------------------------------------------- /tests/unit_tests/strategies/test_gradualrolloutwithuserid.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from UnleashClient.strategies import GradualRolloutUserId 3 | from tests.utilities import generate_context 4 | 5 | 6 | @pytest.fixture() 7 | def strategy(): 8 | yield GradualRolloutUserId(parameters={"percentage": 50, "groupId": "test"}) 9 | 10 | 11 | def test_userwithid(strategy): 12 | strategy.execute(context=generate_context()) 13 | -------------------------------------------------------------------------------- /UnleashClient/strategies/GradualRolloutRandom.py: -------------------------------------------------------------------------------- 1 | import random 2 | from UnleashClient.strategies.Strategy import Strategy 3 | 4 | 5 | class GradualRolloutRandom(Strategy): 6 | def apply(self, context: dict = None) -> bool: 7 | """ 8 | Returns random assignment. 9 | 10 | :return: 11 | """ 12 | percentage = int(self.parameters["percentage"]) 13 | 14 | return percentage > 0 and random.randint(1, 100) <= percentage 15 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # TODO: Remove up the package post removing the code which will not get used 2 | bumpversion==0.6.0 3 | coveralls==3.0.1 4 | mimesis==2.1.0 5 | mkdocs==1.3.0 6 | mypy==0.812 7 | pur==5.3.0 8 | pylint==2.7.2 9 | pytest==6.2.2 10 | pytest-cov==2.11.1 11 | pytest-flake8==1.0.7 12 | pytest-html==1.22.0 13 | pytest-mock==3.5.1 14 | pytest-rerunfailures==9.1.1 15 | pytest-runner==5.3.0 16 | pytest-xdist==2.2.1 17 | responses==0.12.1 18 | tox==3.22.0 19 | twine==3.3.0 -------------------------------------------------------------------------------- /tests/unit_tests/strategies/test_gradualrolloutwithsessionid.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import pytest 3 | from UnleashClient.strategies import GradualRolloutSessionId 4 | 5 | 6 | def generate_context(): 7 | return {"sessionId": uuid.uuid4()} 8 | 9 | 10 | @pytest.fixture() 11 | def strategy(): 12 | yield GradualRolloutSessionId(parameters={"percentage": 50, "groupId": "test"}) 13 | 14 | 15 | def test_userwithid(strategy): 16 | strategy.execute(context=generate_context()) 17 | -------------------------------------------------------------------------------- /tox-osx.ini: -------------------------------------------------------------------------------- 1 | 2 | [tox] 3 | envlist = py35,py36,py37 4 | 5 | [testenv] 6 | deps = -rrequirements.txt 7 | commands = 8 | mypy UnleashClient 9 | pylint UnleashClient 10 | py.test --cov UnleashClient tests/unit_tests 11 | py.test --cov UnleashClient tests/specification_tests 12 | 13 | [testenv:py36] 14 | platform = darwin 15 | setenv = 16 | CFLAGS = "-mmacosx-version-min=10.13" 17 | 18 | [testenv:py35] 19 | platform = darwin 20 | setenv = 21 | CFLAGS = "-mmacosx-version-min=10.13" -------------------------------------------------------------------------------- /tests/utilities/mocks/mock_metrics.py: -------------------------------------------------------------------------------- 1 | MOCK_METRICS_REQUEST = \ 2 | { 3 | "appName": "appName", 4 | "instanceId": "instanceId", 5 | "bucket": { 6 | "start": "2016-11-03T07:16:43.572Z", 7 | "stop": "2016-11-03T07:16:53.572Z", 8 | "toggles": { 9 | "toggle-name-1": { 10 | "yes": 123, 11 | "no": 321 12 | }, 13 | "toggle-name-2": { 14 | "yes": 111, 15 | "no": 0 16 | } 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /UnleashClient/strategies/ApplicationHostname.py: -------------------------------------------------------------------------------- 1 | import platform 2 | from UnleashClient.strategies.Strategy import Strategy 3 | 4 | 5 | class ApplicationHostname(Strategy): 6 | def load_provisioning(self) -> list: 7 | return [x.strip() for x in self.parameters["hostNames"].split(',')] 8 | 9 | def apply(self, context: dict = None) -> bool: 10 | """ 11 | Returns true if userId is a member of id list. 12 | 13 | :return: 14 | """ 15 | return platform.node() in self.parsed_provisioning 16 | -------------------------------------------------------------------------------- /tests/unit_tests/strategies/test_userwithids.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from tests.utilities import generate_email_list 3 | from UnleashClient.strategies import UserWithId 4 | 5 | (EMAIL_LIST, CONTEXT) = generate_email_list(20) 6 | 7 | 8 | @pytest.fixture() 9 | def strategy(): 10 | yield UserWithId(parameters={"userIds": EMAIL_LIST}) 11 | 12 | 13 | def test_userwithid(strategy): 14 | assert strategy.execute(context=CONTEXT) 15 | 16 | 17 | def test_userwithid_missing_parameter(strategy): 18 | assert not strategy.execute(context={}) 19 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | notify: 3 | require_ci_to_pass: yes 4 | 5 | coverage: 6 | precision: 2 7 | round: down 8 | range: "40...100" 9 | 10 | status: 11 | project: yes 12 | patch: 13 | default: 14 | target: 80% 15 | threshold: 1% 16 | changes: no 17 | 18 | parsers: 19 | gcov: 20 | branch_detection: 21 | conditional: yes 22 | loop: yes 23 | method: no 24 | macro: no 25 | 26 | comment: 27 | layout: "header, diff" 28 | behavior: default 29 | require_changes: no 30 | -------------------------------------------------------------------------------- /tests/unit_tests/strategies/test_applicationhostname.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import platform 3 | from UnleashClient.strategies import ApplicationHostname 4 | 5 | 6 | @pytest.fixture() 7 | def strategy(): 8 | yield ApplicationHostname(parameters={"hostNames": "%s,garbage,garbage2" % platform.node()}) 9 | 10 | 11 | def test_applicationhostname(strategy): 12 | assert strategy.execute() 13 | 14 | 15 | def test_applicationhostname_nomatch(): 16 | nomatch_strategy = ApplicationHostname(parameters={"hostNames": "garbage,garbage2"}) 17 | assert not nomatch_strategy.execute() 18 | -------------------------------------------------------------------------------- /UnleashClient/strategies/GradualRolloutUserId.py: -------------------------------------------------------------------------------- 1 | from UnleashClient.utils import normalized_hash 2 | from UnleashClient.strategies.Strategy import Strategy 3 | 4 | 5 | class GradualRolloutUserId(Strategy): 6 | def apply(self, context: dict = None) -> bool: 7 | """ 8 | Returns true if userId is a member of id list. 9 | 10 | :return: 11 | """ 12 | percentage = int(self.parameters["percentage"]) 13 | activation_group = self.parameters["groupId"] 14 | 15 | return percentage > 0 and normalized_hash(context["userId"], activation_group) <= percentage 16 | -------------------------------------------------------------------------------- /UnleashClient/strategies/GradualRolloutSessionId.py: -------------------------------------------------------------------------------- 1 | from UnleashClient.utils import normalized_hash 2 | from UnleashClient.strategies.Strategy import Strategy 3 | 4 | 5 | class GradualRolloutSessionId(Strategy): 6 | def apply(self, context: dict = None) -> bool: 7 | """ 8 | Returns true if userId is a member of id list. 9 | 10 | :return: 11 | """ 12 | percentage = int(self.parameters["percentage"]) 13 | activation_group = self.parameters["groupId"] 14 | 15 | return percentage > 0 and normalized_hash(context["sessionId"], activation_group) <= percentage 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # App packages 2 | requests==2.25.0 3 | fcache==0.4.7 4 | mmh3==2.5.1 5 | APScheduler==3.6.3 6 | redis==2.10.6 7 | 8 | # TODO: Remove up the package post removing the code which will not get used 9 | # Development packages 10 | bumpversion==0.6.0 11 | coveralls==3.0.1 12 | mimesis==2.1.0 13 | mkdocs==1.3.0 14 | mypy==0.812 15 | pur==5.3.0 16 | pylint==2.7.2 17 | pytest==6.2.2 18 | pytest-cov==2.11.1 19 | pytest-flake8==1.0.7 20 | pytest-html==1.22.0 21 | pytest-mock==3.5.1 22 | pytest-rerunfailures==9.1.1 23 | pytest-runner==5.3.0 24 | pytest-xdist==2.2.1 25 | responses==0.12.1 26 | tox==3.22.0 27 | twine==3.3.0 -------------------------------------------------------------------------------- /UnleashClient/strategies/UserWithId.py: -------------------------------------------------------------------------------- 1 | from UnleashClient.strategies.Strategy import Strategy 2 | 3 | 4 | class UserWithId(Strategy): 5 | def load_provisioning(self) -> list: 6 | return [x.strip() for x in self.parameters["userIds"].split(',')] 7 | 8 | def apply(self, context: dict = None) -> bool: 9 | """ 10 | Returns true if userId is a member of id list. 11 | 12 | :return: 13 | """ 14 | return_value = False 15 | 16 | if "userId" in context.keys(): 17 | return_value = context["userId"] in self.parsed_provisioning 18 | 19 | return return_value 20 | -------------------------------------------------------------------------------- /requirements-local.txt: -------------------------------------------------------------------------------- 1 | # App packages 2 | requests==2.25.0 3 | fcache==0.4.7 4 | mmh3==2.5.1 5 | APScheduler==3.6.3 6 | redis==2.10.6 7 | 8 | # TODO: Remove up the package post removing the code which will not get used 9 | # Development packages 10 | bumpversion==0.6.0 11 | coveralls==3.0.1 12 | mimesis==2.1.0 13 | mkdocs==1.3.0 14 | mypy==0.812 15 | pur==5.3.0 16 | pylint==2.7.2 17 | pytest==6.2.2 18 | pytest-cov==2.11.1 19 | pytest-flake8==1.0.7 20 | pytest-html==1.22.0 21 | pytest-mock==3.5.1 22 | pytest-rerunfailures==9.1.1 23 | pytest-runner==5.3.0 24 | pytest-xdist==2.2.1 25 | responses==0.12.1 26 | tox==3.22.0 27 | twine==3.3.0 28 | -------------------------------------------------------------------------------- /tests/utilities/data_generator.py: -------------------------------------------------------------------------------- 1 | import mimesis 2 | 3 | 4 | def generate_context(): 5 | return {"userId": mimesis.Person('en').email()} 6 | 7 | 8 | def generate_email_list(num: int) -> (str, dict): 9 | """ 10 | Generates an unleash-style list of emails for testing. 11 | 12 | :param num: 13 | :return: 14 | """ 15 | first_email = mimesis.Person('en').email() 16 | email_list_string = first_email 17 | 18 | context = {"userId": first_email} 19 | 20 | for _ in range(num - 1): 21 | email_list_string += "," + mimesis.Person('en').email() 22 | 23 | return (email_list_string, context) 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | Sample code is welcome! 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Logs** 23 | If applicable, add logs or output to help explain your problem. 24 | 25 | **Additional context** 26 | Add any other context about the problem here. 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /UnleashClient/strategies/__init__.py: -------------------------------------------------------------------------------- 1 | from .Strategy import Strategy 2 | from .Default import Default 3 | from .UserWithId import UserWithId 4 | from .GradualRolloutUserId import GradualRolloutUserId 5 | from .GradualRolloutSessionId import GradualRolloutSessionId 6 | from .GradualRolloutRandom import GradualRolloutRandom 7 | from .RemoteAddress import RemoteAddress 8 | from .ApplicationHostname import ApplicationHostname 9 | from .FlexibleRolloutStrategy import FlexibleRollout 10 | from .EnableForDomainStrategy import EnableForDomains 11 | from .EnableForBusinessStrategy import EnableForBusinesses 12 | from .EnableForExpertStrategy import EnableForExperts 13 | from .EnableForPartnerStrategy import EnableForPartners 14 | -------------------------------------------------------------------------------- /tests/integration_tests/integration.py: -------------------------------------------------------------------------------- 1 | import time 2 | from UnleashClient import UnleashClient 3 | 4 | # --- 5 | import logging 6 | import sys 7 | 8 | root = logging.getLogger() 9 | root.setLevel(logging.DEBUG) 10 | 11 | handler = logging.StreamHandler(sys.stdout) 12 | handler.setLevel(logging.DEBUG) 13 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 14 | handler.setFormatter(formatter) 15 | root.addHandler(handler) 16 | # --- 17 | 18 | my_client = UnleashClient( 19 | url="http://localhost:4242/api", 20 | app_name="pyIvan" 21 | ) 22 | 23 | my_client.initialize_client() 24 | 25 | while True: 26 | time.sleep(10) 27 | print(my_client.is_enabled("Demo")) 28 | -------------------------------------------------------------------------------- /UnleashClient/strategies/EnableForExpertStrategy.py: -------------------------------------------------------------------------------- 1 | from UnleashClient.strategies import Strategy 2 | 3 | 4 | class EnableForExperts(Strategy): 5 | def load_provisioning(self) -> list: 6 | return [x.strip() for x in self.parameters["expert_emails"].split(',')] 7 | 8 | def apply(self, context: dict = None) -> bool: 9 | """ 10 | Check if feature is enabled for expert or not 11 | 12 | Args: 13 | context(dict): expert email provided as context 14 | """ 15 | default_value = False 16 | 17 | if "expert_emails" in context.keys(): 18 | default_value = context["expert_emails"] in self.parsed_provisioning 19 | 20 | return default_value 21 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: $NEXT_PATCH_VERSION 2 | tag-template: $NEXT_PATCH_VERSION 3 | branches: master 4 | template: | 5 | # What's Changed 6 | 7 | $CHANGES 8 | categories: 9 | - title: 🚀 Features 10 | label: new-feature 11 | - title: 🐛 Bug Fixes 12 | label: bug-fixes 13 | - title: 📖 Documentation 14 | label: documentation 15 | - title: 💯 Enhancements 16 | label: enhancement 17 | - title: 🚒 Migrations 18 | label: needs-migration 19 | - title: 📦 Packages Updated 20 | label: packages-updated 21 | - title: 👺 Miscellaneous 22 | label: miscellaneous 23 | - title: 💪 Superman Release 24 | label: superman 25 | 26 | 27 | # exclude-labels: 28 | # - miscellaneous 29 | -------------------------------------------------------------------------------- /UnleashClient/strategies/EnableForDomainStrategy.py: -------------------------------------------------------------------------------- 1 | from UnleashClient.strategies import Strategy 2 | 3 | 4 | class EnableForDomains(Strategy): 5 | def load_provisioning(self) -> list: 6 | return [x.strip() for x in self.parameters["domain_names"].split(',')] 7 | 8 | def apply(self, context: dict = None) -> bool: 9 | """ 10 | Check if feature is enabled for given domain_name or not 11 | 12 | Args: 13 | context(dict): domain_name provided as context 14 | """ 15 | default_value = False 16 | 17 | if "domain_names" in context.keys(): 18 | default_value = context["domain_names"] in self.parsed_provisioning 19 | 20 | return default_value 21 | -------------------------------------------------------------------------------- /UnleashClient/strategies/EnableForTeamStrategy.py: -------------------------------------------------------------------------------- 1 | from UnleashClient.strategies import Strategy 2 | 3 | 4 | class EnableForTeams(Strategy): 5 | def load_provisioning(self) -> list: 6 | return [ 7 | x.strip() for x in self.parameters["team_ids"].split(',') 8 | ] 9 | 10 | def apply(self, context: dict = None) -> bool: 11 | """ 12 | Check if feature is enabled for given team or not 13 | 14 | Args: 15 | context(dict): team IDs provided as context 16 | """ 17 | default_value = False 18 | 19 | if "team_ids" in context.keys(): 20 | default_value = context["team_ids"] in self.parsed_provisioning 21 | 22 | return default_value 23 | -------------------------------------------------------------------------------- /UnleashClient/strategies/EnableForPartnerStrategy.py: -------------------------------------------------------------------------------- 1 | 2 | from UnleashClient.strategies import Strategy 3 | 4 | 5 | class EnableForPartners(Strategy): 6 | def load_provisioning(self) -> list: 7 | return [ 8 | x.strip() for x in self.parameters["partner_names"].split(',') 9 | ] 10 | 11 | def apply(self, context: dict = None) -> bool: 12 | """ 13 | Check if feature is enabled for given partner or not 14 | 15 | Args: 16 | context(dict): partner name provided as context 17 | """ 18 | default_value = False 19 | 20 | if "partner_names" in context.keys(): 21 | default_value = context["partner_names"] in self.parsed_provisioning 22 | 23 | return default_value 24 | -------------------------------------------------------------------------------- /UnleashClient/strategies/EnableForBusinessStrategy.py: -------------------------------------------------------------------------------- 1 | from UnleashClient.strategies import Strategy 2 | 3 | 4 | class EnableForBusinesses(Strategy): 5 | def load_provisioning(self) -> list: 6 | return [ 7 | x.strip() for x in self.parameters["business_via_names"].split(',') 8 | ] 9 | 10 | def apply(self, context: dict = None) -> bool: 11 | """ 12 | Check if feature is enabled for given business or not 13 | 14 | Args: 15 | context(dict): business-via-name provided as context 16 | """ 17 | default_value = False 18 | 19 | if "business_via_names" in context.keys(): 20 | default_value = context["business_via_names"] in self.parsed_provisioning 21 | 22 | return default_value 23 | -------------------------------------------------------------------------------- /FeatureToggle/utils.py: -------------------------------------------------------------------------------- 1 | from UnleashClient.utils import LOGGER 2 | from datetime import datetime, timedelta 3 | from functools import lru_cache, wraps 4 | 5 | 6 | def timed_lru_cache(seconds: int, maxsize: int = 128): 7 | LOGGER.info(f'timed_lru_cache was called') 8 | 9 | def wrapper_cache(func): 10 | func = lru_cache(maxsize=maxsize)(func) 11 | func.lifetime = timedelta(seconds=seconds) 12 | func.expiration = datetime.utcnow() + func.lifetime 13 | 14 | @wraps(func) 15 | def wrapped_func(*args, **kwargs): 16 | if datetime.utcnow() >= func.expiration: 17 | func.cache_clear() 18 | func.expiration = datetime.utcnow() + func.lifetime 19 | return func(*args, **kwargs) 20 | 21 | return wrapped_func 22 | 23 | return wrapper_cache 24 | -------------------------------------------------------------------------------- /tests/integration_tests/integration_gitlab.py: -------------------------------------------------------------------------------- 1 | import time 2 | from UnleashClient import UnleashClient 3 | 4 | # --- 5 | import logging 6 | import sys 7 | 8 | root = logging.getLogger() 9 | root.setLevel(logging.DEBUG) 10 | 11 | handler = logging.StreamHandler(sys.stdout) 12 | handler.setLevel(logging.DEBUG) 13 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 14 | handler.setFormatter(formatter) 15 | root.addHandler(handler) 16 | # --- 17 | 18 | 19 | my_client = UnleashClient( 20 | url="https://gitlab.com/api/v4/feature_flags/unleash/12139921", 21 | app_name="pyIvan", 22 | instance_id="Mr1vcvCx4QijfauYz_fg", 23 | disable_metrics=True, 24 | disable_registration=True 25 | ) 26 | 27 | my_client.initialize_client() 28 | 29 | while True: 30 | time.sleep(10) 31 | print(my_client.is_enabled("test")) 32 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## JIRA Ticket Number 2 | 3 | JIRA TICKET: 4 | 5 | ## Description of change 6 | (REMOVE ME) Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. 7 | 8 | ## Checklist (OPTIONAL): 9 | 10 | - [ ] My code follows the style guidelines of this project 11 | - [ ] I have performed a self-review of my own code 12 | - [ ] I have commented my code, particularly in hard-to-understand areas 13 | - [ ] I have made corresponding changes to the documentation 14 | - [ ] My changes generate no new warnings 15 | - [ ] I have added tests that prove my fix is effective or that my feature works 16 | - [ ] New and existing unit tests pass locally with my changes 17 | - [ ] Any dependent changes have been merged and published in downstream modules 18 | -------------------------------------------------------------------------------- /.github/mergeable.yml: -------------------------------------------------------------------------------- 1 | mergeable: 2 | pull_requests: 3 | stale: 4 | days: 14 5 | message: 'This PR is stale. Please follow up!' 6 | 7 | label: 8 | must_include: 9 | regex: '(new-feature)|(documentation)|(bug-fixes)|(enhancement)|(needs-migration)|(packages-updated)|(miscellaneous)|(superman)' 10 | message: 'Can you please add a valid label! [One of (new-feature) / (documentation) / (bug-fixes) / (enhancement) / (needs-migration) / (packages-updated) / (miscellaneous)]' 11 | must_exclude: 12 | regex: '(do-not-merge)' 13 | message: 'This PR is work in progress. Cannot be merged yet.' 14 | 15 | description: 16 | no_empty: 17 | enabled: true 18 | message: 'Can you please add a description!' 19 | must_exclude: 20 | regex: 'do not merge' 21 | message: 'This PR is work in progress. Cannot be merged yet.' 22 | -------------------------------------------------------------------------------- /tests/integration_tests/integration_unleashheroku.py: -------------------------------------------------------------------------------- 1 | import time 2 | from UnleashClient import UnleashClient 3 | from UnleashClient.strategies import Strategy 4 | 5 | 6 | # --- 7 | import logging 8 | import sys 9 | 10 | root = logging.getLogger() 11 | root.setLevel(logging.DEBUG) 12 | 13 | handler = logging.StreamHandler(sys.stdout) 14 | handler.setLevel(logging.DEBUG) 15 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 16 | handler.setFormatter(formatter) 17 | root.addHandler(handler) 18 | # --- 19 | 20 | my_client = UnleashClient( 21 | url="https://unleash.herokuapp.com/api", 22 | environment="staging", 23 | app_name="pyIvan", 24 | ) 25 | 26 | my_client.initialize_client() 27 | 28 | while True: 29 | time.sleep(10) 30 | context = { 31 | 'userId': "1", 32 | 'sound': 'woof' 33 | } 34 | print(f"ivantest: {my_client.is_enabled('ivantest', context)}") 35 | -------------------------------------------------------------------------------- /tests/utilities/mocks/mock_variants.py: -------------------------------------------------------------------------------- 1 | VARIANTS = \ 2 | [ 3 | { 4 | "name": "VarA", 5 | "weight": 34, 6 | "payload": { 7 | "type": "string", 8 | "value": "Test1" 9 | }, 10 | "overrides": [ 11 | { 12 | "contextName": "userId", 13 | "values": [ 14 | "1" 15 | ] 16 | } 17 | ] 18 | }, 19 | { 20 | "name": "VarB", 21 | "weight": 33, 22 | "payload": { 23 | "type": "string", 24 | "value": "Test 2" 25 | } 26 | }, 27 | { 28 | "name": "VarC", 29 | "weight": 33, 30 | "payload": { 31 | "type": "string", 32 | "value": "Test 3" 33 | } 34 | } 35 | ] 36 | -------------------------------------------------------------------------------- /UnleashClient/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import mmh3 # pylint: disable=import-error 3 | from requests import Response 4 | 5 | LOGGER = logging.getLogger("mogambo") 6 | 7 | 8 | def normalized_hash(identifier: str, 9 | activation_group: str, 10 | normalizer: int = 100) -> int: 11 | return mmh3.hash("{}:{}".format(activation_group, identifier), signed=False) % normalizer + 1 12 | 13 | 14 | def get_identifier(context_key_name: str, context: dict) -> str: 15 | if context_key_name in context.keys(): 16 | value = context[context_key_name] 17 | elif 'properties' in context.keys() and context_key_name in context['properties'].keys(): 18 | value = context['properties'][context_key_name] 19 | else: 20 | value = None 21 | 22 | return value 23 | 24 | 25 | def log_resp_info(resp: Response) -> None: 26 | LOGGER.debug("HTTP status code: %s", resp.status_code) 27 | LOGGER.debug("HTTP headers: %s", resp.headers) 28 | LOGGER.debug("HTTP content: %s", resp.text) 29 | -------------------------------------------------------------------------------- /tests/utilities/decorators.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import uuid 3 | from fcache.cache import FileCache 4 | from UnleashClient.constants import FEATURES_URL 5 | from tests.utilities.mocks import MOCK_ALL_FEATURES, MOCK_CUSTOM_STRATEGY 6 | 7 | 8 | @pytest.fixture() 9 | def cache_empty(): 10 | cache_name = 'pytest_%s' % uuid.uuid4() 11 | temporary_cache = FileCache(cache_name) 12 | yield temporary_cache 13 | temporary_cache.delete() 14 | 15 | 16 | @pytest.fixture() 17 | def cache_full(): 18 | cache_name = 'pytest_%s' % uuid.uuid4() 19 | temporary_cache = FileCache(cache_name) 20 | temporary_cache[FEATURES_URL] = MOCK_ALL_FEATURES 21 | temporary_cache.sync() 22 | yield temporary_cache 23 | temporary_cache.delete() 24 | 25 | 26 | @pytest.fixture() 27 | def cache_custom(): 28 | cache_name = 'pytest_%s' % uuid.uuid4() 29 | temporary_cache = FileCache(cache_name) 30 | temporary_cache[FEATURES_URL] = MOCK_CUSTOM_STRATEGY 31 | temporary_cache.sync() 32 | yield temporary_cache 33 | temporary_cache.delete() 34 | -------------------------------------------------------------------------------- /tests/unit_tests/api/test_metrics.py: -------------------------------------------------------------------------------- 1 | import responses 2 | from pytest import mark, param 3 | from requests import ConnectionError 4 | from tests.utilities.testing_constants import URL, CUSTOM_HEADERS, CUSTOM_OPTIONS 5 | from tests.utilities.mocks.mock_metrics import MOCK_METRICS_REQUEST 6 | from UnleashClient.constants import METRICS_URL 7 | from UnleashClient.api import send_metrics 8 | 9 | 10 | FULL_METRICS_URL = URL + METRICS_URL 11 | 12 | 13 | @responses.activate 14 | @mark.parametrize("payload,status,expected", ( 15 | param({"json": {}}, 202, lambda result: result, id="success"), 16 | param({"json": {}}, 500, lambda result: not result, id="failure"), 17 | param({"body": ConnectionError("Test connection error.")}, 200, lambda result: not result, id="exception"), 18 | )) 19 | def test_send_metrics(payload, status, expected): 20 | responses.add(responses.POST, FULL_METRICS_URL, **payload, status=status) 21 | 22 | result = send_metrics(URL, MOCK_METRICS_REQUEST, CUSTOM_HEADERS, CUSTOM_OPTIONS) 23 | 24 | assert len(responses.calls) == 1 25 | assert expected(result) 26 | -------------------------------------------------------------------------------- /UnleashClient/periodic_tasks/fetch_and_load.py: -------------------------------------------------------------------------------- 1 | import redis 2 | import pickle 3 | from UnleashClient.api import get_feature_toggles 4 | from UnleashClient.loader import load_features 5 | from UnleashClient.constants import FEATURES_URL 6 | from UnleashClient.utils import LOGGER 7 | 8 | 9 | def fetch_and_load_features(url: str, 10 | app_name: str, 11 | instance_id: str, 12 | custom_headers: dict, 13 | custom_options: dict, 14 | cache: redis.Redis, 15 | features: dict, 16 | strategy_mapping: dict) -> None: 17 | feature_provisioning = get_feature_toggles( 18 | url, app_name, instance_id, 19 | custom_headers, custom_options 20 | ) 21 | 22 | if feature_provisioning: 23 | cache.set(FEATURES_URL, pickle.dumps(feature_provisioning)) 24 | else: 25 | LOGGER.warning("Unable to get feature flag toggles, using cached provisioning.") 26 | 27 | load_features(cache, features, strategy_mapping) 28 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Ivan Lee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /UnleashClient/deprecation_warnings.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from UnleashClient.strategies import Strategy 3 | 4 | 5 | def strategy_v2xx_deprecation_check(strategies: list) -> None: 6 | """ 7 | Notify users of backwards incompatible changes in v3 for custom strategies. 8 | """ 9 | for strategy in strategies: 10 | try: 11 | # Check if the __call__() method is overwritten (should only be true for custom strategies in v1.x or v2.x. 12 | if strategy.__call__ != Strategy.__call__: 13 | warnings.warn( 14 | "unleash-client-python v3.x.x requires overriding the execute() method instead of the __call__() method. Error in: {}".format(strategy.__name__), 15 | DeprecationWarning 16 | ) 17 | except AttributeError: 18 | # Ignore if not. 19 | pass 20 | 21 | 22 | def default_value_warning() -> None: 23 | warnings.warn( 24 | "The default_value argument for the is_enabled() function is being deprecated in next major version. Please use fallback_function argument instead.", 25 | DeprecationWarning 26 | ) 27 | -------------------------------------------------------------------------------- /docs/strategy.md: -------------------------------------------------------------------------------- 1 | ## Strategy 2 | 3 | ### `__init__(params)` 4 | 5 | A generic strategy objects. 6 | 7 | **Arguments** 8 | 9 | Argument | Description | Required? | Type | Default Value| 10 | ---------|-------------|-----------|-------|---------------| 11 | params | 'parameters' key from strategy section (...from feature section) of /api/clients/features response | N, but you probably should have one. :) | Dictionary | {} | 12 | 13 | ### `load_provisioning()` 14 | 15 | Method to load data on object initialization, if desired. This should parse the raw values in _self.parameters_ into format Python can comprehend. 16 | 17 | The value returned by `load_provisioning()` will be stored in the _self.parsed_provisioning_ class variable when object is created. The superclass returns an empty list since most of Unleash's default strategies are list-based (in one way or another). 18 | 19 | ## `apply(context)` 20 | Strategy implementation goes here. 21 | 22 | **Arguments** 23 | 24 | Argument | Description | Required? | Type | Default Value| 25 | ---------|-------------|-----------|-------|---------------| 26 | context | Application Context | N | Dictionary | {} | -------------------------------------------------------------------------------- /tests/utilities/testing_constants.py: -------------------------------------------------------------------------------- 1 | from UnleashClient.strategies import ApplicationHostname, Default, GradualRolloutRandom, \ 2 | GradualRolloutSessionId, GradualRolloutUserId, UserWithId, RemoteAddress, FlexibleRollout 3 | 4 | # General configs 5 | APP_NAME = "pytest" 6 | ENVIRONMENT = "unit" 7 | INSTANCE_ID = "123" 8 | REFRESH_INTERVAL = 15 9 | METRICS_INTERVAL = 10 10 | DISABLE_METRICS = True 11 | DISABLE_REGISTRATION = True 12 | CUSTOM_HEADERS = {"name": "My random header."} 13 | CUSTOM_OPTIONS = {"verify": False} 14 | 15 | # URLs 16 | URL = "http://localhost:4242/api" 17 | INTEGRATION_URL = "http://localhost:4242/api" 18 | 19 | # Constants 20 | IP_LIST = "69.208.0.0/29,70.208.1.1,2001:db8:1234::/48,2002:db8:1234:0000:0000:0000:0000:0001" 21 | 22 | # Mapping 23 | DEFAULT_STRATEGY_MAPPING = { 24 | "applicationHostname": ApplicationHostname, 25 | "default": Default, 26 | "gradualRolloutRandom": GradualRolloutRandom, 27 | "gradualRolloutSessionId": GradualRolloutSessionId, 28 | "gradualRolloutUserId": GradualRolloutUserId, 29 | "remoteAddress": RemoteAddress, 30 | "userWithId": UserWithId, 31 | "flexibleRollout": FlexibleRollout 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | main: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Set up Python 3.7 13 | uses: actions/setup-python@v1 14 | with: 15 | python-version: 3.7 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install -r requirements.txt 20 | pip install wheel 21 | - name: Build package 22 | run: | 23 | python setup.py sdist bdist_wheel 24 | - name: Upload package to pypi 25 | run: | 26 | twine upload dist/* 27 | env: 28 | TWINE_USERNAME: ${{ secrets.pypi_username }} 29 | TWINE_PASSWORD: ${{ secrets.pypi_password }} 30 | - name: Notify Slack of pipeline completion 31 | uses: 8398a7/action-slack@v2 32 | with: 33 | status: ${{ job.status }} 34 | author_name: Github Action 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.github_slack_token }} 37 | SLACK_WEBHOOK_URL: ${{ secrets.slack_webhook }} 38 | if: always() 39 | -------------------------------------------------------------------------------- /tests/unit_tests/api/test_feature.py: -------------------------------------------------------------------------------- 1 | import responses 2 | from pytest import mark, param 3 | from tests.utilities.mocks.mock_features import MOCK_FEATURE_RESPONSE 4 | from tests.utilities.testing_constants import URL, APP_NAME, INSTANCE_ID, CUSTOM_HEADERS, CUSTOM_OPTIONS 5 | from UnleashClient.constants import FEATURES_URL 6 | from UnleashClient.api import get_feature_toggles 7 | 8 | 9 | FULL_FEATURE_URL = URL + FEATURES_URL 10 | 11 | 12 | @responses.activate 13 | @mark.parametrize("response,status,expected", ( 14 | param(MOCK_FEATURE_RESPONSE, 200, lambda result: result["version"] == 1, id="success"), 15 | param(MOCK_FEATURE_RESPONSE, 202, lambda result: not result, id="failure"), 16 | param({}, 500, lambda result: not result, id="failure"), 17 | )) 18 | def test_get_feature_toggle(response, status, expected): 19 | responses.add(responses.GET, FULL_FEATURE_URL, json=response, status=status) 20 | 21 | result = get_feature_toggles(URL, 22 | APP_NAME, 23 | INSTANCE_ID, 24 | CUSTOM_HEADERS, 25 | CUSTOM_OPTIONS) 26 | 27 | assert len(responses.calls) == 1 28 | assert expected(result) 29 | -------------------------------------------------------------------------------- /tests/unit_tests/api/test_register.py: -------------------------------------------------------------------------------- 1 | import responses 2 | from pytest import mark, param 3 | from requests import ConnectionError 4 | from UnleashClient.constants import REGISTER_URL 5 | from UnleashClient.api import register_client 6 | from tests.utilities.testing_constants import URL, APP_NAME, INSTANCE_ID, METRICS_INTERVAL, CUSTOM_HEADERS, CUSTOM_OPTIONS, DEFAULT_STRATEGY_MAPPING 7 | 8 | 9 | FULL_REGISTER_URL = URL + REGISTER_URL 10 | 11 | 12 | @responses.activate 13 | @mark.parametrize("payload,status,expected", ( 14 | param({"json": {}}, 202, True, id="success"), 15 | param({"json": {}}, 500, False, id="failure"), 16 | param({"body": ConnectionError("Test connection error")}, 200, False, id="exception"), 17 | )) 18 | def test_register_client(payload, status, expected): 19 | responses.add(responses.POST, FULL_REGISTER_URL, **payload, status=status) 20 | 21 | result = register_client(URL, 22 | APP_NAME, 23 | INSTANCE_ID, 24 | METRICS_INTERVAL, 25 | CUSTOM_HEADERS, 26 | CUSTOM_OPTIONS, 27 | DEFAULT_STRATEGY_MAPPING) 28 | 29 | assert len(responses.calls) == 1 30 | assert result is expected 31 | -------------------------------------------------------------------------------- /UnleashClient/constraints/Constraint.py: -------------------------------------------------------------------------------- 1 | from UnleashClient.utils import LOGGER, get_identifier 2 | 3 | class Constraint: 4 | def __init__(self, constraint_dict: dict) -> None: 5 | """ 6 | Represents a constraint on a strategy 7 | 8 | constraint_dict = From the strategy document. 9 | """ 10 | self.context_name = constraint_dict['contextName'] 11 | self.operator = constraint_dict['operator'] 12 | self.values = constraint_dict['values'] 13 | 14 | def apply(self, context: dict = None) -> bool: 15 | """ 16 | Returns true/false depending on constraint provisioning and context. 17 | 18 | :param context: Context information 19 | :return: 20 | """ 21 | constraint_check = False 22 | 23 | try: 24 | value = get_identifier(self.context_name, context) 25 | 26 | if value: 27 | if self.operator.upper() == "IN": 28 | constraint_check = value in self.values 29 | elif self.operator.upper() == "NOT_IN": 30 | constraint_check = value not in self.values 31 | except Exception as excep: #pylint: disable=W0703 32 | LOGGER.info("Could not evaluate context %s! Error: %s", self.context_name, excep) 33 | 34 | return constraint_check 35 | -------------------------------------------------------------------------------- /tests/unit_tests/strategies/test_remoteaddress.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from UnleashClient.strategies import RemoteAddress 3 | from tests.utilities.testing_constants import IP_LIST 4 | 5 | 6 | @pytest.fixture() 7 | def strategy(): 8 | yield RemoteAddress(parameters={"IPs": IP_LIST}) 9 | 10 | 11 | def test_init_with_bad_address(): 12 | BAD_IP_LIST = IP_LIST + ",garbage" 13 | strategy = RemoteAddress(parameters={"IPs": BAD_IP_LIST}) 14 | assert len(strategy.parsed_provisioning) == 4 15 | 16 | 17 | def test_init_with_bad_range(): 18 | BAD_IP_LIST = IP_LIST + ",ga/rbage" 19 | strategy = RemoteAddress(parameters={"IPs": BAD_IP_LIST}) 20 | assert len(strategy.parsed_provisioning) == 4 21 | 22 | 23 | def test_ipv4_range(strategy): 24 | assert strategy.execute(context={"remoteAddress": "69.208.0.1"}) 25 | 26 | 27 | def test_ipv4_value(strategy): 28 | assert strategy.execute(context={"remoteAddress": "70.208.1.1"}) 29 | 30 | 31 | def test_ipv6_rangee(strategy): 32 | assert strategy.execute(context={"remoteAddress": "2001:db8:1234:0000:0000:0000:0000:0001"}) 33 | 34 | 35 | def test_ipv6_value(strategy): 36 | assert strategy.execute(context={"remoteAddress": "2002:db8:1234:0000:0000:0000:0000:0001"}) 37 | 38 | 39 | def test_garbage_value(strategy): 40 | assert not strategy.execute(context={"remoteAddress": "WTFISTHISURCRAZY"}) 41 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setup file for UnleashClient""" 2 | from setuptools import setup, find_packages 3 | 4 | 5 | def readme(): 6 | """Include README.rst content in PyPi build information""" 7 | with open('README.md') as file: 8 | return file.read() 9 | 10 | # Forked by Parvez Alam 11 | 12 | setup( 13 | name='UnleashClient', 14 | version='3.5.0', 15 | author='Ivan Lee', 16 | author_email='ivanklee86@gmail.com', 17 | description='Python client for the Unleash feature toggle system!', 18 | long_description=readme(), 19 | long_description_content_type="text/markdown", 20 | url='https://github.com/Unleash/unleash-client-python', 21 | packages=find_packages(), 22 | install_requires=["requests==2.25.0", 23 | "fcache==0.4.7", 24 | "mmh3==2.5.1", 25 | "apscheduler==3.6.3"], 26 | tests_require=['pytest', "mimesis", "responses", 'pytest-mock'], 27 | zip_safe=False, 28 | include_package_data=True, 29 | classifiers=[ 30 | "Development Status :: 5 - Production/Stable", 31 | "Intended Audience :: Developers", 32 | "License :: OSI Approved :: MIT License", 33 | "Programming Language :: Python :: 3.5", 34 | "Programming Language :: Python :: 3.6", 35 | "Programming Language :: Python :: 3.7", 36 | "Programming Language :: Python :: 3.8" 37 | ] 38 | ) 39 | -------------------------------------------------------------------------------- /tests/utilities/old_code/StrategyV2.py: -------------------------------------------------------------------------------- 1 | #Old Strategy object from unleash-client-python version 1.x.x and 2.x.x 2 | 3 | # pylint: disable=dangerous-default-value 4 | class StrategyOldV2(): 5 | """ 6 | In general, default & custom classes should only need to override: 7 | * __call__() - Implementation of the strategy. 8 | * load_provisioning - Loads strategy provisioning 9 | """ 10 | def __init__(self, 11 | parameters: dict = {}) -> None: 12 | """ 13 | A generic strategy objects. 14 | :param parameters: 'parameters' key from strategy section (...from feature section) of 15 | /api/clients/features response 16 | """ 17 | # Experiment information 18 | self.parameters = parameters 19 | 20 | self.parsed_provisioning = self.load_provisioning() 21 | 22 | # pylint: disable=no-self-use 23 | def load_provisioning(self) -> list: 24 | """ 25 | Method to load data on object initialization, if desired. 26 | This should parse the raw values in self.parameters into format Python can comprehend. 27 | """ 28 | return [] 29 | 30 | def __eq__(self, other): 31 | return self.parameters == other.parameters 32 | 33 | def __call__(self, context: dict = None) -> bool: 34 | """ 35 | Strategy implementation goes here. 36 | :param context: Context information 37 | :return: 38 | """ 39 | return False 40 | -------------------------------------------------------------------------------- /UnleashClient/strategies/FlexibleRolloutStrategy.py: -------------------------------------------------------------------------------- 1 | import random 2 | from UnleashClient.strategies.Strategy import Strategy 3 | from UnleashClient.utils import normalized_hash 4 | 5 | 6 | class FlexibleRollout(Strategy): 7 | @staticmethod 8 | def random_hash() -> int: 9 | return random.randint(1, 100) 10 | 11 | def apply(self, context: dict = None) -> bool: 12 | """ 13 | If constraints are satisfied, return a percentage rollout on provisioned. 14 | 15 | :return: 16 | """ 17 | percentage = int(self.parameters['rollout']) 18 | activation_group = self.parameters['groupId'] 19 | stickiness = self.parameters['stickiness'] 20 | 21 | if stickiness == 'default': 22 | if 'userId' in context.keys(): 23 | calculated_percentage = normalized_hash(context['userId'], activation_group) 24 | elif 'sessionId' in context.keys(): 25 | calculated_percentage = normalized_hash(context['sessionId'], activation_group) 26 | else: 27 | calculated_percentage = self.random_hash() 28 | elif stickiness in ['userId', 'sessionId']: 29 | calculated_percentage = normalized_hash(context[stickiness], activation_group) 30 | else: 31 | # This also handles the stickiness == random scenario. 32 | calculated_percentage = self.random_hash() 33 | 34 | return percentage > 0 and calculated_percentage <= percentage 35 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | ROOT_DIR := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) 3 | PROJECT_NAME = UnleashClient 4 | 5 | #----------------------------------------------------------------------- 6 | # Rules of Rules : Grouped rules that _doathing_ 7 | #----------------------------------------------------------------------- 8 | test: lint pytest 9 | 10 | precommit: clean generate-requirements 11 | 12 | build: clean build-package upload 13 | 14 | build-local: clean build-package 15 | 16 | #----------------------------------------------------------------------- 17 | # Install 18 | #----------------------------------------------------------------------- 19 | 20 | install-clean: 21 | pip install -U -r requirements-dev.txt && \ 22 | pip install -U -r requirements-package.txt 23 | 24 | #----------------------------------------------------------------------- 25 | # Testing & Linting 26 | #----------------------------------------------------------------------- 27 | lint: 28 | pylint ${PROJECT_NAME} && \ 29 | mypy ${PROJECT_NAME}; 30 | 31 | pytest: 32 | export PYTHONPATH=${ROOT_DIR}: $$PYTHONPATH && \ 33 | py.test --cov ${PROJECT_NAME} tests/unit_tests 34 | 35 | tox-osx: 36 | tox -c tox-osx.ini --parallel auto 37 | 38 | #----------------------------------------------------------------------- 39 | # Rules 40 | #----------------------------------------------------------------------- 41 | clean: 42 | rm -rf build; \ 43 | rm -rf dist; 44 | 45 | build-package: 46 | python setup.py sdist bdist_wheel 47 | 48 | upload: 49 | twine upload dist/* 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # unleash-client-python 2 | 3 | This is the Python client for [Unleash](https://github.com/unleash/unleash). It implements [Client Specifications 1.0](https://github.com/Unleash/unleash/blob/master/docs/client-specification.md) and checks compliance based on spec in [unleash/client-specifications](https://github.com/Unleash/client-specification). 4 | 5 | ## Params Required to initialise the FeatureToggles 6 | ``` 7 | url -> Unleash Service Client URL 8 | 9 | app_name -> Unleash server URL 10 | 11 | environment -> Get from ENV variable 12 | 13 | cas_name -> Get from ENV variable 14 | 15 | redis_host -> Get from ENV variable 16 | 17 | redis_port -> Get from ENV variable 18 | 19 | redis_db -> Get from ENV variable 20 | 21 | enable_feature_oggle_service -> Get from ENV variable 22 | ``` 23 | 24 | ## Initialise the client in haptik_api 25 | ``` 26 | FeatureToggles.initialize( 27 | url, 28 | app_name, 29 | environment, 30 | cas_name, 31 | redis_host, 32 | redis_port, 33 | redis_db 34 | enable_feature_toggle_service) 35 | ``` 36 | 37 | ## Usage in haptik Repositories 38 | ``` 39 | # To check if feature is enabled for domain 40 | FeatureToggles.is_enabled_for_domain(, ) 41 | 42 | # Check if certainfeature is enabled for partner 43 | FeatureToggles.is_enabled_for_partner(, ) 44 | 45 | # Check if certain feature is enabled for business 46 | FeatureToggles.is_enabled_for_business(, ) 47 | 48 | # Check if certain feature is enabled for an expert 49 | FeatureToggles.is_enabled_for_expert(, ) 50 | ``` 51 | -------------------------------------------------------------------------------- /UnleashClient/api/metrics.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests 3 | from UnleashClient.constants import REQUEST_TIMEOUT, APPLICATION_HEADERS, METRICS_URL 4 | from UnleashClient.utils import LOGGER, log_resp_info 5 | 6 | 7 | # pylint: disable=broad-except 8 | def send_metrics(url: str, 9 | request_body: dict, 10 | custom_headers: dict, 11 | custom_options: dict) -> bool: 12 | """ 13 | Attempts to send metrics to Unleash server 14 | 15 | Notes: 16 | * If unsuccessful (i.e. not HTTP status code 200), message will be logged 17 | 18 | :param url: 19 | :param app_name: 20 | :param instance_id: 21 | :param metrics_interval: 22 | :param custom_headers: 23 | :param custom_options: 24 | :return: true if registration successful, false if registration unsuccessful or exception. 25 | """ 26 | try: 27 | LOGGER.info("Sending messages to with unleash @ %s", url) 28 | LOGGER.info("unleash metrics information: %s", request_body) 29 | 30 | resp = requests.post(url + METRICS_URL, 31 | data=json.dumps(request_body), 32 | headers={**custom_headers, **APPLICATION_HEADERS}, 33 | timeout=REQUEST_TIMEOUT, **custom_options) 34 | 35 | if resp.status_code != 202: 36 | log_resp_info(resp) 37 | LOGGER.warning("Unleash CLient metrics submission failed.") 38 | return False 39 | 40 | LOGGER.info("Unleash Client metrics successfully sent!") 41 | 42 | return True 43 | except Exception: 44 | LOGGER.exception("Unleash Client metrics submission failed dye to exception: %s", Exception) 45 | 46 | return False 47 | -------------------------------------------------------------------------------- /tests/unit_tests/test_constraints.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from UnleashClient.constraints import Constraint 3 | 4 | 5 | CONSTRAINT_DICT_IN = \ 6 | { 7 | "contextName": "appName", 8 | "operator": "IN", 9 | "values": [ 10 | "test", 11 | "test2" 12 | ] 13 | } 14 | 15 | 16 | CONSTRAINT_DICT_NOTIN = \ 17 | { 18 | "contextName": "appName", 19 | "operator": "NOT_IN", 20 | "values": [ 21 | "test", 22 | "test2" 23 | ] 24 | } 25 | 26 | 27 | @pytest.fixture() 28 | def constraint_IN(): 29 | yield Constraint(CONSTRAINT_DICT_IN) 30 | 31 | 32 | @pytest.fixture() 33 | def constraint_NOTIN(): 34 | yield Constraint(CONSTRAINT_DICT_NOTIN) 35 | 36 | 37 | def test_constraint_IN_match(constraint_IN): 38 | constraint = constraint_IN 39 | context = { 40 | 'appName': 'test' 41 | } 42 | 43 | assert constraint.apply(context) 44 | 45 | 46 | def test_constraint_IN_not_match(constraint_IN): 47 | constraint = constraint_IN 48 | context = { 49 | 'appName': 'test3' 50 | } 51 | 52 | assert not constraint.apply(context) 53 | 54 | 55 | def test_constraint_IN_missingcontext(constraint_IN): 56 | constraint = constraint_IN 57 | assert not constraint.apply({}) 58 | 59 | 60 | def test_constraint_NOTIN_match(constraint_NOTIN): 61 | constraint = constraint_NOTIN 62 | context = { 63 | 'appName': 'test' 64 | } 65 | 66 | assert not constraint.apply(context) 67 | 68 | 69 | def test_constraint_NOTIN_not_match(constraint_NOTIN): 70 | constraint = constraint_NOTIN 71 | context = { 72 | 'appName': 'test3' 73 | } 74 | 75 | assert constraint.apply(context) 76 | -------------------------------------------------------------------------------- /UnleashClient/api/features.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from UnleashClient.constants import REQUEST_TIMEOUT, FEATURES_URL 3 | from UnleashClient.utils import LOGGER, log_resp_info 4 | 5 | 6 | # pylint: disable=broad-except 7 | def get_feature_toggles(url: str, 8 | app_name: str, 9 | instance_id: str, 10 | custom_headers: dict, 11 | custom_options: dict) -> dict: 12 | """ 13 | Retrieves feature flags from unleash central server. 14 | 15 | Notes: 16 | * If unsuccessful (i.e. not HTTP status code 200), exception will be caught and logged. 17 | This is to allow "safe" error handling if unleash server goes down. 18 | 19 | :param url: 20 | :param app_name: 21 | :param instance_id: 22 | :param custom_headers: 23 | :param custom_options: 24 | :return: Feature flags if successful, empty dict if not. 25 | """ 26 | try: 27 | LOGGER.info("Getting feature flag.") 28 | 29 | headers = { 30 | "UNLEASH-APPNAME": app_name, 31 | "UNLEASH-INSTANCEID": instance_id 32 | } 33 | 34 | resp = requests.get(url + FEATURES_URL, 35 | headers={**custom_headers, **headers}, 36 | timeout=REQUEST_TIMEOUT, **custom_options) 37 | 38 | if resp.status_code != 200: 39 | log_resp_info(resp) 40 | LOGGER.warning("Unleash Client feature fetch failed due to unexpected HTTP status code.") 41 | raise Exception("Unleash Client feature fetch failed!") 42 | 43 | return resp.json() 44 | except Exception: 45 | LOGGER.exception("Unleash Client feature fetch failed due to exception: %s", Exception) 46 | 47 | return {} 48 | -------------------------------------------------------------------------------- /docs/customstrategies.md: -------------------------------------------------------------------------------- 1 | ## Implementing a custom strategy 2 | 3 | * Set up a custom strategy in Unleash. Note down the name - you'll need this exact value to ensure we're loading the custom strategy correctly. 4 | * Create a custom strategy object by sub-classing the Strategy object. 5 | 6 | ``` 7 | from UnleashClient.strategies.Strategies import Strategy 8 | 9 | class CatTest(Strategy): 10 | def load_provisioning(self) -> list: 11 | return [x.strip() for x in self.parameters["sound"].split(',')] 12 | 13 | def apply(self, context: dict = None) -> bool: 14 | """ 15 | Turn on if I'm a cat. 16 | 17 | :return: 18 | """ 19 | default_value = False 20 | 21 | if "sound" in context.keys(): 22 | default_value = context["sound"] in self.parsed_provisioning 23 | 24 | return default_value 25 | ``` 26 | 27 | * Create a dictionary where the key is the name of the custom strategy. Note: The key must match the name of the custom strategy created on the Unleash server exactly (including capitalization!). 28 | 29 | ``` 30 | my_custom_strategies = {"amIACat": CatTest} 31 | ``` 32 | 33 | * When initializing UnleashClient, provide the custom strategy dictionary. 34 | 35 | ``` 36 | unleash_client = UnleashClient(URL, APP_NAME, custom_strategies=my_custom_strategies) 37 | ``` 38 | 39 | * Fire up Unleash! You can now use the "amIACat" strategy in a feature toggle. 40 | 41 | ### Migrating your custom strategies from Strategy from v2.x.x to v3.x.x (for fun and profit) 42 | To get support for for constraints in your custom strategy, take the following steps: 43 | 44 | - Instead of overriding the `__call__()` method, override the `apply()` method. (In practice, you can just rename the method!) 45 | - ??? 46 | - Profit! 47 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | Contributions welcome! 2 | 3 | Here are some notes about common tools and tasks you'll run into when working on `unleash-client-python`. 4 | 5 | ## Tools 6 | * [miniconda](https://docs.conda.io/en/latest/miniconda.html) - Used for local tox-ing to minimize friction when developing on a Mac. =) 7 | 8 | ## Setup 9 | 1. Create a new conda environment (`conda create -n ucp python=3.7`) or a venv. 10 | 2. Install packages: `pip install requirements-local.txt`. 11 | 3. If using Pycharm, add [conda env](https://medium.com/infinity-aka-aseem/how-to-setup-pycharm-with-an-anaconda-virtual-environment-already-created-fb927bacbe61) as your project interpreter. 12 | 13 | ## Testing 14 | 1. Activate your virtualenv solution (e.g. `source activate ucp`). 15 | 1. Run linting & tests: `make test` 16 | 1. Run tox tests `make tox-osx` 17 | 18 | ## Dependency management 19 | * Adding 20 | * Add version-less package to `requirement-*.txt`file (in case we ever just wanna install everything) and versioned package to `requirements.txt`. 21 | * Updating 22 | * Use [pur](https://github.com/alanhamlett/pip-update-requirements) to update requirements.txt. 23 | * If updating package requirements, update the `setup.py` file. 24 | 25 | ## mmh3 on OSX 26 | If having trouble installing mmh3 on OSX, try: 27 | ```shell 28 | CFLAGS="-mmacosx-version-min=10.13" pip install mmh3 29 | ``` 30 | 31 | ## Release 32 | 1. Land all your PRs on `master`. :) 33 | 1. Update changelog.md and other sundry documentation. 34 | 1. Deploy documents by running `mkdocs gh-deploy` 35 | 1. Run `bumpversion [major/minor/patch]` to generate new version & tag. 36 | 1. Push tag to remotes. 37 | 1. Create new Release in Github and paste in Changelog. 38 | 1. Github Actions workflow will automagically publish to Pypi. ^^ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | .mypy_cache/ 51 | .benchmarks 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # dotenv 87 | .env 88 | 89 | # virtualenv 90 | .venv 91 | venv/ 92 | ENV/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | #Custom 101 | .idea 102 | .vscode 103 | assets 104 | results* 105 | site 106 | -------------------------------------------------------------------------------- /UnleashClient/periodic_tasks/send_metrics.py: -------------------------------------------------------------------------------- 1 | import redis 2 | import pickle 3 | from collections import ChainMap 4 | from datetime import datetime, timezone 5 | from UnleashClient.api import send_metrics 6 | from UnleashClient.constants import METRIC_LAST_SENT_TIME 7 | 8 | 9 | def aggregate_and_send_metrics(url: str, 10 | app_name: str, 11 | instance_id: str, 12 | custom_headers: dict, 13 | custom_options: dict, 14 | features: dict, 15 | ondisk_cache: redis.Redis 16 | ) -> None: 17 | feature_stats_list = [] 18 | 19 | for feature_name in features.keys(): 20 | feature_stats = { 21 | features[feature_name].name: { 22 | "yes": features[feature_name].yes_count, 23 | "no": features[feature_name].no_count 24 | } 25 | } 26 | 27 | features[feature_name].reset_stats() 28 | feature_stats_list.append(feature_stats) 29 | 30 | metric_last_seen_time = pickle.loads( 31 | ondisk_cache.get( 32 | METRIC_LAST_SENT_TIME 33 | ) 34 | ) 35 | 36 | metrics_request = { 37 | "appName": app_name, 38 | "instanceId": instance_id, 39 | "bucket": { 40 | "start": metric_last_seen_time.isoformat(), 41 | "stop": datetime.now(timezone.utc).isoformat(), 42 | "toggles": dict(ChainMap(*feature_stats_list)) 43 | } 44 | } 45 | 46 | send_metrics(url, metrics_request, custom_headers, custom_options) 47 | ondisk_cache.set( 48 | METRIC_LAST_SENT_TIME, 49 | pickle.dumps(datetime.now(timezone.utc)) 50 | ) 51 | -------------------------------------------------------------------------------- /tests/integration_tests/integration_unleashhosted.py: -------------------------------------------------------------------------------- 1 | import time 2 | from UnleashClient import UnleashClient 3 | from UnleashClient.strategies import Strategy 4 | 5 | # --- 6 | class DogTest(Strategy): 7 | def load_provisioning(self) -> list: 8 | return [x.strip() for x in self.parameters["sound"].split(',')] 9 | 10 | def apply(self, context: dict = None) -> bool: 11 | """ 12 | Turn on if I'm a dog. 13 | 14 | :return: 15 | """ 16 | default_value = False 17 | 18 | if "sound" in context.keys(): 19 | default_value = context["sound"] in self.parsed_provisioning 20 | 21 | return default_value 22 | 23 | 24 | # --- 25 | import logging 26 | import sys 27 | 28 | root = logging.getLogger() 29 | root.setLevel(logging.DEBUG) 30 | 31 | handler = logging.StreamHandler(sys.stdout) 32 | handler.setLevel(logging.DEBUG) 33 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 34 | handler.setFormatter(formatter) 35 | root.addHandler(handler) 36 | # --- 37 | 38 | custom_strategies_dict = { 39 | "amIADog": DogTest 40 | } 41 | 42 | my_client = UnleashClient( 43 | url="https://app.unleash-hosted.com/demo/api", 44 | environment="staging", 45 | app_name="pyIvan", 46 | custom_headers={'Authorization': '56907a2fa53c1d16101d509a10b78e36190b0f918d9f122d'}, 47 | custom_strategies=custom_strategies_dict 48 | ) 49 | 50 | my_client.initialize_client() 51 | 52 | while True: 53 | time.sleep(10) 54 | context = { 55 | 'userId': "1", 56 | 'sound': 'woof' 57 | } 58 | print(f"ivantest: {my_client.is_enabled('ivantest', context)}") 59 | print(f"ivan-variations: {my_client.get_variant('ivan-variations', context)}") 60 | print(f"ivan-customstrategyx: {my_client.is_enabled('ivan-customstrategy', context)}") 61 | -------------------------------------------------------------------------------- /tests/unit_tests/periodic/test_aggregate_and_send_metrics.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime, timezone, timedelta 3 | import responses 4 | from fcache.cache import FileCache 5 | from tests.utilities.testing_constants import URL, APP_NAME, INSTANCE_ID, CUSTOM_HEADERS, CUSTOM_OPTIONS, IP_LIST 6 | from UnleashClient.constants import METRICS_URL, METRIC_LAST_SENT_TIME 7 | from UnleashClient.periodic_tasks import aggregate_and_send_metrics 8 | from UnleashClient.features import Feature 9 | from UnleashClient.strategies import RemoteAddress, Default 10 | 11 | 12 | FULL_METRICS_URL = URL + METRICS_URL 13 | print(FULL_METRICS_URL) 14 | 15 | 16 | @responses.activate 17 | def test_aggregate_and_send_metrics(): 18 | responses.add(responses.POST, FULL_METRICS_URL, json={}, status=200) 19 | 20 | start_time = datetime.now(timezone.utc) - timedelta(seconds=60) 21 | cache = FileCache("TestCache") 22 | cache[METRIC_LAST_SENT_TIME] = start_time 23 | strategies = [RemoteAddress(parameters={"IPs": IP_LIST}), Default()] 24 | my_feature1 = Feature("My Feature1", True, strategies) 25 | my_feature1.yes_count = 1 26 | my_feature1.no_count = 1 27 | 28 | my_feature2 = Feature("My Feature2", True, strategies) 29 | my_feature2.yes_count = 2 30 | my_feature2.no_count = 2 31 | 32 | features = {"My Feature1": my_feature1, "My Feature 2": my_feature2} 33 | 34 | aggregate_and_send_metrics(URL, APP_NAME, INSTANCE_ID, CUSTOM_HEADERS, CUSTOM_OPTIONS, features, cache) 35 | 36 | assert len(responses.calls) == 1 37 | request = json.loads(responses.calls[0].request.body) 38 | 39 | assert len(request['bucket']["toggles"].keys()) == 2 40 | assert request['bucket']["toggles"]["My Feature1"]["yes"] == 1 41 | assert request['bucket']["toggles"]["My Feature1"]["no"] == 1 42 | assert cache[METRIC_LAST_SENT_TIME] > start_time 43 | -------------------------------------------------------------------------------- /UnleashClient/constants.py: -------------------------------------------------------------------------------- 1 | # Library 2 | SDK_NAME = "unleash-client-python" 3 | SDK_VERSION = "3.5.0" 4 | REQUEST_TIMEOUT = 30 5 | METRIC_LAST_SENT_TIME = "mlst" 6 | 7 | # =Unleash= 8 | APPLICATION_HEADERS = {"Content-Type": "application/json"} 9 | DISABLED_VARIATION = { 10 | 'name': 'disabled', 11 | 'enabled': False 12 | } 13 | 14 | # Paths 15 | REGISTER_URL = "/client/register" 16 | FEATURES_URL = "/client/features" 17 | METRICS_URL = "/client/metrics" 18 | 19 | 20 | FEATURE_TOGGLES_BASE_URL = "http://128.199.29.137:4242/api" 21 | FEATURE_TOGGLES_APP_NAME = "feature-toggles-poc" 22 | FEATURE_TOGGLES_INSTANCE_ID = "haptik-development-dev-parvez-vm-1" 23 | FEATURE_TOGGLES_ENABLED = False 24 | FEATURE_TOGGLES_CACHE_KEY = "/client/features" 25 | FEATURE_TOGGLES_API_RESPONSE = { 26 | "haptik.development.enable_smart_skills": { 27 | "domain_names": ["test_pvz_superman", "priyanshisupermandefault"], 28 | "business_via_names": ["testpvzsupermanchannel", "priyanshisupermandefaultchannel"], 29 | "partner_names": ["Platform Demo"] 30 | }, 31 | "prestaging.staging.enable_smart_skills": { 32 | "domain_names": ["test_pvz_superman", "priyanshisupermandefault"], 33 | "business_via_names": ["testpvzsupermanchannel", "priyanshisupermandefaultchannel"], 34 | "partner_names": ["Platform Demo"] 35 | }, 36 | "haptik.staging.enable_smart_skills": { 37 | "domain_names": ["test_pvz_superman", "priyanshisupermandefault"], 38 | "business_via_names": ["testpvzsupermanchannel", "priyanshisupermandefaultchannel"], 39 | "partner_names": ["Platform Demo"] 40 | }, 41 | "haptik.production.enable_smart_skills": { 42 | "domain_names": ["test_pvz_superman", "priyanshisupermandefault"], 43 | "business_via_names": ["testpvzsupermanchannel", "priyanshisupermandefaultchannel"], 44 | "partner_names": ["Platform Demo"] 45 | } 46 | } 47 | 48 | -------------------------------------------------------------------------------- /tests/utilities/mocks/mock_custom_strategy.py: -------------------------------------------------------------------------------- 1 | MOCK_CUSTOM_STRATEGY = { 2 | "version": 1, 3 | "features": [ 4 | { 5 | "name": "CustomToggle", 6 | "description": "CustomToggle Test", 7 | "enabled": True, 8 | "strategies": [ 9 | { 10 | "name": "amIACat", 11 | "parameters": { 12 | "sound": "meow,nyaa" 13 | }, 14 | "constraints": [ 15 | { 16 | "contextName": "environment", 17 | "operator": "IN", 18 | "values": [ 19 | "staging", 20 | "prod" 21 | ] 22 | } 23 | ] 24 | } 25 | ], 26 | "createdAt": "2018-10-13T10:15:29.009Z" 27 | }, 28 | { 29 | "name": "CustomToggleWarning", 30 | "description": "CustomToggle Warning Test", 31 | "enabled": True, 32 | "strategies": [ 33 | { 34 | "name": "amIADog", 35 | "parameters": { 36 | "sound": "arf,bark" 37 | } 38 | } 39 | ], 40 | "createdAt": "2018-10-13T10:15:29.009Z" 41 | }, 42 | { 43 | "name": "CustomToggleWarningMultiStrat", 44 | "description": "CustomToggle Warning Test", 45 | "enabled": True, 46 | "strategies": [ 47 | { 48 | "name": "amIADog", 49 | "parameters": { 50 | "sound": "arf,bark" 51 | } 52 | }, 53 | { 54 | "name": "default", 55 | "parameters": {} 56 | } 57 | ], 58 | "createdAt": "2018-10-13T10:15:29.009Z" 59 | }, 60 | { 61 | "name": "UserWithId", 62 | "description": "UserWithId", 63 | "enabled": True, 64 | "strategies": [ 65 | { 66 | "name": "userWithId", 67 | "parameters": { 68 | "userIds": "meep@meep.com,test@test.com,ivan@ivan.com" 69 | } 70 | } 71 | ], 72 | "createdAt": "2018-10-11T09:33:51.171Z" 73 | } 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /UnleashClient/strategies/RemoteAddress.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | from UnleashClient.strategies.Strategy import Strategy 3 | from UnleashClient.utils import LOGGER 4 | 5 | 6 | class RemoteAddress(Strategy): 7 | def load_provisioning(self) -> list: 8 | parsed_ips = [] 9 | 10 | for address in self.parameters["IPs"].split(','): 11 | 12 | if "/" in address: 13 | try: 14 | parsed_ips.append(ipaddress.ip_network(address.strip(), strict=True)) # type: ignore 15 | except (ipaddress.AddressValueError, ipaddress.NetmaskValueError, ValueError) as parsing_error: 16 | LOGGER.warning("Error parsing IP range: %s", parsing_error) 17 | else: 18 | try: 19 | parsed_ips.append(ipaddress.ip_address(address.strip())) # type: ignore 20 | except (ipaddress.AddressValueError, ipaddress.NetmaskValueError, ValueError) as parsing_error: 21 | LOGGER.warning("Error parsing IP : %s", parsing_error) 22 | 23 | return parsed_ips 24 | 25 | def apply(self, context: dict = None) -> bool: 26 | """ 27 | Returns true if IP is in list of IPs 28 | 29 | :return: 30 | """ 31 | return_value = False 32 | 33 | try: 34 | context_ip = ipaddress.ip_address(context["remoteAddress"]) 35 | except (ipaddress.AddressValueError, ipaddress.NetmaskValueError, ValueError) as parsing_error: 36 | LOGGER.warning("Error parsing IP : %s", parsing_error) 37 | context_ip = None 38 | 39 | if context_ip: 40 | for addr_or_range in [value for value in self.parsed_provisioning if value.version == context_ip.version]: 41 | if isinstance(addr_or_range, (ipaddress.IPv4Address, ipaddress.IPv6Address)): 42 | if context_ip == addr_or_range: 43 | return_value = True 44 | break 45 | else: 46 | if context_ip in addr_or_range: 47 | return_value = True 48 | break 49 | 50 | return return_value 51 | -------------------------------------------------------------------------------- /tests/unit_tests/test_variants.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from UnleashClient.variants import Variants 3 | from tests.utilities.mocks.mock_variants import VARIANTS 4 | 5 | 6 | @pytest.fixture() 7 | def variations(): 8 | yield Variants(VARIANTS, "TestFeature") 9 | 10 | 11 | def test_variations_override_match(variations): 12 | override_variant = variations._apply_overrides({'userId': '1'}) 13 | assert override_variant['name'] == 'VarA' 14 | 15 | 16 | def test_variations_overrid_nomatch(variations): 17 | assert not variations._apply_overrides({'userId': '2'}) 18 | 19 | 20 | def test_variations_seed(variations): 21 | # Random seed generation 22 | context = {} 23 | seed = variations._get_seed(context) 24 | assert float(seed) > 0 25 | 26 | # UserId, SessionId, and remoteAddress 27 | context = { 28 | 'userId': '1', 29 | 'sessionId': '1', 30 | 'remoteAddress': '1.1.1.1' 31 | } 32 | 33 | assert context['userId'] == variations._get_seed(context) 34 | del context['userId'] 35 | assert context['sessionId'] == variations._get_seed(context) 36 | del context['sessionId'] 37 | assert context['remoteAddress'] == variations._get_seed(context) 38 | 39 | 40 | def test_variation_selectvariation_happypath(variations): 41 | variant = variations.get_variant({'userId': '2'}) 42 | assert variant 43 | assert 'payload' in variant 44 | assert variant['name'] == 'VarC' 45 | 46 | 47 | def test_variation_selectvariation_multi(variations): 48 | tracker = {} 49 | for x in range(100): 50 | variant = variations.get_variant({}) 51 | name = variant['name'] 52 | if name in tracker: 53 | tracker[name] += 1 54 | else: 55 | tracker[name] = 1 56 | 57 | assert len(tracker) == 3 58 | assert sum([tracker[x] for x in tracker.keys()]) == 100 59 | 60 | 61 | def test_variation_override(variations): 62 | variant = variations.get_variant({'userId': '1'}) 63 | assert variant 64 | assert 'payload' in variant 65 | assert variant['name'] == 'VarA' 66 | 67 | 68 | def test_variation_novariants(): 69 | variations = Variants([], "TestFeature") 70 | variant = variations.get_variant({}) 71 | assert variant 72 | assert variant['name'] == 'disabled' 73 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | main: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - name: Set up Python 3.7 11 | uses: actions/setup-python@v1 12 | with: 13 | python-version: 3.7 14 | - name: Install dependencies 15 | run: | 16 | python -m pip install --upgrade pip 17 | pip install -r requirements.txt 18 | - name: Linting 19 | run: | 20 | mypy UnleashClient 21 | pylint UnleashClient 22 | - name: Unit tests 23 | run: | 24 | py.test --html=pytest/results.html --junitxml=pytest/results.xml --self-contained-html --cov=UnleashClient tests/unit_tests 25 | - name: Specification tests 26 | run: | 27 | py.test tests/specification_tests 28 | - name: Send coverage to Coveralls 29 | env: 30 | COVERALLS_REPO_TOKEN: ${{ secrets.coveralls_repo_token }} 31 | run: | 32 | coveralls 33 | - name: Notify Slack of pipeline completion 34 | uses: 8398a7/action-slack@v2 35 | with: 36 | status: ${{ job.status }} 37 | author_name: Github Action 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.github_slack_token }} 40 | SLACK_WEBHOOK_URL: ${{ secrets.slack_webhook }} 41 | if: always() 42 | 43 | tox: 44 | runs-on: ubuntu-latest 45 | strategy: 46 | matrix: 47 | os: [ubuntu-latest, macos-latest, windows-latest] 48 | python: [3.5, 3.6, 3.7, 3.8] 49 | steps: 50 | - uses: actions/checkout@v1 51 | - name: Setup Python 52 | uses: actions/setup-python@v1 53 | with: 54 | python-version: ${{ matrix.python }} 55 | - name: Install Tox and any other packages 56 | run: | 57 | python -m pip install --upgrade pip 58 | pip install tox 59 | - name: Run Tox 60 | run: tox -e py 61 | - name: Notify Slack of pipeline completion 62 | uses: 8398a7/action-slack@v2 63 | with: 64 | status: ${{ job.status }} 65 | author_name: Github Action 66 | env: 67 | GITHUB_TOKEN: ${{ secrets.github_slack_token }} 68 | SLACK_WEBHOOK_URL: ${{ secrets.slack_webhook }} 69 | if: failure() 70 | -------------------------------------------------------------------------------- /UnleashClient/api/register.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime, timezone 3 | import requests 4 | from UnleashClient.constants import SDK_NAME, SDK_VERSION, REQUEST_TIMEOUT, APPLICATION_HEADERS, REGISTER_URL 5 | from UnleashClient.utils import LOGGER, log_resp_info 6 | 7 | 8 | # pylint: disable=broad-except 9 | def register_client(url: str, 10 | app_name: str, 11 | instance_id: str, 12 | metrics_interval: int, 13 | custom_headers: dict, 14 | custom_options: dict, 15 | supported_strategies: dict) -> bool: 16 | """ 17 | Attempts to register client with unleash server. 18 | 19 | Notes: 20 | * If unsuccessful (i.e. not HTTP status code 202), exception will be caught and logged. 21 | This is to allow "safe" error handling if unleash server goes down. 22 | 23 | :param url: 24 | :param app_name: 25 | :param instance_id: 26 | :param metrics_interval: 27 | :param custom_headers: 28 | :param custom_options: 29 | :param supported_strategies: 30 | :return: true if registration successful, false if registration unsuccessful or exception. 31 | """ 32 | registation_request = { 33 | "appName": app_name, 34 | "instanceId": instance_id, 35 | "sdkVersion": "{}:{}".format(SDK_NAME, SDK_VERSION), 36 | "strategies": [*supported_strategies], 37 | "started": datetime.now(timezone.utc).isoformat(), 38 | "interval": metrics_interval 39 | } 40 | 41 | try: 42 | LOGGER.info("Registering unleash client with unleash @ %s", url) 43 | LOGGER.info("Registration request information: %s", registation_request) 44 | 45 | resp = requests.post(url + REGISTER_URL, 46 | data=json.dumps(registation_request), 47 | headers={**custom_headers, **APPLICATION_HEADERS}, 48 | timeout=REQUEST_TIMEOUT, **custom_options) 49 | 50 | if resp.status_code != 202: 51 | log_resp_info(resp) 52 | LOGGER.warning("Unleash Client registration failed due to unexpected HTTP status code.") 53 | return False 54 | 55 | LOGGER.info("Unleash Client successfully registered!") 56 | 57 | return True 58 | except Exception: 59 | LOGGER.exception("Unleash Client registration failed due to exception: %s", Exception) 60 | 61 | return False 62 | -------------------------------------------------------------------------------- /tests/unit_tests/periodic/test_fetch_and_load.py: -------------------------------------------------------------------------------- 1 | import responses 2 | from UnleashClient.constants import FEATURES_URL 3 | from UnleashClient.periodic_tasks import fetch_and_load_features 4 | from UnleashClient.features import Feature 5 | from tests.utilities.mocks.mock_features import MOCK_FEATURE_RESPONSE 6 | from tests.utilities.testing_constants import URL, APP_NAME, INSTANCE_ID, CUSTOM_HEADERS, CUSTOM_OPTIONS, DEFAULT_STRATEGY_MAPPING 7 | from tests.utilities.decorators import cache_empty # noqa: F401 8 | 9 | 10 | FULL_FEATURE_URL = URL + FEATURES_URL 11 | 12 | 13 | @responses.activate 14 | def test_fetch_and_load(cache_empty): # noqa: F811 15 | # Set up for tests 16 | in_memory_features = {} 17 | responses.add(responses.GET, FULL_FEATURE_URL, json=MOCK_FEATURE_RESPONSE, status=200) 18 | temp_cache = cache_empty 19 | 20 | fetch_and_load_features(URL, 21 | APP_NAME, 22 | INSTANCE_ID, 23 | CUSTOM_HEADERS, 24 | CUSTOM_OPTIONS, 25 | temp_cache, 26 | in_memory_features, 27 | DEFAULT_STRATEGY_MAPPING) 28 | 29 | assert isinstance(in_memory_features["testFlag"], Feature) 30 | 31 | 32 | @responses.activate 33 | def test_fetch_and_load_failure(cache_empty): # noqa: F811 34 | # Set up for tests 35 | in_memory_features = {} 36 | responses.add(responses.GET, FULL_FEATURE_URL, json=MOCK_FEATURE_RESPONSE, status=200) 37 | temp_cache = cache_empty 38 | 39 | fetch_and_load_features(URL, 40 | APP_NAME, 41 | INSTANCE_ID, 42 | CUSTOM_HEADERS, 43 | CUSTOM_OPTIONS, 44 | temp_cache, 45 | in_memory_features, 46 | DEFAULT_STRATEGY_MAPPING) 47 | 48 | # Fail next request 49 | responses.reset() 50 | responses.add(responses.GET, FULL_FEATURE_URL, json={}, status=500) 51 | 52 | fetch_and_load_features(URL, 53 | APP_NAME, 54 | INSTANCE_ID, 55 | CUSTOM_HEADERS, 56 | CUSTOM_OPTIONS, 57 | temp_cache, 58 | in_memory_features, 59 | DEFAULT_STRATEGY_MAPPING) 60 | 61 | assert isinstance(in_memory_features["testFlag"], Feature) 62 | -------------------------------------------------------------------------------- /tests/unit_tests/test_features.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from UnleashClient.features import Feature 3 | from UnleashClient.strategies import RemoteAddress, UserWithId, Default 4 | from UnleashClient.variants import Variants 5 | from tests.utilities import generate_email_list 6 | from tests.utilities.testing_constants import IP_LIST 7 | from tests.utilities.mocks.mock_variants import VARIANTS 8 | 9 | 10 | (EMAIL_LIST, CONTEXT) = generate_email_list(20) 11 | 12 | 13 | @pytest.fixture() 14 | def test_feature(): 15 | strategies = [RemoteAddress(parameters={"IPs": IP_LIST}), UserWithId(parameters={"userIds": EMAIL_LIST})] 16 | yield Feature("My Feature", True, strategies) 17 | 18 | 19 | @pytest.fixture() 20 | def test_feature_variants(): 21 | strategies = [Default()] 22 | variants = Variants(VARIANTS, "My Feature") 23 | yield Feature("My Feature", True, strategies, variants) 24 | 25 | 26 | def test_create_feature_true(test_feature): 27 | my_feature = test_feature 28 | 29 | CONTEXT["remoteAddress"] = "69.208.0.1" 30 | assert my_feature.is_enabled(CONTEXT) 31 | assert my_feature.yes_count == 1 32 | 33 | my_feature.reset_stats() 34 | assert my_feature.yes_count == 0 35 | 36 | 37 | def test_create_feature_false(test_feature): 38 | my_feature = test_feature 39 | 40 | CONTEXT["remoteAddress"] = "1.208.0.1" 41 | CONTEXT["userId"] = "random@random.com" 42 | assert not my_feature.is_enabled(CONTEXT) 43 | assert my_feature.no_count == 1 44 | 45 | my_feature.reset_stats() 46 | assert my_feature.no_count == 0 47 | 48 | 49 | def test_create_feature_not_enabled(test_feature): 50 | my_feature = test_feature 51 | my_feature.enabled = False 52 | 53 | CONTEXT["remoteAddress"] = "69.208.0.1" 54 | assert not my_feature.is_enabled(CONTEXT) 55 | 56 | 57 | def test_create_feature_exception(test_feature): 58 | strategies = [{}, UserWithId(parameters={"userIds": EMAIL_LIST})] 59 | my_feature = Feature("My Feature", True, strategies) 60 | 61 | CONTEXT["remoteAddress"] = "69.208.0.1" 62 | assert not my_feature.is_enabled(CONTEXT) 63 | 64 | 65 | def test_select_variation_novariation(test_feature): 66 | selected_variant = test_feature.get_variant() 67 | assert type(selected_variant) == dict 68 | assert selected_variant['name'] == 'disabled' 69 | 70 | 71 | def test_select_variation_variation(test_feature_variants): 72 | selected_variant = test_feature_variants.get_variant({'userId': '2'}) 73 | assert selected_variant['enabled'] 74 | assert selected_variant['name'] == 'VarB' 75 | -------------------------------------------------------------------------------- /tests/unit_tests/test_loader.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from UnleashClient.loader import load_features 3 | from UnleashClient.features import Feature 4 | from UnleashClient.strategies import GradualRolloutUserId, FlexibleRollout, UserWithId 5 | from UnleashClient.variants import Variants 6 | from UnleashClient.constants import FEATURES_URL 7 | from tests.utilities.mocks import MOCK_ALL_FEATURES 8 | from tests.utilities.testing_constants import DEFAULT_STRATEGY_MAPPING 9 | from tests.utilities.decorators import cache_full, cache_custom # noqa: F401 10 | 11 | MOCK_UPDATED = copy.deepcopy(MOCK_ALL_FEATURES) 12 | MOCK_UPDATED["features"][4]["strategies"][0]["parameters"]["percentage"] = 60 13 | 14 | 15 | def test_loader_initialization(cache_full): # noqa: F811 16 | # Set up variables 17 | in_memory_features = {} 18 | temp_cache = cache_full 19 | 20 | # Tests 21 | load_features(temp_cache, in_memory_features, DEFAULT_STRATEGY_MAPPING) 22 | assert isinstance(in_memory_features["GradualRolloutUserID"], Feature) 23 | assert isinstance(in_memory_features["GradualRolloutUserID"].strategies[0], GradualRolloutUserId) 24 | 25 | for feature_name in in_memory_features.keys(): 26 | feature = in_memory_features[feature_name] 27 | assert len(feature.strategies) > 0 28 | strategy = feature.strategies[0] 29 | 30 | if isinstance(strategy, UserWithId): 31 | assert strategy.parameters 32 | assert len(strategy.parsed_provisioning) 33 | 34 | if isinstance(strategy, FlexibleRollout): 35 | len(strategy.parsed_constraints) > 0 36 | 37 | if isinstance(strategy, Variants): 38 | assert strategy.variants 39 | 40 | 41 | def test_loader_refresh(cache_full): # noqa: F811 42 | # Set up variables 43 | in_memory_features = {} 44 | temp_cache = cache_full 45 | 46 | load_features(temp_cache, in_memory_features, DEFAULT_STRATEGY_MAPPING) 47 | 48 | # Simulate update mutation 49 | temp_cache[FEATURES_URL] = MOCK_UPDATED 50 | temp_cache.sync() 51 | 52 | load_features(temp_cache, in_memory_features, DEFAULT_STRATEGY_MAPPING) 53 | 54 | assert in_memory_features["GradualRolloutUserID"].strategies[0].parameters["percentage"] == 60 55 | 56 | 57 | def test_loader_initialization_failure(cache_custom): # noqa: F811 58 | # Set up variables 59 | in_memory_features = {} 60 | temp_cache = cache_custom 61 | 62 | # Tests 63 | load_features(temp_cache, in_memory_features, DEFAULT_STRATEGY_MAPPING) 64 | assert isinstance(in_memory_features["UserWithId"], Feature) 65 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Transcriptase 2 | We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: 3 | 4 | - Reporting a bug 5 | - Discussing the current state of the code 6 | - Submitting a fix 7 | - Proposing new features 8 | - Becoming a maintainer 9 | 10 | ## We Develop with Github 11 | We use github to host code, to track issues and feature requests, as well as accept pull requests. 12 | 13 | ## We Use [Github Flow](https://guides.github.com/introduction/flow/index.html), So All Code Changes Happen Through Pull Requests 14 | Pull requests are the best way to propose changes to the codebase (we use [Github Flow](https://guides.github.com/introduction/flow/index.html)). We actively welcome your pull requests: 15 | 16 | 1. Fork the repo and create your branch from `master`. 17 | 2. If you've added code that should be tested, add tests. 18 | 3. If you've changed APIs, update the documentation. 19 | 4. Ensure the test suite passes. 20 | 5. Make sure your code lints. 21 | 6. Issue that pull request! 22 | 23 | ## Any contributions you make will be under the MIT Software License 24 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 25 | 26 | ## Report bugs using Github's [issues](https://github.com/briandk/transcriptase-atom/issues) 27 | We use GitHub issues to track public bugs. Report a bug by [opening a new issue](); it's that easy! 28 | 29 | ## Write bug reports with detail, background, and sample code 30 | Here's [an example of a great bug report](http://www.openradar.me/11905408). 31 | 32 | **Great Bug Reports** tend to have: 33 | 34 | - A quick summary and/or background 35 | - Steps to reproduce 36 | - Be specific! 37 | - Give sample code if you can. 38 | - What you expected would happen 39 | - What actually happens 40 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 41 | 42 | People *love* thorough bug reports. I'm not even kidding. 43 | 44 | ## Use a Consistent Coding Style 45 | We use the following static analysis tools to help us keep our codebase clean. 46 | * PEP 8 47 | * Pylint 48 | * Mypy 49 | 50 | These *aren't* the final word ("A foolish consistency is the hobgoblin of little minds..." and all that. We should ignore (via configuration/comments/etc) errors to keep tool outputs clean. 51 | 52 | ## License 53 | By contributing, you agree that your contributions will be licensed under its MIT License. 54 | 55 | ## References 56 | This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/a9316a723f9e918afde44dea68b5f9f39b7d9b00/CONTRIBUTING.md) 57 | -------------------------------------------------------------------------------- /UnleashClient/strategies/Strategy.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=dangerous-default-value 2 | import warnings 3 | from UnleashClient.constraints import Constraint 4 | 5 | 6 | class Strategy: 7 | """ 8 | The parent class for default and custom strategies. 9 | 10 | In general, default & custom classes should only need to override: 11 | * __init__() - Depending on the parameters your feature needs 12 | * apply() - Your feature provisioning 13 | """ 14 | def __init__(self, 15 | constraints: list = [], 16 | parameters: dict = {}, 17 | ) -> None: 18 | """ 19 | A generic strategy objects. 20 | 21 | :param constraints: List of 'constraints' objects derived from strategy section (...from feature section) of 22 | /api/clients/features response 23 | :param parameters: The 'parameter' objects from the strategy section (...from feature section) of 24 | /api/clients/features response 25 | """ 26 | self.parameters = parameters 27 | self.constraints = constraints 28 | self.parsed_constraints = self.load_constraints(constraints) 29 | self.parsed_provisioning = self.load_provisioning() 30 | 31 | def __call__(self, context: dict = None): 32 | warnings.warn( 33 | "unleash-client-python v3.x.x requires overriding the execute() method instead of the __call__() method.", 34 | DeprecationWarning 35 | ) 36 | 37 | def execute(self, context: dict = None) -> bool: 38 | """ 39 | Executes the strategies by: 40 | - Checking constraints 41 | - Applying the strategy 42 | 43 | :param context: Context information 44 | :return: 45 | """ 46 | flag_state = False 47 | 48 | if all([constraint.apply(context) for constraint in self.parsed_constraints]): 49 | flag_state = self.apply(context) 50 | 51 | return flag_state 52 | 53 | def load_constraints(self, constraints_list: list) -> list: #pylint: disable=R0201 54 | """ 55 | Loads constraints from provisioning. 56 | 57 | :return: 58 | """ 59 | parsed_constraints_list = [] 60 | 61 | for constraint_dict in constraints_list: 62 | parsed_constraints_list.append(Constraint(constraint_dict=constraint_dict)) 63 | 64 | return parsed_constraints_list 65 | 66 | # pylint: disable=no-self-use 67 | def load_provisioning(self) -> list: 68 | """ 69 | Method to load data on object initialization, if desired. 70 | 71 | This should parse the raw values in self.parameters into format Python can comprehend. 72 | """ 73 | return [] 74 | 75 | def apply(self, context: dict = None) -> bool: #pylint: disable=W0613,R0201 76 | """ 77 | Strategy implementation goes here. 78 | 79 | :param context: 80 | :return: 81 | """ 82 | return False 83 | -------------------------------------------------------------------------------- /.circleci-archive/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | references: 4 | install_tox: &install_tox 5 | run: 6 | name: "Install tox" 7 | command: | 8 | python3 -m venv venv 9 | . venv/bin/activate 10 | pip install tox 11 | 12 | jobs: 13 | test: 14 | docker: 15 | - image: circleci/python:3.7 16 | steps: 17 | - checkout 18 | - run: 19 | name: Install dependencies. 20 | command: | 21 | python3 -m venv venv 22 | . venv/bin/activate 23 | pip install -r requirements.txt 24 | - run: 25 | name: Lint code. 26 | command: | 27 | . venv/bin/activate 28 | mypy UnleashClient 29 | pylint UnleashClient 30 | - run: 31 | name: Run unit tests. 32 | command: | 33 | . venv/bin/activate 34 | py.test --html=pytest/results.html --junitxml=pytest/results.xml --self-contained-html --cov=UnleashClient tests/unit_tests 35 | - run: 36 | name: Run specification tests. 37 | command: | 38 | . venv/bin/activate 39 | py.test tests/specification_tests 40 | - store_test_results: 41 | path: pytest 42 | - run: 43 | name: Send code coverage to Coveralls 44 | command: | 45 | . venv/bin/activate 46 | coveralls 47 | tox-3.5: 48 | docker: 49 | - image: circleci/python:3.5 50 | steps: 51 | - checkout 52 | - *install_tox 53 | - run: 54 | name: "Run tox for Python 3.5" 55 | command: | 56 | . venv/bin/activate 57 | tox -e py35 58 | 59 | tox-3.6: 60 | docker: 61 | - image: circleci/python:3.6 62 | steps: 63 | - checkout 64 | - *install_tox 65 | - run: 66 | name: "Run tox for Python 3.6" 67 | command: | 68 | . venv/bin/activate 69 | tox -e py36 70 | 71 | tox-3.8: 72 | docker: 73 | - image: circleci/python:3.8-rc 74 | steps: 75 | - checkout 76 | - *install_tox 77 | - run: 78 | name: "Run tox for Python 3.8" 79 | command: | 80 | . venv/bin/activate 81 | tox -e py38 82 | 83 | workflows: 84 | version: 2 85 | build_and_test: 86 | jobs: 87 | - test 88 | - tox-3.5: 89 | requires: 90 | - test 91 | - tox-3.6: 92 | requires: 93 | - test 94 | - tox-3.8: 95 | requires: 96 | - test 97 | -------------------------------------------------------------------------------- /tests/utilities/mocks/mock_features.py: -------------------------------------------------------------------------------- 1 | MOCK_FEATURE_RESPONSE = { 2 | "version": 1, 3 | "features": [ 4 | { 5 | "name": "testFlag", 6 | "description": "This is a test!", 7 | "enabled": True, 8 | "strategies": [ 9 | { 10 | "name": "default", 11 | "parameters": {} 12 | } 13 | ], 14 | "createdAt": "2018-10-04T01:27:28.477Z" 15 | }, 16 | { 17 | "name": "testFlag2", 18 | "description": "Test flag 2", 19 | "enabled": True, 20 | "strategies": [ 21 | { 22 | "name": "gradualRolloutRandom", 23 | "parameters": { 24 | "percentage": 50 25 | } 26 | } 27 | ], 28 | "createdAt": "2018-10-04T11:03:56.062Z" 29 | }, 30 | { 31 | "name": "testContextFlag", 32 | "description": "This is a test for static context fileds!", 33 | "enabled": True, 34 | "strategies": [ 35 | { 36 | "name": "custom-context", 37 | "parameters": { 38 | "environments": "prod" 39 | } 40 | } 41 | ], 42 | "createdAt": "2018-10-04T01:27:28.477Z" 43 | }, 44 | { 45 | "name": "testVariations", 46 | "description": "Test variation", 47 | "enabled": True, 48 | "strategies": [ 49 | { 50 | "name": "userWithId", 51 | "parameters": { 52 | "userIds": "2" 53 | } 54 | } 55 | ], 56 | "variants": [ 57 | { 58 | "name": "VarA", 59 | "weight": 34, 60 | "payload": { 61 | "type": "string", 62 | "value": "Test1" 63 | }, 64 | "overrides": [ 65 | { 66 | "contextName": "userId", 67 | "values": [ 68 | "ivanklee86@gmail.com", 69 | "ivan@aaptiv.com" 70 | ] 71 | } 72 | ] 73 | }, 74 | { 75 | "name": "VarB", 76 | "weight": 33, 77 | "payload": { 78 | "type": "string", 79 | "value": "Test 2" 80 | } 81 | }, 82 | { 83 | "name": "VarC", 84 | "weight": 33, 85 | "payload": { 86 | "type": "string", 87 | "value": "Test 3" 88 | } 89 | } 90 | ], 91 | "createdAt": "2019-10-25T13:22:02.035Z" 92 | } 93 | ] 94 | } 95 | -------------------------------------------------------------------------------- /FeatureToggle/redis_utils.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import redis 4 | from redis.sentinel import Sentinel 5 | 6 | 7 | class RedisConnector: 8 | """ 9 | Utility Redis connector class to help with generating Redis sentinel and non-sentinel connection 10 | """ 11 | @staticmethod 12 | def get_sentinel_connection(sentinels: list, sentinel_service_name: str, redis_db: int, 13 | redis_auth_enabled: Optional[bool] = False, redis_password: Optional[str] = None): 14 | """ 15 | Generates the Redis sentinel connection 16 | :param sentinels: 17 | :param sentinel_service_name: 18 | :param redis_auth_enabled: 19 | :param redis_password: 20 | :param redis_db: 21 | :return: Redis 22 | """ 23 | if not all([sentinels, sentinel_service_name]): 24 | raise ValueError( 25 | "[get_sentinel_connection] Mandatory args for Redis Sentinel are missing." 26 | "Required Args: (sentinels, sentinel_service_name)" 27 | ) 28 | if redis_auth_enabled and not redis_password: 29 | raise ValueError("[get_sentinel_connection] Redis Auth enabled but Redis Password not provided.") 30 | 31 | if redis_auth_enabled and redis_password: 32 | sentinel = Sentinel(sentinels, sentinel_kwargs={"password": redis_password}) 33 | sentinel_connection_pool = sentinel.master_for(sentinel_service_name, password=redis_password, db=redis_db) 34 | else: 35 | sentinel = Sentinel(sentinels) 36 | sentinel_connection_pool = sentinel.master_for(sentinel_service_name, db=redis_db) 37 | return sentinel_connection_pool 38 | 39 | @staticmethod 40 | def get_non_sentinel_connection(redis_host: str, redis_port: int, redis_db: int, 41 | redis_auth_enabled: Optional[bool] = False, 42 | redis_password: Optional[str] = None): 43 | """ 44 | Generates the Redis non-sentinel connection 45 | :param redis_host: 46 | :param redis_port: 47 | :param redis_db: 48 | :param redis_auth_enabled: 49 | :param redis_password: 50 | :return: Redis>> 51 | """ 52 | if redis_auth_enabled and not redis_password: 53 | raise ValueError("[get_non_sentinel_connection] Redis Auth enabled but Redis Password not provided.") 54 | 55 | if redis_auth_enabled and redis_password: 56 | non_sentinel_connection_pool = redis.Redis( 57 | host=redis_host, 58 | port=redis_port, 59 | db=redis_db, 60 | password=redis_password 61 | ) 62 | else: 63 | non_sentinel_connection_pool = redis.Redis( 64 | host=redis_host, 65 | port=redis_port, 66 | db=redis_db 67 | ) 68 | return non_sentinel_connection_pool 69 | -------------------------------------------------------------------------------- /UnleashClient/variants/Variants.py: -------------------------------------------------------------------------------- 1 | import random 2 | import copy 3 | from typing import Dict 4 | from UnleashClient import utils 5 | from UnleashClient.constants import DISABLED_VARIATION 6 | 7 | 8 | class Variants(): 9 | def __init__(self, variants_list: list, feature_name: str) -> None: 10 | """ 11 | Represents an A/B test 12 | 13 | variants_list = From the strategy document. 14 | """ 15 | self.variants = variants_list 16 | self.feature_name = feature_name 17 | 18 | def _apply_overrides(self, context: dict) -> dict: 19 | """ 20 | Figures out if an override should be applied based on a context. 21 | 22 | Notes: 23 | - This matches only the first variant found. 24 | """ 25 | variants_with_overrides = [x for x in self.variants if 'overrides' in x.keys()] 26 | override_variant = {} # type: Dict 27 | 28 | for variant in variants_with_overrides: 29 | for override in variant['overrides']: 30 | identifier = utils.get_identifier(override['contextName'], context) 31 | if identifier in override["values"]: 32 | override_variant = variant 33 | 34 | return override_variant 35 | 36 | @staticmethod 37 | def _get_seed(context: dict) -> str: 38 | """ 39 | Grabs seed value from context. 40 | """ 41 | seed = str(random.random() * 10000) 42 | 43 | if 'userId' in context: 44 | seed = context['userId'] 45 | elif 'sessionId' in context: 46 | seed = context['sessionId'] 47 | elif 'remoteAddress' in context: 48 | seed = context['remoteAddress'] 49 | 50 | return seed 51 | 52 | @staticmethod 53 | def _format_variation(variation: dict) -> dict: 54 | formatted_variation = copy.deepcopy(variation) 55 | del formatted_variation['weight'] 56 | if 'overrides' in formatted_variation: 57 | del formatted_variation['overrides'] 58 | return formatted_variation 59 | 60 | def get_variant(self, context: dict) -> dict: 61 | """ 62 | Determines what variation a user is in. 63 | 64 | :param context: 65 | :return: 66 | """ 67 | fallback_variant = copy.deepcopy(DISABLED_VARIATION) 68 | 69 | if self.variants: 70 | override_variant = self._apply_overrides(context) 71 | if override_variant: 72 | return self._format_variation(override_variant) 73 | 74 | total_weight = sum([x['weight'] for x in self.variants]) 75 | if total_weight <= 0: 76 | return fallback_variant 77 | 78 | target = utils.normalized_hash(self._get_seed(context), self.feature_name, total_weight) 79 | counter = 0 80 | for variation in self.variants: 81 | counter += variation['weight'] 82 | 83 | if counter >= target: 84 | return self._format_variation(variation) 85 | 86 | # Catch all return. 87 | return fallback_variant 88 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # unleash-client-python 2 | 3 | Welcome to the Unleash Python client documentation! This folder contains documentation related to the project. 4 | 5 | ## Installation 6 | 7 | Check out the package on [Pypi](https://pypi.org/project/UnleashClient/)! 8 | 9 | ``` 10 | pip install UnleashClient 11 | ``` 12 | 13 | ## Initialization 14 | 15 | ``` 16 | from UnleashClient import UnleashClient 17 | client = UnleashClient("https://unleash.herokuapp.com/api", "My Program") 18 | client.initialize_client() 19 | ``` 20 | 21 | To clean up gracefully: 22 | ``` 23 | client.destroy() 24 | ``` 25 | 26 | ## Checking if a feature is enabled 27 | 28 | A check of a simple toggle: 29 | ```Python 30 | client.is_enabled("My Toggle") 31 | ``` 32 | 33 | Specifying a default value: 34 | ```Python 35 | client.is_enabled("My Toggle", default_value=True) 36 | ``` 37 | 38 | Supplying application context: 39 | ```Python 40 | app_context = {"userId": "test@email.com"} 41 | client.is_enabled("User ID Toggle", app_context) 42 | ``` 43 | 44 | Supplying a fallback function: 45 | ```Python 46 | def custom_fallback(feature_name: str, context: dict) -> bool: 47 | return True 48 | 49 | client.is_enabled("My Toggle", fallback_function=custom_fallback) 50 | ``` 51 | 52 | - Must accept the fature name and context as an argument. 53 | - Client will evaluate the fallback function only if exception occurs when calling the `is_enabled()` method i.e. feature flag not found or other general exception. 54 | - If both a `default_value` and `fallback_function` are supplied, client will define the default value by `OR`ing the default value and the output of the fallback function. 55 | 56 | ## Getting a variant 57 | 58 | Checking for a variant: 59 | ```python 60 | context = {'userId': '2'} # Context must have userId, sessionId, or remoteAddr. If none are present, distribution will be random. 61 | 62 | variant = client.get_variant("MyvariantToggle", context) 63 | 64 | print(variant) 65 | > { 66 | > "name": "variant1", 67 | > "payload": { 68 | > "type": "string", 69 | > "value": "val1" 70 | > }, 71 | > "enabled": True 72 | > } 73 | ``` 74 | 75 | `select_variant()` supports the same arguments (i.e. fallback functions) as the `is_enabled()` method. 76 | 77 | For more information about variants, see the [Beta feature documentation](https://unleash.github.io/docs/beta_features). 78 | 79 | ## Logging 80 | 81 | Unleash Client uses the built-in logging facility to show information about errors, background jobs (feature-flag updates and metrics), et cetera. 82 | 83 | It's highly recommended that users implement 84 | 85 | To see what's going on when PoCing code, you can use the following: 86 | ```python 87 | import logging 88 | import sys 89 | 90 | root = logging.getLogger() 91 | root.setLevel(logging.INFO) 92 | 93 | handler = logging.StreamHandler(sys.stdout) 94 | handler.setLevel(logging.DEBUG) 95 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 96 | handler.setFormatter(formatter) 97 | root.addHandler(handler) 98 | 99 | ``` 100 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at ivanklee86@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /UnleashClient/features/Feature.py: -------------------------------------------------------------------------------- 1 | from UnleashClient.variants import Variants 2 | from UnleashClient.utils import LOGGER 3 | from UnleashClient.constants import DISABLED_VARIATION 4 | 5 | 6 | # pylint: disable=dangerous-default-value, broad-except 7 | class Feature: 8 | def __init__(self, 9 | name: str, 10 | enabled: bool, 11 | strategies: list, 12 | variants: Variants = None) -> None: 13 | """ 14 | An representation of a feature object 15 | 16 | :param name: Name of the feature. 17 | :param enabled: Whether feature is enabled. 18 | :param strategies: List of sub-classed Strategy objects representing feature strategies. 19 | """ 20 | # Experiment information 21 | self.name = name 22 | self.enabled = enabled 23 | self.strategies = strategies 24 | self.variations = variants 25 | 26 | # Stats tracking 27 | self.yes_count = 0 28 | self.no_count = 0 29 | 30 | def reset_stats(self) -> None: 31 | """ 32 | Resets stats after metrics reporting 33 | 34 | :return: 35 | """ 36 | self.yes_count = 0 37 | self.no_count = 0 38 | 39 | def increment_stats(self, result: bool) -> None: 40 | """ 41 | Increments stats. 42 | 43 | :param result: 44 | :return: 45 | """ 46 | if result: 47 | self.yes_count += 1 48 | else: 49 | self.no_count += 1 50 | 51 | def is_enabled(self, 52 | context: dict = None, 53 | default_value: bool = False) -> bool: # pylint: disable=W0613 54 | """ 55 | Checks if feature is enabled. 56 | 57 | :param context: Context information 58 | :param default_value: Deprecated! Users should use the fallback_function on the main is_enabled() method. 59 | :return: 60 | """ 61 | flag_value = False 62 | 63 | if self.enabled: 64 | try: 65 | if self.strategies: 66 | strategy_result = any([x.execute(context) for x in self.strategies]) 67 | else: 68 | # If no strategies are present, should default to true. This isn't possible via UI. 69 | strategy_result = True 70 | 71 | flag_value = strategy_result 72 | except Exception as strategy_except: 73 | LOGGER.warning("Error checking feature flag: %s", strategy_except) 74 | 75 | self.increment_stats(flag_value) 76 | 77 | LOGGER.info("Feature toggle status for feature %s: %s", self.name, flag_value) 78 | 79 | return flag_value 80 | 81 | def get_variant(self, 82 | context: dict = None) -> dict: 83 | """ 84 | Checks if feature is enabled and, if so, get the variant. 85 | 86 | :param context: Context information 87 | :return: 88 | """ 89 | is_feature_enabled = self.is_enabled(context) 90 | 91 | if is_feature_enabled and self.variations is not None: 92 | try: 93 | variant = self.variations.get_variant(context) 94 | variant['enabled'] = is_feature_enabled 95 | except Exception as variant_exception: 96 | LOGGER.warning("Error selecting variant: %s", variant_exception) 97 | else: 98 | variant = DISABLED_VARIATION 99 | 100 | return variant 101 | -------------------------------------------------------------------------------- /docs/unleashclient.md: -------------------------------------------------------------------------------- 1 | ## UnleashClient 2 | 3 | ### `__init__()` 4 | A client for the Unleash feature toggle system. 5 | 6 | ` 7 | UnleashClient.__init__(url, app_name, instance_id, refresh_interval, metrics_interval, disable_metrics, disable_registration, custom_headers) 8 | ` 9 | 10 | **Arguments** 11 | 12 | Argument | Description | Required? | Type | Default Value| 13 | ---------|-------------|-----------|-------|---------------| 14 | url | Unleash server URL | Y | String | N/A | 15 | app_name | Name of your program | Y | String | N/A | 16 | instance_id | Unique ID for your program | N | String | unleash-client-python | 17 | refresh_interval | How often the unleash client should check for configuration changes. | N | Integer | 15 | 18 | metrics_interval | How often the unleash client should send metrics to server. | N | Integer | 60 | 19 | disable_metrics | Disables sending metrics to Unleash server. | N | Boolean | F | 20 | disable_registration | Disables registration with Unleash server. | N | Boolean | F | 21 | custom_headers | Custom headers to send to Unleash. | N | Dictionary | {} 22 | custom_options | Custom arguments for requests package. | N | Dictionary | {} 23 | custom_strategies | Custom strategies you'd like UnleashClient to support. | N | Dictionary | {} | 24 | cache_directory | Location of the cache directory. When unset, FCache will determine the location | N | Str | Unset | 25 | 26 | ### `initialize_client()` 27 | Initializes client and starts communication with central unleash server(s). 28 | 29 | This kicks off: 30 | * Client registration 31 | * Provisioning poll 32 | * Stats poll 33 | 34 | ### `destroy()` 35 | Gracefully shuts down the Unleash client by stopping jobs, stopping the scheduler, and deleting the cache. 36 | 37 | You shouldn't need this too much! 38 | 39 | ### `is_enabled()` 40 | 41 | Checks if a feature toggle is enabled. 42 | 43 | Notes: 44 | * If client hasn't been initialized yet or an error occurs, flat will default to false. 45 | 46 | ` 47 | UnleashClient.is_enabled(feature_name, context, default_value) 48 | ` 49 | 50 | **Arguments** 51 | 52 | Argument | Description | Required? | Type | Default Value| 53 | ---------|-------------|-----------|-------|---------------| 54 | feature_name | Name of feature | Y | String | N/A | 55 | context | Custom information for strategies | N | Dictionary | {} | 56 | default_value | Deprecated, use Fallback Function. | N | Boolean | F | 57 | fallback_function | A function that takes two arguments (feature name, context) and returns a boolean. Used if exception occurs when checking a feature flag. | N | Callable | None | 58 | 59 | ### Notes 60 | 61 | **Using `unleash-client-python` with Gitlab** 62 | 63 | [Gitlab's feature flags](https://docs.gitlab.com/ee/user/project/operations/feature_flags.html) only supports the features URL. (API calls to the registration URL and metrics URL will fail with HTTP Error code 401.) 64 | 65 | If using `unleash-client-python` with Gitlab's feature flages, we recommend initializing the client with `disable_metrics` = True and `disable_registration` = True. 66 | 67 | ``` python 68 | my_client = UnleashClient( 69 | url="https://gitlab.com/api/v4/feature_flags/someproject/someid", 70 | app_name="myClient1", 71 | instance_id="myinstanceid", 72 | disable_metrics=True, 73 | disable_registration=True 74 | ) 75 | ``` 76 | 77 | **Overriding SSL certificate verification** 78 | 79 | (Do this at your own risk!) 80 | 81 | If using an on-prem SSL certificate with a self-signed cert, you can pass custom arguments through to the **request** package using the *custom_options* argument. 82 | 83 | ```python 84 | my_client = UnleashClient( 85 | url="https://myunleash.hamster.com", 86 | app_name="myClient1", 87 | instance_id="myinstanceid", 88 | custom_options={"verify": False} 89 | ) 90 | ``` 91 | -------------------------------------------------------------------------------- /tests/unit_tests/strategies/test_flexiblerollout.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from UnleashClient.strategies.FlexibleRolloutStrategy import FlexibleRollout 3 | 4 | BASE_FLEXIBLE_ROLLOUT_DICT = \ 5 | { 6 | "name": "flexibleRollout", 7 | "parameters": { 8 | "rollout": 50, 9 | "stickiness": "userId", 10 | "groupId": "AB12A" 11 | }, 12 | "constraints": [ 13 | { 14 | "contextName": "environment", 15 | "operator": "IN", 16 | "values": [ 17 | "staging", 18 | "prod" 19 | ] 20 | }, 21 | { 22 | "contextName": "userId", 23 | "operator": "IN", 24 | "values": [ 25 | "122", 26 | "155", 27 | "9" 28 | ] 29 | }, 30 | { 31 | "contextName": "userId", 32 | "operator": "NOT_IN", 33 | "values": [ 34 | "4" 35 | ] 36 | }, 37 | { 38 | "contextName": "appName", 39 | "operator": "IN", 40 | "values": [ 41 | "test" 42 | ] 43 | } 44 | ] 45 | } 46 | 47 | 48 | @pytest.fixture() 49 | def strategy(): 50 | yield FlexibleRollout(BASE_FLEXIBLE_ROLLOUT_DICT['constraints'], BASE_FLEXIBLE_ROLLOUT_DICT['parameters']) 51 | 52 | 53 | def test_flexiblerollout_satisfiesconstraints(strategy): 54 | context = { 55 | 'userId': "122", 56 | 'appName': 'test', 57 | 'environment': 'prod' 58 | } 59 | 60 | assert strategy.execute(context) 61 | 62 | 63 | def test_flexiblerollout_doesntsatisfiesconstraints(strategy): 64 | context = { 65 | 'userId': "2", 66 | 'appName': 'qualityhamster', 67 | 'environment': 'prod' 68 | } 69 | assert not strategy.execute(context) 70 | 71 | 72 | def test_flexiblerollout_userid(strategy): 73 | base_context = dict(appName='test', environment='prod') 74 | base_context['userId'] = "122" 75 | assert strategy.execute(base_context) 76 | base_context['userId'] = "155" 77 | assert not strategy.execute(base_context) 78 | 79 | 80 | def test_flexiblerollout_sessionid(strategy): 81 | BASE_FLEXIBLE_ROLLOUT_DICT['parameters']['stickiness'] = 'sessionId' 82 | base_context = dict(appName='test', environment='prod', userId="9") 83 | base_context['sessionId'] = "122" 84 | assert strategy.execute(base_context) 85 | base_context['sessionId'] = "155" 86 | assert not strategy.execute(base_context) 87 | 88 | 89 | def test_flexiblerollout_random(strategy): 90 | BASE_FLEXIBLE_ROLLOUT_DICT['parameters']['stickiness'] = 'random' 91 | base_context = dict(appName='test', environment='prod', userId="1") 92 | assert strategy.execute(base_context) in [True, False] 93 | 94 | 95 | def test_flexiblerollout_default(): 96 | BASE_FLEXIBLE_ROLLOUT_DICT['parameters']['stickiness'] = 'default' 97 | BASE_FLEXIBLE_ROLLOUT_DICT['constraints'] = [x for x in BASE_FLEXIBLE_ROLLOUT_DICT['constraints'] if x['contextName'] != 'userId'] 98 | strategy = FlexibleRollout(BASE_FLEXIBLE_ROLLOUT_DICT['constraints'], BASE_FLEXIBLE_ROLLOUT_DICT['parameters']) 99 | base_context = dict(appName='test', environment='prod', userId="122", sessionId="155") 100 | assert strategy.execute(base_context) 101 | base_context = dict(appName='test', environment='prod', sessionId="122") 102 | assert strategy.execute(base_context) 103 | base_context = dict(appName='test', environment='prod') 104 | assert strategy.execute(base_context) in [True, False] 105 | -------------------------------------------------------------------------------- /tests/specification_tests/test_05_gradual_rollout_random_strategy.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import json 3 | import pytest 4 | import responses 5 | from UnleashClient import UnleashClient 6 | from UnleashClient.constants import FEATURES_URL, METRICS_URL, REGISTER_URL 7 | from tests.utilities.testing_constants import URL, APP_NAME 8 | 9 | 10 | MOCK_JSON = """ 11 | { 12 | "version": 1, 13 | "features": [{ 14 | "name": "Feature.A5", 15 | "description": "Enabled toggle for 100%", 16 | "enabled": true, 17 | "strategies": [{ 18 | "name": "gradualRolloutRandom", 19 | "parameters": { 20 | "percentage": "100" 21 | } 22 | }] 23 | }, 24 | { 25 | "name": "Feature.B5", 26 | "description": "Disabled toggle with 0% rollout", 27 | "enabled": true, 28 | "strategies": [{ 29 | "name": "gradualRolloutRandom", 30 | "parameters": { 31 | "percentage": "0" 32 | } 33 | }] 34 | }, 35 | { 36 | "name": "Feature.C5", 37 | "enabled": true, 38 | "strategies": [{ 39 | "name": "gradualRolloutRandom", 40 | "parameters": { 41 | "percentage": "0" 42 | } 43 | }, 44 | { 45 | "name": "default" 46 | } 47 | ] 48 | }, 49 | { 50 | "name": "Feature.D5", 51 | "description": "Disabled toggle should be disabled", 52 | "enabled": false, 53 | "strategies": [{ 54 | "name": "gradualRolloutRandom", 55 | "parameters": { 56 | "percentage": "100" 57 | } 58 | }] 59 | } 60 | ] 61 | } 62 | """ 63 | 64 | 65 | @pytest.fixture() 66 | def unleash_client(): 67 | unleash_client = UnleashClient(url=URL, 68 | app_name=APP_NAME, 69 | instance_id='pytest_%s' % uuid.uuid4()) 70 | yield unleash_client 71 | unleash_client.destroy() 72 | 73 | 74 | @responses.activate 75 | def test_feature_a5(unleash_client): 76 | """ 77 | Feature.A5 should be enabled 78 | """ 79 | # Set up API 80 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 81 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 82 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 83 | 84 | # Tests 85 | unleash_client.initialize_client() 86 | assert unleash_client.is_enabled("Feature.A5") 87 | 88 | 89 | @responses.activate 90 | def test_feature_b5(unleash_client): 91 | """ 92 | Feature.B5 should be disabled 93 | """ 94 | # Set up API 95 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 96 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 97 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 98 | 99 | # Tests 100 | unleash_client.initialize_client() 101 | assert not unleash_client.is_enabled("Feature.B5") 102 | 103 | 104 | @responses.activate 105 | def test_feature_c5(unleash_client): 106 | """ 107 | Feature.C5 should be enabled because of default strategy 108 | """ 109 | # Set up API 110 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 111 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 112 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 113 | 114 | # Tests 115 | unleash_client.initialize_client() 116 | assert unleash_client.is_enabled("Feature.C5") 117 | 118 | 119 | @responses.activate 120 | def test_feature_d5(unleash_client): 121 | """ 122 | Feature.D5 should be disabled 123 | """ 124 | # Set up API 125 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 126 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 127 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 128 | 129 | # Tests 130 | unleash_client.initialize_client() 131 | assert not unleash_client.is_enabled("Feature.D5") 132 | -------------------------------------------------------------------------------- /tests/unit_tests/test_custom_strategy.py: -------------------------------------------------------------------------------- 1 | import responses 2 | from UnleashClient import UnleashClient 3 | from UnleashClient.strategies import Strategy 4 | from tests.utilities.testing_constants import URL, APP_NAME 5 | from tests.utilities.mocks import MOCK_CUSTOM_STRATEGY 6 | from tests.utilities.old_code.StrategyV2 import StrategyOldV2 7 | from UnleashClient.constants import REGISTER_URL, FEATURES_URL, METRICS_URL 8 | 9 | 10 | class CatTest(Strategy): 11 | def load_provisioning(self) -> list: 12 | return [x.strip() for x in self.parameters["sound"].split(',')] 13 | 14 | def apply(self, context: dict = None) -> bool: 15 | """ 16 | Turn on if I'm a cat. 17 | 18 | :return: 19 | """ 20 | default_value = False 21 | 22 | if "sound" in context.keys(): 23 | default_value = context["sound"] in self.parsed_provisioning 24 | 25 | return default_value 26 | 27 | 28 | class DogTest(StrategyOldV2): 29 | def load_provisioning(self) -> list: 30 | return [x.strip() for x in self.parameters["sound"].split(',')] 31 | 32 | def _call_(self, context: dict = None) -> bool: 33 | """ 34 | Turn on if I'm a dog. 35 | 36 | :return: 37 | """ 38 | default_value = False 39 | 40 | if "sound" in context.keys(): 41 | default_value = context["sound"] in self.parsed_provisioning 42 | 43 | return default_value 44 | 45 | 46 | @responses.activate 47 | def test_uc_customstrategy_happypath(recwarn): 48 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 49 | responses.add(responses.GET, URL + FEATURES_URL, json=MOCK_CUSTOM_STRATEGY, status=200) 50 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 51 | 52 | custom_strategies_dict = { 53 | "amIACat": CatTest, 54 | "amIADog": DogTest 55 | } 56 | 57 | unleash_client = UnleashClient( 58 | URL, 59 | APP_NAME, 60 | environment="prod", 61 | custom_strategies=custom_strategies_dict) 62 | 63 | unleash_client.initialize_client() 64 | 65 | # Check custom strategy. 66 | assert unleash_client.is_enabled("CustomToggle", {"sound": "meow"}) 67 | assert not unleash_client.is_enabled("CustomToggle", {"sound": "bark"}) 68 | 69 | # Check warning on deprecated strategy. 70 | assert len(recwarn) == 1 71 | assert recwarn.pop(DeprecationWarning) 72 | 73 | 74 | @responses.activate 75 | def test_uc_customstrategy_depredationwarning(): 76 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 77 | responses.add(responses.GET, URL + FEATURES_URL, json=MOCK_CUSTOM_STRATEGY, status=200) 78 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 79 | 80 | custom_strategies_dict = { 81 | "amIACat": CatTest, 82 | "amIADog": DogTest 83 | } 84 | 85 | unleash_client = UnleashClient( 86 | URL, 87 | APP_NAME, 88 | environment="prod", 89 | custom_strategies=custom_strategies_dict) 90 | 91 | unleash_client.initialize_client() 92 | 93 | # Check a toggle that contains an outdated custom strategy 94 | assert unleash_client.is_enabled("CustomToggleWarning", {"sound": "meow"}) 95 | 96 | 97 | @responses.activate 98 | def test_uc_customstrategy_safemulti(): 99 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 100 | responses.add(responses.GET, URL + FEATURES_URL, json=MOCK_CUSTOM_STRATEGY, status=200) 101 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 102 | 103 | custom_strategies_dict = { 104 | "amIACat": CatTest, 105 | "amIADog": DogTest 106 | } 107 | 108 | unleash_client = UnleashClient( 109 | URL, 110 | APP_NAME, 111 | environment="prod", 112 | custom_strategies=custom_strategies_dict) 113 | 114 | unleash_client.initialize_client() 115 | 116 | # Check a toggle that contains an outdated custom strategy and a default strategy. 117 | assert unleash_client.is_enabled("CustomToggleWarningMultiStrat", {"sound": "meow"}) 118 | -------------------------------------------------------------------------------- /UnleashClient/loader.py: -------------------------------------------------------------------------------- 1 | import redis 2 | import pickle 3 | from UnleashClient.features.Feature import Feature 4 | from UnleashClient.variants.Variants import Variants 5 | from UnleashClient.constants import FEATURES_URL 6 | from UnleashClient.utils import LOGGER 7 | 8 | 9 | # pylint: disable=broad-except 10 | def _create_strategies(provisioning: dict, 11 | strategy_mapping: dict) -> list: 12 | feature_strategies = [] 13 | 14 | for strategy in provisioning["strategies"]: 15 | try: 16 | if "parameters" in strategy.keys(): 17 | strategy_provisioning = strategy['parameters'] 18 | else: 19 | strategy_provisioning = {} 20 | 21 | if "constraints" in strategy.keys(): 22 | constraint_provisioning = strategy['constraints'] 23 | else: 24 | constraint_provisioning = {} 25 | 26 | feature_strategies.append( 27 | strategy_mapping[strategy['name']](constraints=constraint_provisioning, parameters=strategy_provisioning) 28 | ) 29 | except Exception as excep: 30 | LOGGER.warning("Failed to load strategy. This may be a problem with a custom strategy. Exception: %s", 31 | excep) 32 | 33 | return feature_strategies 34 | 35 | 36 | def _create_feature(provisioning: dict, 37 | strategy_mapping: dict) -> Feature: 38 | if "strategies" in provisioning.keys(): 39 | parsed_strategies = _create_strategies(provisioning, strategy_mapping) 40 | else: 41 | parsed_strategies = [] 42 | 43 | if "variants" in provisioning: 44 | variant = Variants(provisioning['variants'], provisioning['name']) 45 | else: 46 | variant = None 47 | 48 | return Feature(name=provisioning["name"], 49 | enabled=provisioning["enabled"], 50 | strategies=parsed_strategies, 51 | variants=variant 52 | ) 53 | 54 | 55 | def load_features(cache: redis.Redis, 56 | feature_toggles: dict, 57 | strategy_mapping: dict) -> None: 58 | """ 59 | Caching 60 | 61 | :param cache: Should be the cache class variable from UnleashClient 62 | :param feature_toggles: Should be the features class variable from UnleashClient 63 | :return: 64 | """ 65 | # Pull raw provisioning from cache. 66 | try: 67 | feature_provisioning = pickle.loads(cache.get(FEATURES_URL)) 68 | 69 | # Parse provisioning 70 | parsed_features = {} 71 | feature_names = [ 72 | d["name"] for d in feature_provisioning 73 | ] 74 | 75 | for provisioning in feature_provisioning: 76 | parsed_features[provisioning["name"]] = provisioning 77 | 78 | # Delete old features/cache 79 | for feature in list(feature_toggles.keys()): 80 | if feature not in feature_names: 81 | del feature_toggles[feature] 82 | 83 | # Update existing objects 84 | for feature in feature_toggles.keys(): 85 | feature_for_update = feature_toggles[feature] 86 | strategies = parsed_features[feature]["strategies"] 87 | 88 | feature_for_update.enabled = parsed_features[feature]["enabled"] 89 | if strategies: 90 | parsed_strategies = _create_strategies(parsed_features[feature], strategy_mapping) 91 | feature_for_update.strategies = parsed_strategies 92 | 93 | if 'variants' in parsed_features[feature]: 94 | feature_for_update.variants = Variants( 95 | parsed_features[feature]['variants'], 96 | parsed_features[feature]['name'] 97 | ) 98 | 99 | # Handle creation or deletions 100 | new_features = list(set(feature_names) - set(feature_toggles.keys())) 101 | 102 | for feature in new_features: 103 | feature_toggles[feature] = _create_feature(parsed_features[feature], strategy_mapping) 104 | except KeyError as cache_exception: 105 | LOGGER.warning("Cache Exception: %s", cache_exception) 106 | LOGGER.warning("Unleash client does not have cached features. Please make sure client can communicate with Unleash server!") 107 | -------------------------------------------------------------------------------- /tests/specification_tests/test_01_simple_examples.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import json 3 | import pytest 4 | import responses 5 | from UnleashClient import UnleashClient 6 | from UnleashClient.constants import FEATURES_URL, METRICS_URL, REGISTER_URL 7 | from tests.utilities.testing_constants import URL, APP_NAME 8 | 9 | 10 | MOCK_JSON = """ 11 | { 12 | "version": 1, 13 | "features": [{ 14 | "name": "Feature.A", 15 | "description": "Enabled toggle", 16 | "enabled": true, 17 | "strategies": [{ 18 | "name": "default" 19 | }] 20 | }, 21 | { 22 | "name": "Feature.B", 23 | "description": "Disabled toggle", 24 | "enabled": false, 25 | "strategies": [{ 26 | "name": "default" 27 | }] 28 | }, 29 | { 30 | "name": "Feature.C", 31 | "enabled": true, 32 | "strategies": [] 33 | } 34 | ] 35 | } 36 | """ 37 | 38 | 39 | @pytest.fixture() 40 | def unleash_client(): 41 | unleash_client = UnleashClient(url=URL, 42 | app_name=APP_NAME, 43 | instance_id='pytest_%s' % uuid.uuid4()) 44 | yield unleash_client 45 | unleash_client.destroy() 46 | 47 | 48 | @responses.activate 49 | def test_feature_a(unleash_client): 50 | """ 51 | Feature.A should be enabled. 52 | """ 53 | # Set up API 54 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 55 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 56 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 57 | 58 | # Tests 59 | unleash_client.initialize_client() 60 | assert unleash_client.is_enabled("Feature.A") 61 | 62 | 63 | @responses.activate 64 | def test_feature_b(unleash_client): 65 | """ 66 | Feature.B should be disabled 67 | """ 68 | # Set up API 69 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 70 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 71 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 72 | 73 | # Tests 74 | unleash_client.initialize_client() 75 | assert not unleash_client.is_enabled("Feature.B") 76 | 77 | 78 | @responses.activate 79 | def test_feature_c(unleash_client): 80 | """ 81 | Feature.C should be enabled when strategy missing 82 | """ 83 | # Set up API 84 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 85 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 86 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 87 | 88 | # Tests 89 | unleash_client.initialize_client() 90 | assert unleash_client.is_enabled("Feature.C") 91 | 92 | 93 | @responses.activate 94 | def test_feature_unknown(unleash_client): 95 | """ 96 | Unknown feature toggle should be disabled 97 | """ 98 | # Set up API 99 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 100 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 101 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 102 | 103 | # Tests 104 | unleash_client.initialize_client() 105 | assert not unleash_client.is_enabled("Unknown") 106 | 107 | 108 | @responses.activate 109 | def test_feature_all_context_values(unleash_client): 110 | """ 111 | Should allow all context values 112 | """ 113 | # Set up API 114 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 115 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 116 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 117 | 118 | # Tests 119 | context_values = { 120 | "userId": "123", 121 | "sessionId": "asd123", 122 | "remoteAddress": "127.0.0.1", 123 | "properties": { 124 | "customName": "customValue", 125 | "anotherName": "anotherValue" 126 | } 127 | } 128 | 129 | unleash_client.initialize_client() 130 | assert unleash_client.is_enabled("Feature.A", context_values) 131 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | ## Next version 2 | 3 | 4 | ## v3.5.0 5 | * (Major) Stop using the `default_value` argument in the `is_enabled()` method (as it can cause counter-intuitive behavior) and add deprecation warning. This argument will be removed in the next major version upgrade! 6 | * We recommend using the `fallback_function` argument instead. If you need a blanket True in case of an exception, you can pass in a lambda like: `lambda x, y: True`. 7 | * (Minor) Add better logging for API errors. 8 | * (Minor) Update requests version to v2.25.0. 9 | 10 | 11 | ## v3.4.1, v3.4.2 12 | 13 | **General** 14 | * (Minor) Move CI to Github Actions, add auto-publishing. 15 | 16 | ## v3.4.0 17 | 18 | **Bugfixes** 19 | * (Major) Fallback function will only be called if exception (feature flag not found, general exception) occurs when calling `is_enabled()`. It will not be called on successful execution of the method. 20 | 21 | ## v3.3.0 22 | 23 | **General** 24 | * (Major) Add support for variants on feature toggles. 25 | 26 | **Bugfixes** 27 | * (Minor) Fixed issue with applying custom constraints to non-standard parameters in context. 28 | 29 | ## v3.2.0 30 | 31 | **General** 32 | 33 | * (Major) Allow users to supply a fallback function to customize the default value of a feature flag. 34 | 35 | ## v3.1.1 36 | 37 | **Bugfixes** 38 | 39 | * Custom constraints check should check for values in the `properties` sub-property in the context as specified by [Unleash context documentation](https://unleash.github.io/docs/unleash_context). 40 | 41 | ## v3.1.0 42 | 43 | **General** 44 | 45 | * (Minor) Add official-ish support for Python 3.8. 46 | 47 | ## v3.0.0 48 | 49 | **General** 50 | 51 | * (Major) Support constraints on all default strategies. 52 | * This is a breaking change! To update your custom strategy, please checkout the [custom strategy migration guide](https://unleash.github.io/unleash-client-python/customstrategies/). 53 | * (Major) Added flexibleRollout strategy. 54 | 55 | ## v2.6.0 56 | 57 | **General** 58 | 59 | * (Minor) Add ability to add request kwargs when initializing the client. These will be used when registering the client, fetching feature flags, and sending metrics. 60 | 61 | ## v2.5.0 62 | 63 | **General** 64 | 65 | * (Minor) Unleash client will not error if cache is not present and Unleash server not accessible during initialization. 66 | 67 | ## v2.4.0 68 | 69 | **General** 70 | 71 | * (Minor) Added static context values (app name, env) in preparation for Unleash v4 features. 72 | 73 | ## v2.3.0 74 | 75 | **General** 76 | 77 | * (Minor) Add option to disable metrics on client initialization. 78 | 79 | **Bugfix** 80 | 81 | * (Minor) Fixed issue where `disable_metrics` arugment wasn't honored. 82 | 83 | ## v2.2.1 84 | 85 | **Bugfixes** 86 | 87 | * (Major) Date/time sent to Unleash (in register, metrics, etc) is correctly in UTC w/timestamp format. 88 | 89 | ## v2.2.0 90 | 91 | * Allow configuration of the cache directory. 92 | 93 | ## v2.1.0 94 | 95 | **General** 96 | 97 | * (Major) Support for Python 3.5, 3.6, and 3.7. (Credit to [Baaym](https://github.com/baaym) for 3.5 support!) 98 | 99 | ## v2.0.1 100 | 101 | **Bugfixes** 102 | 103 | * (Major) Fix issue where `bucket.start` value sent to Unleash was never updated. Credit to Calle for bug report/proposed solution! =) 104 | 105 | ## v2.0.0 106 | 107 | **Bugfixes** 108 | 109 | * (Major) Removed hard-coded `/api/` in Unleash server URLs. Before upgrading, please adjust your server URL accordingly (i.e. changing http://unleash.heroku.com to http://unleash.heroku.com/api). 110 | 111 | ## v1.0.2 112 | 113 | **General** 114 | 115 | * unleash-client-python has moved under the general Unleash project! 116 | 117 | **Bugfixes** 118 | 119 | * (Minor) Updated requests version to address security issue in dependency. 120 | 121 | ## v1.0.0 122 | **General** 123 | 124 | * Implemented custom strategies. 125 | 126 | ## v0.3.0 127 | 128 | **General** 129 | 130 | * Implemented [client specification](https://github.com/Unleash/client-specification) tests. 131 | * Cache changed to use Instance ID as key. 132 | 133 | **Bugfixes** 134 | 135 | * (Major) Fixed interposed arguments in normalized_hash() (aka MurmerHash3 wrapper). Python client will now do the same thing as the other clients! 136 | * (Major) Fixed issues with logic in random strategies. 137 | 138 | ## v0.2.0 139 | 140 | **General** 141 | 142 | * Changed cache implementation. Instead of caching {feature toggle name: provisioning} we'll now cache the entire API response (and use it if the fetch fails in any way). 143 | 144 | ## v0.1.1 145 | 146 | **General** 147 | 148 | * Fixed Github link on pypi. 149 | * Removed unused sphinx documentation. 150 | * Added documentation using mkdocs 151 | 152 | ## v0.1.0 153 | 154 | **General** 155 | 156 | * First implementation of the Unleash Python client! Woo! 157 | -------------------------------------------------------------------------------- /tests/specification_tests/test_02_user_with_id_strategy.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import json 3 | import pytest 4 | import responses 5 | from UnleashClient import UnleashClient 6 | from UnleashClient.constants import FEATURES_URL, METRICS_URL, REGISTER_URL 7 | from tests.utilities.testing_constants import URL, APP_NAME 8 | 9 | 10 | MOCK_JSON = """ 11 | { 12 | "version": 1, 13 | "features": [{ 14 | "name": "Feature.A2", 15 | "description": "Enabled toggle", 16 | "enabled": true, 17 | "strategies": [{ 18 | "name": "userWithId", 19 | "parameters": { 20 | "userIds": "123" 21 | } 22 | }] 23 | }, 24 | { 25 | "name": "Feature.B2", 26 | "description": "Disabled toggle", 27 | "enabled": true, 28 | "strategies": [{ 29 | "name": "userWithId", 30 | "parameters": { 31 | "userIds": "123" 32 | } 33 | }] 34 | }, 35 | { 36 | "name": "Feature.C2", 37 | "enabled": true, 38 | "strategies": [{ 39 | "name": "userWithId", 40 | "parameters": { 41 | "userIds": "123" 42 | } 43 | }, 44 | { 45 | "name": "default" 46 | } 47 | ] 48 | }, 49 | { 50 | "name": "Feature.D2", 51 | "enabled": true, 52 | "strategies": [{ 53 | "name": "userWithId", 54 | "parameters": { 55 | "userIds": "123, 222, 88" 56 | } 57 | }] 58 | } 59 | ] 60 | } 61 | """ 62 | 63 | 64 | @pytest.fixture() 65 | def unleash_client(): 66 | unleash_client = UnleashClient(url=URL, 67 | app_name=APP_NAME, 68 | instance_id='pytest_%s' % uuid.uuid4()) 69 | yield unleash_client 70 | unleash_client.destroy() 71 | 72 | 73 | @responses.activate 74 | def test_feature_a2_enabled(unleash_client): 75 | """ 76 | Feature.A2 Should be enabled for user on context 77 | """ 78 | # Set up API 79 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 80 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 81 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 82 | 83 | # Tests 84 | context_values = { 85 | "userId": "123" 86 | } 87 | 88 | unleash_client.initialize_client() 89 | assert unleash_client.is_enabled("Feature.A2", context_values) 90 | 91 | 92 | @responses.activate 93 | def test_feature_a2_disabled(unleash_client): 94 | """ 95 | Feature.A2 Should not be enabled for user not in context 96 | """ 97 | # Set up API 98 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 99 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 100 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 101 | 102 | # Tests 103 | context_values = { 104 | "userId": "22" 105 | } 106 | 107 | unleash_client.initialize_client() 108 | assert not unleash_client.is_enabled("Feature.A2", context_values) 109 | 110 | 111 | @responses.activate 112 | def test_feature_c2(unleash_client): 113 | """ 114 | Feature.C2 Should not be "disabled" for for everyone 115 | TODO: Contact team about issue in test case description, should be enabled. 116 | """ 117 | # Set up API 118 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 119 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 120 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 121 | 122 | # Tests 123 | context_values = { 124 | "userId": "22" 125 | } 126 | 127 | unleash_client.initialize_client() 128 | assert unleash_client.is_enabled("Feature.C2", context_values) 129 | 130 | 131 | @responses.activate 132 | def test_feature_d2(unleash_client): 133 | """ 134 | Feature.D2 Should "-not-" be enabled for user in list 135 | TODO: Contact team about issue in test case description, should be enabled. 136 | """ 137 | # Set up API 138 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 139 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 140 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 141 | 142 | # Tests 143 | context_values = { 144 | "userId": "222" 145 | } 146 | 147 | unleash_client.initialize_client() 148 | assert unleash_client.is_enabled("Feature.D2", context_values) 149 | 150 | 151 | @responses.activate 152 | def test_feature_no_id(unleash_client): 153 | """ 154 | Feature.A2 Should be disabled when no userId on context 155 | """ 156 | # Set up API 157 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 158 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 159 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 160 | 161 | # Tests 162 | context_values = {} 163 | 164 | unleash_client.initialize_client() 165 | assert not unleash_client.is_enabled("Feature.A2", context_values) 166 | -------------------------------------------------------------------------------- /tests/utilities/mocks/mock_all_features.py: -------------------------------------------------------------------------------- 1 | MOCK_ALL_FEATURES = \ 2 | { 3 | "version": 1, 4 | "features": [ 5 | { 6 | "name": "ApplicationHostname", 7 | "description": "Application Hostname strategy", 8 | "enabled": True, 9 | "strategies": [ 10 | { 11 | "name": "applicationHostname", 12 | "parameters": { 13 | "hostNames": "iMacPro.local,test1,test2" 14 | } 15 | } 16 | ], 17 | "createdAt": "2018-10-09T06:05:14.757Z" 18 | }, 19 | { 20 | "name": "Default", 21 | "description": "Default feature toggle", 22 | "enabled": True, 23 | "strategies": [ 24 | { 25 | "name": "default", 26 | "parameters": {} 27 | } 28 | ], 29 | "createdAt": "2018-10-09T06:04:05.667Z" 30 | }, 31 | { 32 | "name": "GradualRolloutRandom", 33 | "description": "Gradual Rollout Random example", 34 | "enabled": True, 35 | "strategies": [ 36 | { 37 | "name": "gradualRolloutRandom", 38 | "parameters": { 39 | "percentage": 50 40 | } 41 | } 42 | ], 43 | "createdAt": "2018-10-09T06:05:37.637Z" 44 | }, 45 | { 46 | "name": "GradualRolloutSessionId", 47 | "description": "SessionID check!", 48 | "enabled": True, 49 | "strategies": [ 50 | { 51 | "name": "gradualRolloutSessionId", 52 | "parameters": { 53 | "percentage": 50, 54 | "groupId": "GradualRolloutSessionId" 55 | } 56 | } 57 | ], 58 | "createdAt": "2018-10-09T06:06:51.057Z" 59 | }, 60 | { 61 | "name": "GradualRolloutUserID", 62 | "description": "GradualRolloutUserID strategy", 63 | "enabled": True, 64 | "strategies": [ 65 | { 66 | "name": "gradualRolloutUserId", 67 | "parameters": { 68 | "percentage": 50, 69 | "groupId": "GradualRolloutUserID" 70 | } 71 | } 72 | ], 73 | "createdAt": "2018-10-09T06:07:17.520Z" 74 | }, 75 | { 76 | "name": "RemoteAddress", 77 | "description": "RemoteAddress strategies", 78 | "enabled": True, 79 | "strategies": [ 80 | { 81 | "name": "remoteAddress", 82 | "parameters": { 83 | "IPs": "69.208.0.0/29,70.208.1.1,2001:db8:1234::/48,2002:db8:1234:0000:0000:0000:0000:0001" 84 | } 85 | } 86 | ], 87 | "createdAt": "2018-10-09T06:08:42.398Z" 88 | }, 89 | { 90 | "name": "UserWithId", 91 | "description": "UserWithId strategies", 92 | "enabled": True, 93 | "strategies": [ 94 | { 95 | "name": "userWithId", 96 | "parameters": { 97 | "userIds": "meep@meep.com,test@test.com,wat@wat.com" 98 | } 99 | } 100 | ], 101 | "createdAt": "2018-10-09T06:09:19.203Z" 102 | }, 103 | { 104 | "name": "FlexibleRollout", 105 | "description": "FlexibleRollout strategies", 106 | "enabled": True, 107 | "strategies": [ 108 | { 109 | "name": "flexibleRollout", 110 | "parameters": { 111 | "rollout": "21", 112 | "stickiness": "userId", 113 | "groupId": "ivantest" 114 | }, 115 | "constraints": [ 116 | { 117 | "contextName": "environment", 118 | "operator": "IN", 119 | "values": [ 120 | "staging", 121 | "prod" 122 | ] 123 | }, 124 | { 125 | "contextName": "userId", 126 | "operator": "NOT_IN", 127 | "values": [ 128 | "1", 129 | "2", 130 | "3" 131 | ] 132 | }, 133 | { 134 | "contextName": "userId", 135 | "operator": "IN", 136 | "values": [ 137 | "4", 138 | "5", 139 | "6" 140 | ] 141 | }, 142 | { 143 | "contextName": "appName", 144 | "operator": "IN", 145 | "values": [ 146 | "test" 147 | ] 148 | } 149 | ] 150 | } 151 | ], 152 | "variants": None, 153 | "createdAt": "2019-10-05T07:30:29.896Z" 154 | }, 155 | { 156 | "name": "Variations", 157 | "description": "Test variation", 158 | "enabled": True, 159 | "strategies": [ 160 | { 161 | "name": "default" 162 | } 163 | ], 164 | "variants": [ 165 | { 166 | "name": "VarA", 167 | "weight": 34, 168 | "payload": { 169 | "type": "string", 170 | "value": "Test1" 171 | }, 172 | "overrides": [ 173 | { 174 | "contextName": "userId", 175 | "values": [ 176 | "ivanklee86@gmail.com", 177 | "ivan@aaptiv.com" 178 | ] 179 | } 180 | ] 181 | }, 182 | { 183 | "name": "VarB", 184 | "weight": 33, 185 | "payload": { 186 | "type": "string", 187 | "value": "Test 2" 188 | } 189 | }, 190 | { 191 | "name": "VarC", 192 | "weight": 33, 193 | "payload": { 194 | "type": "string", 195 | "value": "Test 3" 196 | } 197 | } 198 | ], 199 | "createdAt": "2019-10-25T13:22:02.035Z" 200 | } 201 | ] 202 | } 203 | -------------------------------------------------------------------------------- /tests/specification_tests/test_03_gradual_rollout_user_id_strategy.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import json 3 | import pytest 4 | import responses 5 | from UnleashClient import UnleashClient 6 | from UnleashClient.constants import FEATURES_URL, METRICS_URL, REGISTER_URL 7 | from tests.utilities.testing_constants import URL, APP_NAME 8 | 9 | 10 | MOCK_JSON = """ 11 | { 12 | "version": 1, 13 | "features": [{ 14 | "name": "Feature.A3", 15 | "description": "Enabled toggle for 100%", 16 | "enabled": true, 17 | "strategies": [{ 18 | "name": "gradualRolloutUserId", 19 | "parameters": { 20 | "percentage": "100", 21 | "groupId": "AB12A" 22 | } 23 | }] 24 | }, 25 | { 26 | "name": "Feature.B3", 27 | "description": "Enabled toggle for 50%", 28 | "enabled": true, 29 | "strategies": [{ 30 | "name": "gradualRolloutUserId", 31 | "parameters": { 32 | "percentage": "50", 33 | "groupId": "AB12A" 34 | } 35 | }] 36 | }, 37 | { 38 | "name": "Feature.C3", 39 | "enabled": true, 40 | "strategies": [{ 41 | "name": "gradualRolloutUserId", 42 | "parameters": { 43 | "percentage": "0", 44 | "groupId": "AB12A" 45 | } 46 | }] 47 | }, 48 | { 49 | "name": "Feature.D3", 50 | "enabled": true, 51 | "strategies": [{ 52 | "name": "gradualRolloutUserId", 53 | "parameters": { 54 | "percentage": "0", 55 | "groupId": "AB12A" 56 | } 57 | }, 58 | { 59 | "name": "default" 60 | } 61 | ] 62 | } 63 | ] 64 | } 65 | """ 66 | 67 | 68 | @pytest.fixture() 69 | def unleash_client(): 70 | unleash_client = UnleashClient(url=URL, 71 | app_name=APP_NAME, 72 | instance_id='pytest_%s' % uuid.uuid4()) 73 | yield unleash_client 74 | unleash_client.destroy() 75 | 76 | 77 | @responses.activate 78 | def test_feature_a3_enabled(unleash_client): 79 | """ 80 | Feature.A3 should be enabled for user on context 81 | """ 82 | # Set up API 83 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 84 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 85 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 86 | 87 | # Tests 88 | context_values = { 89 | "userId": "123" 90 | } 91 | 92 | unleash_client.initialize_client() 93 | assert unleash_client.is_enabled("Feature.A3", context_values) 94 | 95 | 96 | @responses.activate 97 | def test_feature_a3_nocontext(unleash_client): 98 | """ 99 | Feature.A3 should be disabled when no user on context 100 | """ 101 | # Set up API 102 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 103 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 104 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 105 | 106 | # Tests 107 | context_values = {} 108 | 109 | unleash_client.initialize_client() 110 | assert not unleash_client.is_enabled("Feature.A3", context_values) 111 | 112 | 113 | @responses.activate 114 | def test_feature_b3_enabled(unleash_client): 115 | """ 116 | Feature.B3 should be enabled for userId=122 117 | """ 118 | # Set up API 119 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 120 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 121 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 122 | 123 | # Tests 124 | context_values = { 125 | "userId": "122" 126 | } 127 | 128 | unleash_client.initialize_client() 129 | assert unleash_client.is_enabled("Feature.B3", context_values) 130 | 131 | 132 | @responses.activate 133 | def test_feature_b3_disabled(unleash_client): 134 | """ 135 | Feature.B3 should be disabled for userId=155 136 | """ 137 | # Set up API 138 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 139 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 140 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 141 | 142 | # Tests 143 | context_values = { 144 | "userId": "155" 145 | } 146 | 147 | unleash_client.initialize_client() 148 | assert not unleash_client.is_enabled("Feature.B3", context_values) 149 | 150 | 151 | @responses.activate 152 | def test_feature_c3(unleash_client): 153 | """ 154 | Feature.C3 should be disabled 155 | """ 156 | # Set up API 157 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 158 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 159 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 160 | 161 | # Tests 162 | context_values = { 163 | "userId": "122" 164 | } 165 | 166 | unleash_client.initialize_client() 167 | assert not unleash_client.is_enabled("Feature.C3", context_values) 168 | 169 | 170 | @responses.activate 171 | def test_feature_d3(unleash_client): 172 | """ 173 | Feature.D3 should be enabled for all because of default strategy 174 | """ 175 | # Set up API 176 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 177 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 178 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 179 | 180 | # Tests 181 | context_values = { 182 | "userId": "122" 183 | } 184 | 185 | unleash_client.initialize_client() 186 | assert unleash_client.is_enabled("Feature.D3", context_values) 187 | -------------------------------------------------------------------------------- /tests/specification_tests/test_04_gradual_rollout_session_id_strategy.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import json 3 | import pytest 4 | import responses 5 | from UnleashClient import UnleashClient 6 | from UnleashClient.constants import FEATURES_URL, METRICS_URL, REGISTER_URL 7 | from tests.utilities.testing_constants import URL, APP_NAME 8 | 9 | 10 | MOCK_JSON = """ 11 | { 12 | "version": 1, 13 | "features": [{ 14 | "name": "Feature.A4", 15 | "description": "Enabled toggle for 100%", 16 | "enabled": true, 17 | "strategies": [{ 18 | "name": "gradualRolloutSessionId", 19 | "parameters": { 20 | "percentage": "100", 21 | "groupId": "AB12A" 22 | } 23 | }] 24 | }, 25 | { 26 | "name": "Feature.B4", 27 | "description": "Enabled toggle for 50%", 28 | "enabled": true, 29 | "strategies": [{ 30 | "name": "gradualRolloutSessionId", 31 | "parameters": { 32 | "percentage": "50", 33 | "groupId": "AB12A" 34 | } 35 | }] 36 | }, 37 | { 38 | "name": "Feature.C4", 39 | "enabled": true, 40 | "strategies": [{ 41 | "name": "gradualRolloutSessionId", 42 | "parameters": { 43 | "percentage": "0", 44 | "groupId": "AB12A" 45 | } 46 | }] 47 | }, 48 | { 49 | "name": "Feature.D4", 50 | "enabled": true, 51 | "strategies": [{ 52 | "name": "gradualRolloutSessionId", 53 | "parameters": { 54 | "percentage": "0", 55 | "groupId": "AB12A" 56 | } 57 | }, 58 | { 59 | "name": "default" 60 | } 61 | ] 62 | } 63 | ] 64 | } 65 | """ 66 | 67 | 68 | @pytest.fixture() 69 | def unleash_client(): 70 | unleash_client = UnleashClient(url=URL, 71 | app_name=APP_NAME, 72 | instance_id='pytest_%s' % uuid.uuid4()) 73 | yield unleash_client 74 | unleash_client.destroy() 75 | 76 | 77 | @responses.activate 78 | def test_feature_a4_enabled(unleash_client): 79 | """ 80 | Feature.A4 should be enabled for user on context 81 | """ 82 | # Set up API 83 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 84 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 85 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 86 | 87 | # Tests 88 | context_values = { 89 | "sessionId": "123" 90 | } 91 | 92 | unleash_client.initialize_client() 93 | assert unleash_client.is_enabled("Feature.A4", context_values) 94 | 95 | 96 | @responses.activate 97 | def test_feature_a4_nocontext(unleash_client): 98 | """ 99 | Feature.A4 should be disabled when no user on context 100 | """ 101 | # Set up API 102 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 103 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 104 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 105 | 106 | # Tests 107 | context_values = {} 108 | 109 | unleash_client.initialize_client() 110 | assert not unleash_client.is_enabled("Feature.A4", context_values) 111 | 112 | 113 | @responses.activate 114 | def test_feature_b4_enabled(unleash_client): 115 | """ 116 | Feature.B4 should be enabled for sessionId=122 117 | """ 118 | # Set up API 119 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 120 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 121 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 122 | 123 | # Tests 124 | context_values = { 125 | "sessionId": "122" 126 | } 127 | 128 | unleash_client.initialize_client() 129 | assert unleash_client.is_enabled("Feature.B4", context_values) 130 | 131 | 132 | @responses.activate 133 | def test_feature_b4_disabled(unleash_client): 134 | """ 135 | Feature.B4 should be disabled for sessionId=155 136 | """ 137 | # Set up API 138 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 139 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 140 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 141 | 142 | # Tests 143 | context_values = { 144 | "sessionId": "155" 145 | } 146 | 147 | unleash_client.initialize_client() 148 | assert not unleash_client.is_enabled("Feature.B4", context_values) 149 | 150 | 151 | @responses.activate 152 | def test_feature_c4(unleash_client): 153 | """ 154 | Feature.C4 should be disabled 155 | """ 156 | # Set up API 157 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 158 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 159 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 160 | 161 | # Tests 162 | context_values = { 163 | "sessionId": "122" 164 | } 165 | 166 | unleash_client.initialize_client() 167 | assert not unleash_client.is_enabled("Feature.C4", context_values) 168 | 169 | 170 | @responses.activate 171 | def test_feature_d4(unleash_client): 172 | """ 173 | Feature.D4 should be enabled for all because of default strategy 174 | """ 175 | # Set up API 176 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 177 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 178 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 179 | 180 | # Tests 181 | context_values = { 182 | "sessionId": "122" 183 | } 184 | 185 | unleash_client.initialize_client() 186 | assert unleash_client.is_enabled("Feature.D4", context_values) 187 | -------------------------------------------------------------------------------- /tests/specification_tests/test_06_remote_address_strategy.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import json 3 | import pytest 4 | import responses 5 | from UnleashClient import UnleashClient 6 | from UnleashClient.constants import FEATURES_URL, METRICS_URL, REGISTER_URL 7 | from tests.utilities.testing_constants import URL, APP_NAME 8 | 9 | 10 | MOCK_JSON = """ 11 | { 12 | "version": 1, 13 | "features": [{ 14 | "name": "Feature.remoteAddress.A", 15 | "description": "Enabled toggle for localhost", 16 | "enabled": true, 17 | "strategies": [{ 18 | "name": "remoteAddress", 19 | "parameters": { 20 | "IPs": "127.0.0.1" 21 | } 22 | }] 23 | }, 24 | { 25 | "name": "Feature.remoteAddress.B", 26 | "description": "Enabled toggle for list of IPs", 27 | "enabled": true, 28 | "strategies": [{ 29 | "name": "remoteAddress", 30 | "parameters": { 31 | "IPs": "192.168.0.1, 192.168.0.2, 192.168.0.3" 32 | } 33 | }] 34 | }, 35 | { 36 | "name": "Feature.remoteAddress.C", 37 | "description": "Ignore invalid IP's in list", 38 | "enabled": true, 39 | "strategies": [{ 40 | "name": "remoteAddress", 41 | "parameters": { 42 | "IPs": "192.168.0.1, 192.invalid, 192.168.0.2, 192.168.0.3" 43 | } 44 | }] 45 | } 46 | ] 47 | } 48 | """ 49 | 50 | 51 | @pytest.fixture() 52 | def unleash_client(): 53 | unleash_client = UnleashClient(url=URL, 54 | app_name=APP_NAME, 55 | instance_id='pytest_%s' % uuid.uuid4()) 56 | yield unleash_client 57 | unleash_client.destroy() 58 | 59 | 60 | @responses.activate 61 | def test_feature_remoteaddress_a_enabled(unleash_client): 62 | """ 63 | Feature.remoteAddress.A should be enabled for localhost 64 | """ 65 | # Set up API 66 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 67 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 68 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 69 | 70 | # Tests 71 | context_values = { 72 | "remoteAddress": "127.0.0.1" 73 | } 74 | 75 | unleash_client.initialize_client() 76 | assert unleash_client.is_enabled("Feature.remoteAddress.A", context_values) 77 | 78 | 79 | @responses.activate 80 | def test_feature_remoteaddress_a_nocontext(unleash_client): 81 | """ 82 | Feature.remoteAddress.A should not be enabled for missing remoteAddress on context 83 | """ 84 | # Set up API 85 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 86 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 87 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 88 | 89 | # Tests 90 | context_values = {} 91 | 92 | unleash_client.initialize_client() 93 | assert not unleash_client.is_enabled("Feature.remoteAddress.A", context_values) 94 | 95 | 96 | @responses.activate 97 | def test_feature_remoteaddress_b_enabled(unleash_client): 98 | """ 99 | Feature.remoteAddress.B should be enabled for remoteAddress 192.168.0.1 100 | """ 101 | # Set up API 102 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 103 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 104 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 105 | 106 | # Tests 107 | context_values = { 108 | "remoteAddress": "192.168.0.1" 109 | } 110 | 111 | unleash_client.initialize_client() 112 | assert unleash_client.is_enabled("Feature.remoteAddress.B", context_values) 113 | 114 | 115 | @responses.activate 116 | def test_feature_remoteaddress_b_list(unleash_client): 117 | """ 118 | Feature.remoteAddress.B should be enabled for remoteAddress 192.168.0.3 119 | TODO: Error in spec. 120 | """ 121 | # Set up API 122 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 123 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 124 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 125 | 126 | # Tests 127 | context_values = { 128 | "remoteAddress": "192.168.0.3" 129 | } 130 | 131 | unleash_client.initialize_client() 132 | assert unleash_client.is_enabled("Feature.remoteAddress.B", context_values) 133 | 134 | 135 | @responses.activate 136 | def test_feature_remoteaddress_b_notlist(unleash_client): 137 | """ 138 | Feature.remoteAddress.B should not be enabled for remoteAddress 217.100.10.11 139 | TODO: Error in spec. 140 | """ 141 | # Set up API 142 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 143 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 144 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 145 | 146 | # Tests 147 | context_values = { 148 | "remoteAddress": "217.100.10.11" 149 | } 150 | 151 | unleash_client.initialize_client() 152 | assert not unleash_client.is_enabled("Feature.remoteAddress.B", context_values) 153 | 154 | 155 | @responses.activate 156 | def test_feature_remoteaddress_c(unleash_client): 157 | """ 158 | Feature.remoteAddress.C should be enabled for remoteAddress 192.168.0.3 159 | """ 160 | # Set up API 161 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 162 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 163 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 164 | 165 | # Tests 166 | context_values = { 167 | "remoteAddress": "192.168.0.3" 168 | } 169 | 170 | unleash_client.initialize_client() 171 | assert unleash_client.is_enabled("Feature.remoteAddress.C", context_values) 172 | -------------------------------------------------------------------------------- /tests/specification_tests/test_07_multiple_strategies.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import json 3 | import pytest 4 | import responses 5 | from UnleashClient import UnleashClient 6 | from UnleashClient.constants import FEATURES_URL, METRICS_URL, REGISTER_URL 7 | from tests.utilities.testing_constants import URL, APP_NAME 8 | 9 | 10 | MOCK_JSON = """ 11 | { 12 | "version": 1, 13 | "features": [{ 14 | "name": "Feature.multiStrategies.A", 15 | "description": "Enabled for via last stratgy", 16 | "enabled": true, 17 | "strategies": [{ 18 | "name": "gradualRolloutRandom", 19 | "parameters": { 20 | "percentage": "0" 21 | } 22 | }, 23 | { 24 | "name": "default", 25 | "parameters": {} 26 | } 27 | ] 28 | }, 29 | { 30 | "name": "Feature.multiStrategies.B", 31 | "description": "Enabled for user=123", 32 | "enabled": true, 33 | "strategies": [{ 34 | "name": "gradualRolloutRandom", 35 | "parameters": { 36 | "percentage": "0" 37 | } 38 | }, 39 | { 40 | "name": "userWithId", 41 | "parameters": { 42 | "userIds": "123" 43 | } 44 | } 45 | ] 46 | }, 47 | { 48 | "name": "Feature.multiStrategies.C", 49 | "description": "Enabled for user=123", 50 | "enabled": true, 51 | "strategies": [{ 52 | "name": "gradualRolloutRandom", 53 | "parameters": { 54 | "percentage": "0" 55 | } 56 | }, 57 | { 58 | "name": "userWithId", 59 | "parameters": { 60 | "userIds": "123" 61 | } 62 | }, 63 | { 64 | "name": "gradualRolloutRandom", 65 | "parameters": { 66 | "percentage": "0" 67 | } 68 | } 69 | ] 70 | } 71 | ] 72 | } 73 | """ 74 | 75 | 76 | @pytest.fixture() 77 | def unleash_client(): 78 | unleash_client = UnleashClient(url=URL, 79 | app_name=APP_NAME, 80 | instance_id='pytest_%s' % uuid.uuid4()) 81 | yield unleash_client 82 | unleash_client.destroy() 83 | 84 | 85 | @responses.activate 86 | def test_feature_multiStrategies_a_enabled(unleash_client): 87 | """ 88 | Feature.multiStrategies.A should be enabled 89 | """ 90 | # Set up API 91 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 92 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 93 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 94 | 95 | # Tests 96 | unleash_client.initialize_client() 97 | assert unleash_client.is_enabled("Feature.multiStrategies.A") 98 | 99 | 100 | @responses.activate 101 | def test_feature_multiStrategies_b_nouser(unleash_client): 102 | """ 103 | Feature.multiStrategies.B disabled for unknown user 104 | """ 105 | # Set up API 106 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 107 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 108 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 109 | 110 | # Tests 111 | context = {} 112 | 113 | unleash_client.initialize_client() 114 | assert not unleash_client.is_enabled("Feature.multiStrategies.B", context) 115 | 116 | 117 | @responses.activate 118 | def test_feature_multiStrategies_b_enabled(unleash_client): 119 | """ 120 | Feature.multiStrategies.B enabled for user=123 121 | """ 122 | # Set up API 123 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 124 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 125 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 126 | 127 | # Tests 128 | context = { 129 | "userId": "123" 130 | } 131 | 132 | unleash_client.initialize_client() 133 | assert unleash_client.is_enabled("Feature.multiStrategies.B", context) 134 | 135 | 136 | @responses.activate 137 | def test_feature_multiStrategies_c_unknown(unleash_client): 138 | """ 139 | Feature.multiStrategies.C disabled for unknown users 140 | """ 141 | # Set up API 142 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 143 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 144 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 145 | 146 | # Tests 147 | context = {} 148 | 149 | unleash_client.initialize_client() 150 | assert not unleash_client.is_enabled("Feature.multiStrategies.C", context) 151 | 152 | 153 | @responses.activate 154 | def test_feature_multiStrategies_c_disabled(unleash_client): 155 | """ 156 | Feature.multiStrategies.C disabled for user=22 157 | """ 158 | # Set up API 159 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 160 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 161 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 162 | 163 | # Tests 164 | context = { 165 | "userId": "22" 166 | } 167 | 168 | unleash_client.initialize_client() 169 | assert not unleash_client.is_enabled("Feature.multiStrategies.C", context) 170 | 171 | 172 | @responses.activate 173 | def test_feature_multiStrategies_c_enabled(unleash_client): 174 | """ 175 | Feature.multiStrategies.C enabled for user=123 176 | """ 177 | # Set up API 178 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 179 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 180 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 181 | 182 | # Tests 183 | context = { 184 | "userId": "123" 185 | } 186 | 187 | unleash_client.initialize_client() 188 | assert unleash_client.is_enabled("Feature.multiStrategies.C", context) 189 | -------------------------------------------------------------------------------- /tests/specification_tests/test_10_flexible_rollout.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import json 3 | import pytest 4 | import responses 5 | from UnleashClient import UnleashClient 6 | from UnleashClient.constants import FEATURES_URL, METRICS_URL, REGISTER_URL 7 | from tests.utilities.testing_constants import URL, APP_NAME 8 | 9 | 10 | MOCK_JSON = """ 11 | { 12 | "version": 1, 13 | "features": [ 14 | { 15 | "name": "Feature.flexibleRollout.100", 16 | "description": "Should be enabled", 17 | "enabled": true, 18 | "strategies": [ 19 | { 20 | "name": "flexibleRollout", 21 | "parameters": { 22 | "rollout": "100", 23 | "stickiness": "default", 24 | "groupId": "Feature.flexibleRollout.100" 25 | }, 26 | "constraints": [] 27 | } 28 | ] 29 | }, 30 | { 31 | "name": "Feature.flexibleRollout.10", 32 | "description": "Should be enabled", 33 | "enabled": true, 34 | "strategies": [ 35 | { 36 | "name": "flexibleRollout", 37 | "parameters": { 38 | "rollout": "10", 39 | "stickiness": "default", 40 | "groupId": "Feature.flexibleRollout.10" 41 | }, 42 | "constraints": [] 43 | } 44 | ] 45 | }, 46 | { 47 | "name": "Feature.flexibleRollout.userId.55", 48 | "description": "Should be enabled", 49 | "enabled": true, 50 | "strategies": [ 51 | { 52 | "name": "flexibleRollout", 53 | "parameters": { 54 | "rollout": "55", 55 | "stickiness": "userId", 56 | "groupId": "Feature.flexibleRollout.userId.55" 57 | }, 58 | "constraints": [] 59 | } 60 | ] 61 | }, 62 | { 63 | "name": "Feature.flexibleRollout.sessionId.42", 64 | "description": "Should be enabled", 65 | "enabled": true, 66 | "strategies": [ 67 | { 68 | "name": "flexibleRollout", 69 | "parameters": { 70 | "rollout": "42", 71 | "stickiness": "sessionId", 72 | "groupId": "Feature.flexibleRollout.sessionId.42" 73 | }, 74 | "constraints": [] 75 | } 76 | ] 77 | } 78 | ] 79 | } 80 | """ 81 | 82 | 83 | @pytest.fixture() 84 | def unleash_client(): 85 | unleash_client = UnleashClient(url=URL, 86 | app_name=APP_NAME, 87 | instance_id='pytest_%s' % uuid.uuid4()) 88 | yield unleash_client 89 | unleash_client.destroy() 90 | 91 | 92 | @responses.activate 93 | def test_feature_100_enabled(unleash_client): 94 | """ 95 | Feature.flexibleRollout.100 should always be enabled 96 | """ 97 | # Set up API 98 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 99 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 100 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 101 | 102 | # Tests 103 | unleash_client.initialize_client() 104 | assert unleash_client.is_enabled("Feature.flexibleRollout.100", {}) 105 | 106 | 107 | @responses.activate 108 | def test_feature_10userid_enabled(unleash_client): 109 | """ 110 | Feature.flexibleRollout.10 should be enabled for userId=174 111 | """ 112 | # Set up API 113 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 114 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 115 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 116 | 117 | # Tests 118 | unleash_client.initialize_client() 119 | assert unleash_client.is_enabled("Feature.flexibleRollout.10", {"userId": "174"}) 120 | 121 | 122 | @responses.activate 123 | def test_feature_10userid_disabled(unleash_client): 124 | """ 125 | Feature.flexibleRollout.10 should be disabled for userId=499 126 | """ 127 | # Set up API 128 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 129 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 130 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 131 | 132 | # Tests 133 | unleash_client.initialize_client() 134 | assert not unleash_client.is_enabled("Feature.flexibleRollout.10", {"userId": "499"}) 135 | 136 | 137 | @responses.activate 138 | def test_feature_10sessionid_enabled(unleash_client): 139 | """ 140 | Feature.flexibleRollout.10 should be enabled for sessionId=174 141 | """ 142 | # Set up API 143 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 144 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 145 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 146 | 147 | # Tests 148 | unleash_client.initialize_client() 149 | assert unleash_client.is_enabled("Feature.flexibleRollout.10", {"sessionId": "174"}) 150 | 151 | 152 | @responses.activate 153 | def test_feature_10sessionid_disabled(unleash_client): 154 | """ 155 | Feature.flexibleRollout.10 should be disabled for sessionId=499 156 | """ 157 | # Set up API 158 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 159 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 160 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 161 | 162 | # Tests 163 | unleash_client.initialize_client() 164 | assert not unleash_client.is_enabled("Feature.flexibleRollout.10", {"userId": "499"}) 165 | 166 | 167 | @responses.activate 168 | def test_feature_55userid_enabled(unleash_client): 169 | """ 170 | Feature.flexibleRollout.userId.55 should be enabled for userId=25 171 | """ 172 | # Set up API 173 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 174 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 175 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 176 | 177 | # Tests 178 | unleash_client.initialize_client() 179 | assert unleash_client.is_enabled("Feature.flexibleRollout.userId.55", {"userId": "25"}) 180 | 181 | 182 | @responses.activate 183 | def test_feature_55sessionid_disabled(unleash_client): 184 | """ 185 | Feature.flexibleRollout.userId.55 should be disabled for sessionId=25 186 | """ 187 | # Set up API 188 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 189 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 190 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 191 | 192 | # Tests 193 | unleash_client.initialize_client() 194 | assert not unleash_client.is_enabled("Feature.flexibleRollout.userId.55", {"sessionId": "25"}) 195 | 196 | 197 | @responses.activate 198 | def test_feature_55_nouserdisabled(unleash_client): 199 | """ 200 | Feature.flexibleRollout.userId.55 should be disabled 201 | """ 202 | # Set up API 203 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 204 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 205 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 206 | 207 | # Tests 208 | unleash_client.initialize_client() 209 | assert not unleash_client.is_enabled("Feature.flexibleRollout.userId.55", {}) 210 | 211 | 212 | @responses.activate 213 | def test_feature_42sessionid_enabled(unleash_client): 214 | """ 215 | Feature.flexibleRollout.sessionId.42 should be enabled for sessionId=147 216 | """ 217 | # Set up API 218 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 219 | responses.add(responses.GET, URL + FEATURES_URL, json=json.loads(MOCK_JSON), status=200) 220 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 221 | 222 | # Tests 223 | unleash_client.initialize_client() 224 | assert unleash_client.is_enabled("Feature.flexibleRollout.sessionId.42", {"sessionId": "147"}) 225 | -------------------------------------------------------------------------------- /UnleashClient/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Callable, Optional 2 | 3 | from UnleashClient.periodic_tasks import fetch_and_load_features 4 | from UnleashClient.strategies import ( 5 | ApplicationHostname, Default, GradualRolloutRandom, GradualRolloutSessionId, GradualRolloutUserId, UserWithId, 6 | RemoteAddress, FlexibleRollout, EnableForDomains, EnableForBusinesses, EnableForPartners, EnableForExperts 7 | ) 8 | from UnleashClient import constants as consts 9 | from UnleashClient.strategies.EnableForTeamStrategy import EnableForTeams 10 | from UnleashClient.utils import LOGGER 11 | from UnleashClient.loader import load_features 12 | from UnleashClient.deprecation_warnings import strategy_v2xx_deprecation_check, default_value_warning 13 | 14 | 15 | # pylint: disable=dangerous-default-value 16 | class UnleashClient: 17 | """ 18 | Client implementation. 19 | """ 20 | def __init__(self, 21 | url: str, 22 | app_name: str, 23 | environment: str, 24 | cas_name: str, 25 | redis_host: str, 26 | redis_port: int, 27 | redis_db: int, 28 | instance_id: str = "unleash-client-python", 29 | refresh_interval: int = 15, 30 | metrics_interval: int = 60, 31 | disable_metrics: bool = False, 32 | disable_registration: bool = False, 33 | custom_headers: dict = {}, 34 | custom_options: dict = {}, 35 | custom_strategies: dict = {}, 36 | cache_directory: str = None, 37 | sentinel_enabled: bool = False, 38 | sentinels: Optional[list] = None, 39 | sentinel_service_name: Optional[str] = None, 40 | redis_auth_enabled: bool = False, 41 | redis_password: Optional[str] = None 42 | ) -> None: 43 | """ 44 | A client for the Unleash feature toggle system. 45 | :param url: URL of the unleash server, required. 46 | :param app_name: Name of the application using the unleash client, required. 47 | :param environment: Name of the environment using the unleash client, optinal & defaults to "default". 48 | :param instance_id: Unique identifier for unleash client instance, optional & defaults to "unleash-client-python" 49 | :param refresh_interval: Provisioning refresh interval in ms, optional & defaults to 15 seconds 50 | :param metrics_interval: Metrics refresh interval in ms, optional & defaults to 60 seconds 51 | :param disable_metrics: Disables sending metrics to unleash server, optional & defaults to false. 52 | :param custom_headers: Default headers to send to unleash server, optional & defaults to empty. 53 | :param custom_options: Default requests parameters, optional & defaults to empty. 54 | :param custom_strategies: Dictionary of custom strategy names : custom strategy objects 55 | :param cache_directory: Location of the cache directory. When unset, FCache will determine the location 56 | """ 57 | # Configuration 58 | self.unleash_url = url.rstrip('\\') 59 | self.unleash_app_name = app_name 60 | self.unleash_environment = f'{cas_name}|{environment}' 61 | self.unleash_instance_id = instance_id 62 | self.unleash_refresh_interval = refresh_interval 63 | self.unleash_metrics_interval = metrics_interval 64 | self.unleash_disable_metrics = disable_metrics 65 | self.unleash_disable_registration = disable_registration 66 | self.unleash_custom_headers = custom_headers 67 | self.unleash_custom_options = custom_options 68 | self.unleash_static_context = { 69 | "appName": self.unleash_app_name, 70 | "environment": self.unleash_environment 71 | } 72 | from FeatureToggle.redis_utils import RedisConnector 73 | if sentinel_enabled: 74 | self.cache = RedisConnector.get_sentinel_connection(sentinels, sentinel_service_name, redis_db, 75 | redis_auth_enabled, redis_password) 76 | else: 77 | self.cache = RedisConnector.get_non_sentinel_connection(redis_host, redis_port, redis_db, 78 | redis_auth_enabled, redis_password) 79 | 80 | self.features = {} # type: Dict 81 | 82 | # Mappings 83 | default_strategy_mapping = { 84 | "applicationHostname": ApplicationHostname, 85 | "default": Default, 86 | "gradualRolloutRandom": GradualRolloutRandom, 87 | "gradualRolloutSessionId": GradualRolloutSessionId, 88 | "gradualRolloutUserId": GradualRolloutUserId, 89 | "remoteAddress": RemoteAddress, 90 | "userWithId": UserWithId, 91 | "flexibleRollout": FlexibleRollout, 92 | "EnableForDomains": EnableForDomains, 93 | "EnableForExperts": EnableForExperts, 94 | "EnableForPartners": EnableForPartners, 95 | "EnableForBusinesses": EnableForBusinesses, 96 | "EnableForTeams": EnableForTeams 97 | } 98 | 99 | if custom_strategies: 100 | strategy_v2xx_deprecation_check([x for x in custom_strategies.values()]) # pylint: disable=R1721 101 | 102 | self.strategy_mapping = {**custom_strategies, **default_strategy_mapping} 103 | 104 | # Client status 105 | self.is_initialized = False 106 | 107 | def initialize_client(self) -> None: 108 | """ 109 | Initializes client and starts communication with central unleash server(s). 110 | This kicks off: 111 | * Client registration 112 | * Provisioning poll 113 | * Stats poll 114 | :return: 115 | """ 116 | # Setup 117 | fl_args = { 118 | "url": self.unleash_url, 119 | "app_name": self.unleash_app_name, 120 | "instance_id": self.unleash_instance_id, 121 | "custom_headers": self.unleash_custom_headers, 122 | "custom_options": self.unleash_custom_options, 123 | "cache": self.cache, 124 | "features": self.features, 125 | "strategy_mapping": self.strategy_mapping 126 | } 127 | 128 | # Disabling the first API call 129 | # fetch_and_load_features(**fl_args) 130 | load_features(self.cache, self.features, self.strategy_mapping) 131 | 132 | self.is_initialized = True 133 | 134 | def destroy(self): 135 | """ 136 | Gracefully shuts down the Unleash client by stopping jobs, stopping the scheduler, and deleting the cache. 137 | You shouldn't need this too much! 138 | :return: 139 | """ 140 | self.cache.delete() 141 | 142 | @staticmethod 143 | def _get_fallback_value(fallback_function: Callable, feature_name: str, context: dict) -> bool: 144 | if fallback_function: 145 | fallback_value = fallback_function(feature_name, context) 146 | else: 147 | fallback_value = False 148 | 149 | return fallback_value 150 | 151 | # pylint: disable=broad-except 152 | def is_enabled(self, 153 | feature_name: str, 154 | context: dict = {}, 155 | default_value: bool = False, 156 | fallback_function: Callable = None) -> bool: 157 | """ 158 | Checks if a feature toggle is enabled. 159 | Notes: 160 | * If client hasn't been initialized yet or an error occurs, flat will default to false. 161 | :param feature_name: Name of the feature 162 | :param context: Dictionary with context (e.g. IPs, email) for feature toggle. 163 | :param default_value: Allows override of default value. (DEPRECIATED, used fallback_function instead!) 164 | :param fallback_function: Allows users to provide a custom function to set default value. 165 | :return: True/False 166 | """ 167 | context.update(self.unleash_static_context) 168 | 169 | if default_value: 170 | default_value_warning() 171 | 172 | if self.is_initialized: 173 | try: 174 | return self.features[feature_name].is_enabled(context, default_value) 175 | except Exception as excep: 176 | LOGGER.warning("Returning default value for feature: %s", feature_name) 177 | LOGGER.warning("Error checking feature flag: %s", excep) 178 | return self._get_fallback_value(fallback_function, feature_name, context) 179 | else: 180 | LOGGER.warning("Returning default value for feature: %s", feature_name) 181 | LOGGER.warning("Attempted to get feature_flag %s, but client wasn't initialized!", feature_name) 182 | return self._get_fallback_value(fallback_function, feature_name, context) 183 | 184 | # pylint: disable=broad-except 185 | def get_variant(self, 186 | feature_name: str, 187 | context: dict = {}) -> dict: 188 | """ 189 | Checks if a feature toggle is enabled. If so, return variant. 190 | Notes: 191 | * If client hasn't been initialized yet or an error occurs, flat will default to false. 192 | :param feature_name: Name of the feature 193 | :param context: Dictionary with context (e.g. IPs, email) for feature toggle. 194 | :return: Dict with variant and feature flag status. 195 | """ 196 | context.update(self.unleash_static_context) 197 | 198 | if self.is_initialized: 199 | try: 200 | return self.features[feature_name].get_variant(context) 201 | except Exception as excep: 202 | LOGGER.warning("Returning default flag/variation for feature: %s", feature_name) 203 | LOGGER.warning("Error checking feature flag variant: %s", excep) 204 | return consts.DISABLED_VARIATION 205 | else: 206 | LOGGER.warning("Returning default flag/variation for feature: %s", feature_name) 207 | LOGGER.warning("Attempted to get feature flag/variation %s, but client wasn't initialized!", feature_name) 208 | return consts.DISABLED_VARIATION 209 | -------------------------------------------------------------------------------- /tests/unit_tests/test_client.py: -------------------------------------------------------------------------------- 1 | import time 2 | import json 3 | import pytest 4 | import responses 5 | from UnleashClient import UnleashClient 6 | from UnleashClient.strategies import Strategy 7 | from tests.utilities.testing_constants import URL, ENVIRONMENT, APP_NAME, INSTANCE_ID, REFRESH_INTERVAL, \ 8 | METRICS_INTERVAL, DISABLE_METRICS, DISABLE_REGISTRATION, CUSTOM_HEADERS, CUSTOM_OPTIONS 9 | from tests.utilities.mocks.mock_features import MOCK_FEATURE_RESPONSE 10 | from tests.utilities.mocks.mock_all_features import MOCK_ALL_FEATURES 11 | from UnleashClient.constants import REGISTER_URL, FEATURES_URL, METRICS_URL 12 | 13 | 14 | class EnvironmentStrategy(Strategy): 15 | def load_provisioning(self) -> list: 16 | return [x.strip() for x in self.parameters["environments"].split(',')] 17 | 18 | def apply(self, context: dict = None) -> bool: 19 | """ 20 | Turn on if environemnt is a match. 21 | 22 | :return: 23 | """ 24 | default_value = False 25 | 26 | if "environment" in context.keys(): 27 | default_value = context["environment"] in self.parsed_provisioning 28 | 29 | return default_value 30 | 31 | 32 | @pytest.fixture() 33 | def unleash_client(tmpdir): 34 | unleash_client = UnleashClient( 35 | URL, 36 | APP_NAME, 37 | refresh_interval=REFRESH_INTERVAL, 38 | metrics_interval=METRICS_INTERVAL, 39 | cache_directory=tmpdir.dirname 40 | ) 41 | yield unleash_client 42 | unleash_client.destroy() 43 | 44 | 45 | @pytest.fixture() 46 | def unleash_client_nodestroy(tmpdir): 47 | unleash_client = UnleashClient( 48 | URL, 49 | APP_NAME, 50 | refresh_interval=REFRESH_INTERVAL, 51 | metrics_interval=METRICS_INTERVAL, 52 | cache_directory=tmpdir.dirname 53 | ) 54 | yield unleash_client 55 | 56 | 57 | @pytest.fixture() 58 | def unleash_client_toggle_only(tmpdir): 59 | unleash_client = UnleashClient( 60 | URL, 61 | APP_NAME, 62 | refresh_interval=REFRESH_INTERVAL, 63 | metrics_interval=METRICS_INTERVAL, 64 | disable_registration=True, 65 | disable_metrics=True, 66 | cache_directory=str(tmpdir) 67 | ) 68 | yield unleash_client 69 | unleash_client.destroy() 70 | 71 | 72 | def test_UC_initialize_default(): 73 | client = UnleashClient(URL, APP_NAME) 74 | assert client.unleash_url == URL 75 | assert client.unleash_app_name == APP_NAME 76 | assert client.unleash_metrics_interval == 60 77 | 78 | 79 | def test_UC_initialize_full(): 80 | client = UnleashClient(URL, 81 | APP_NAME, 82 | ENVIRONMENT, 83 | INSTANCE_ID, 84 | REFRESH_INTERVAL, 85 | METRICS_INTERVAL, 86 | DISABLE_METRICS, 87 | DISABLE_REGISTRATION, 88 | CUSTOM_HEADERS, 89 | CUSTOM_OPTIONS) 90 | assert client.unleash_instance_id == INSTANCE_ID 91 | assert client.unleash_refresh_interval == REFRESH_INTERVAL 92 | assert client.unleash_metrics_interval == METRICS_INTERVAL 93 | assert client.unleash_disable_metrics == DISABLE_METRICS 94 | assert client.unleash_disable_registration == DISABLE_REGISTRATION 95 | assert client.unleash_custom_headers == CUSTOM_HEADERS 96 | assert client.unleash_custom_options == CUSTOM_OPTIONS 97 | 98 | 99 | def test_UC_type_violation(): 100 | client = UnleashClient(URL, APP_NAME, refresh_interval="60") 101 | assert client.unleash_url == URL 102 | assert client.unleash_app_name == APP_NAME 103 | assert client.unleash_refresh_interval == "60" 104 | 105 | 106 | @responses.activate 107 | def test_uc_lifecycle(unleash_client): 108 | # Set up API 109 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 110 | responses.add(responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200) 111 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 112 | 113 | # Create Unleash client and check initial load 114 | unleash_client.initialize_client() 115 | time.sleep(1) 116 | assert unleash_client.is_initialized 117 | assert len(unleash_client.features) >= 4 118 | 119 | # Simulate server provisioning change 120 | responses.add(responses.GET, URL + FEATURES_URL, json=MOCK_ALL_FEATURES, status=200) 121 | time.sleep(30) 122 | assert len(unleash_client.features) >= 9 123 | 124 | 125 | @responses.activate 126 | def test_uc_is_enabled(unleash_client): 127 | # Set up API 128 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 129 | responses.add(responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200) 130 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 131 | 132 | # Create Unleash client and check initial load 133 | unleash_client.initialize_client() 134 | time.sleep(1) 135 | assert unleash_client.is_enabled("testFlag") 136 | 137 | 138 | @responses.activate 139 | def test_uc_fallbackfunction(unleash_client, mocker): 140 | def good_fallback(feature_name: str, context: dict) -> bool: 141 | return True 142 | 143 | def bad_fallback(feature_name: str, context: dict) -> bool: 144 | return False 145 | 146 | def context_fallback(feature_name: str, context: dict) -> bool: 147 | return context['wat'] 148 | 149 | # Set up API 150 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 151 | responses.add(responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200) 152 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 153 | fallback_spy = mocker.Mock(wraps=good_fallback) 154 | 155 | # Create Unleash client and check initial load 156 | unleash_client.initialize_client() 157 | time.sleep(1) 158 | # Non-existent feature flag, fallback_function 159 | assert unleash_client.is_enabled("notFoundTestFlag", fallback_function=fallback_spy) 160 | assert fallback_spy.call_count == 1 161 | fallback_spy.reset_mock() 162 | 163 | # Non-existent feature flag, default value, fallback_function 164 | assert not unleash_client.is_enabled("notFoundTestFlag", fallback_function=bad_fallback) 165 | assert fallback_spy.call_count == 0 166 | 167 | # Existent feature flag, fallback_function 168 | assert unleash_client.is_enabled("testFlag", fallback_function=good_fallback) 169 | assert fallback_spy.call_count == 0 170 | 171 | 172 | @responses.activate 173 | def test_uc_dirty_cache(unleash_client_nodestroy): 174 | unleash_client = unleash_client_nodestroy 175 | # Set up API 176 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 177 | responses.add(responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200) 178 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 179 | 180 | # Create Unleash client and check initial load 181 | unleash_client.initialize_client() 182 | time.sleep(5) 183 | assert unleash_client.is_enabled("testFlag") 184 | unleash_client.scheduler.shutdown() 185 | 186 | # Check that everything works if previous cache exists. 187 | unleash_client.initialize_client() 188 | time.sleep(5) 189 | assert unleash_client.is_enabled("testFlag") 190 | 191 | 192 | @responses.activate 193 | def test_uc_is_enabled_with_context(): 194 | # Set up API 195 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 196 | responses.add(responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200) 197 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 198 | 199 | custom_strategies_dict = { 200 | "custom-context": EnvironmentStrategy 201 | } 202 | 203 | unleash_client = UnleashClient(URL, APP_NAME, environment='prod', custom_strategies=custom_strategies_dict) 204 | # Create Unleash client and check initial load 205 | unleash_client.initialize_client() 206 | 207 | time.sleep(1) 208 | assert unleash_client.is_enabled("testContextFlag") 209 | unleash_client.destroy() 210 | 211 | 212 | @responses.activate 213 | def test_uc_is_enabled_error_states(unleash_client): 214 | # Set up API 215 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 216 | responses.add(responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200) 217 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 218 | 219 | # Create Unleash client and check initial load 220 | unleash_client.initialize_client() 221 | time.sleep(1) 222 | assert not unleash_client.is_enabled("ThisFlagDoesn'tExist") 223 | assert unleash_client.is_enabled("ThisFlagDoesn'tExist", fallback_function=lambda x, y: True) 224 | 225 | 226 | @responses.activate 227 | def test_uc_not_initialized_isenabled(): 228 | unleash_client = UnleashClient(URL, APP_NAME) 229 | assert not unleash_client.is_enabled("ThisFlagDoesn'tExist") 230 | assert unleash_client.is_enabled("ThisFlagDoesn'tExist", fallback_function=lambda x, y: True) 231 | 232 | 233 | @responses.activate 234 | def test_uc_get_variant(): 235 | # Set up API 236 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 237 | responses.add(responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200) 238 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 239 | 240 | unleash_client = UnleashClient(URL, APP_NAME) 241 | # Create Unleash client and check initial load 242 | unleash_client.initialize_client() 243 | 244 | time.sleep(1) 245 | # If feature flag is on. 246 | variant = unleash_client.get_variant("testVariations", context={'userId': '2'}) 247 | assert variant['name'] == 'VarA' 248 | assert variant['enabled'] 249 | 250 | # If feature flag is not. 251 | variant = unleash_client.get_variant("testVariations", context={'userId': '3'}) 252 | assert variant['name'] == 'disabled' 253 | assert not variant['enabled'] 254 | 255 | unleash_client.destroy() 256 | 257 | 258 | @responses.activate 259 | def test_uc_not_initialized_getvariant(): 260 | unleash_client = UnleashClient(URL, APP_NAME) 261 | variant = unleash_client.get_variant("ThisFlagDoesn'tExist") 262 | assert not variant['enabled'] 263 | assert variant['name'] == 'disabled' 264 | 265 | 266 | @responses.activate 267 | def test_uc_metrics(unleash_client): 268 | # Set up API 269 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) 270 | responses.add(responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200) 271 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) 272 | 273 | # Create Unleash client and check initial load 274 | unleash_client.initialize_client() 275 | time.sleep(1) 276 | assert unleash_client.is_enabled("testFlag") 277 | 278 | time.sleep(12) 279 | request = json.loads(responses.calls[-1].request.body) 280 | assert request['bucket']["toggles"]["testFlag"]["yes"] == 1 281 | 282 | 283 | @responses.activate 284 | def test_uc_disabled_registration(unleash_client_toggle_only): 285 | unleash_client = unleash_client_toggle_only 286 | # Set up APIs 287 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=401) 288 | responses.add(responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200) 289 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=401) 290 | 291 | unleash_client.initialize_client() 292 | unleash_client.is_enabled("testFlag") 293 | time.sleep(20) 294 | assert unleash_client.is_enabled("testFlag") 295 | 296 | for api_call in responses.calls: 297 | assert '/api/client/features' in api_call.request.url 298 | 299 | 300 | @responses.activate 301 | def test_uc_server_error(unleash_client): 302 | # Verify that Unleash Client will still fall back gracefully if SERVER ANGRY RAWR, and then recover gracefully. 303 | 304 | unleash_client = unleash_client 305 | # Set up APIs 306 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=401) 307 | responses.add(responses.GET, URL + FEATURES_URL, status=500) 308 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=401) 309 | 310 | unleash_client.initialize_client() 311 | assert not unleash_client.is_enabled("testFlag") 312 | 313 | responses.remove(responses.GET, URL + FEATURES_URL) 314 | responses.add(responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200) 315 | time.sleep(20) 316 | assert unleash_client.is_enabled("testFlag") 317 | 318 | 319 | @responses.activate 320 | def test_uc_server_error_recovery(unleash_client): 321 | # Verify that Unleash Client will still fall back gracefully if SERVER ANGRY RAWR, and then recover gracefully. 322 | 323 | unleash_client = unleash_client 324 | # Set up APIs 325 | responses.add(responses.POST, URL + REGISTER_URL, json={}, status=401) 326 | responses.add(responses.GET, URL + FEATURES_URL, status=500) 327 | responses.add(responses.POST, URL + METRICS_URL, json={}, status=401) 328 | 329 | unleash_client.initialize_client() 330 | assert not unleash_client.is_enabled("testFlag") 331 | 332 | responses.remove(responses.GET, URL + FEATURES_URL) 333 | responses.add(responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200) 334 | time.sleep(20) 335 | assert unleash_client.is_enabled("testFlag") 336 | --------------------------------------------------------------------------------