From af0bcd62f9c8ac42781c7975bab0469bdf7d2f48 Mon Sep 17 00:00:00 2001 From: Ivan Lee Date: Thu, 24 Oct 2019 12:31:08 -0400 Subject: [PATCH] Add fallback function so users can customize default values. (#79) * Add fallback function so users can customize default values. * Clean up new unit test. * Defer evaluation of fallback for catch-all exception. * Update documenation for fallback function feature. * Add unit test for context usage in fallback function. * Fix mock requirement. * Add pytest-mock. --- README.md | 18 +++++++++++++--- UnleashClient/__init__.py | 16 +++++++++++---- UnleashClient/features/Feature.py | 10 +++++++-- docs/changelog.md | 5 +++++ docs/index.md | 18 +++++++++++++--- docs/strategy.md | 2 +- requirements.txt | 1 + setup.py | 2 +- tests/unit_tests/test_client.py | 34 +++++++++++++++++++++++++++++++ 9 files changed, 92 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 8d2004b6..7a1d36d6 100644 --- a/README.md +++ b/README.md @@ -55,17 +55,29 @@ custom_strategies | Custom strategies you'd like UnleashClient to support. | N | ### Checking if a feature is enabled A check of a simple toggle: -``` +```Python client.is_enabled("My Toggle") ``` Specifying a default value: -``` +```Python client.is_enabled("My Toggle", default_value=True) ``` Supplying application context: -``` +```Python app_context = {"userId": "test@email.com"} client.is_enabled("User ID Toggle", app_context) ``` + +Supplying a fallback function: +```Python +def custom_fallback(feature_name: str, context: dict) -> bool: + return True + +client.is_enabled("My Toggle", fallback_function=custom_fallback) +``` + +- Must accept the fature name and context as an argument. +- Client will evaluate the fallback function once per call of `is_enabled()`. Please keep this in mind when creating your fallback function! +- 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. \ No newline at end of file diff --git a/UnleashClient/__init__.py b/UnleashClient/__init__.py index e5cc641c..727b3deb 100644 --- a/UnleashClient/__init__.py +++ b/UnleashClient/__init__.py @@ -1,5 +1,5 @@ from datetime import datetime, timezone -from typing import Dict +from typing import Dict, Callable from fcache.cache import FileCache from apscheduler.job import Job from apscheduler.schedulers.background import BackgroundScheduler @@ -164,7 +164,8 @@ def destroy(self): def is_enabled(self, feature_name: str, context: dict = {}, - default_value: bool = False) -> bool: + default_value: bool = False, + fallback_function: Callable = None) -> bool: """ Checks if a feature toggle is enabled. @@ -174,17 +175,24 @@ def is_enabled(self, :param feature_name: Name of the feature :param context: Dictionary with context (e.g. IPs, email) for feature toggle. :param default_value: Allows override of default value. + :param fallback_function: Allows users to provide a custom function to set default value. :return: True/False """ context.update(self.unleash_static_context) if self.is_initialized: try: - return self.features[feature_name].is_enabled(context, default_value) + return self.features[feature_name].is_enabled(context, default_value, fallback_function) except Exception as excep: LOGGER.warning("Returning default value for feature: %s", feature_name) LOGGER.warning("Error checking feature flag: %s", excep) - return default_value + + if fallback_function: + fallback_value = default_value or fallback_function(feature_name, context) + else: + fallback_value = default_value + + return fallback_value else: LOGGER.warning("Returning default value for feature: %s", feature_name) LOGGER.warning("Attempted to get feature_flag %s, but client wasn't initialized!", feature_name) diff --git a/UnleashClient/features/Feature.py b/UnleashClient/features/Feature.py index 92e9e5dc..1ac5d6c9 100644 --- a/UnleashClient/features/Feature.py +++ b/UnleashClient/features/Feature.py @@ -1,3 +1,4 @@ +from typing import Callable from UnleashClient.utils import LOGGER @@ -46,15 +47,20 @@ def increment_stats(self, result: bool) -> None: def is_enabled(self, context: dict = None, - default_value: bool = False) -> bool: + default_value: bool = False, + fallback_function: Callable = None) -> bool: """ Checks if feature is enabled. :param context: Context information :param default_value: Optional, but allows for override. + :param fallback_function: Optional, but allows for fallback function. :return: """ - flag_value = default_value + if fallback_function: + flag_value = default_value or fallback_function(self.name, context) + else: + flag_value = default_value if self.enabled: try: diff --git a/docs/changelog.md b/docs/changelog.md index 035fa4f6..d82ea31b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,3 +1,8 @@ +## v3.2.0 +**General** + +* (Major) Allow users to supply a fallback function to customize the default value of a feature flag. + ## v3.1.1 **Bugfixes** diff --git a/docs/index.md b/docs/index.md index fceb5243..dff408f8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,21 +26,33 @@ client.destroy() ## Checking if a feature is enabled A check of a simple toggle: -``` +```Python client.is_enabled("My Toggle") ``` Specifying a default value: -``` +```Python client.is_enabled("My Toggle", default_value=True) ``` Supplying application context: -``` +```Python app_context = {"userId": "test@email.com"} client.is_enabled("User ID Toggle", app_context) ``` +Supplying a fallback function: +```Python +def custom_fallback(feature_name: str, context: dict) -> bool: + return True + +client.is_enabled("My Toggle", fallback_function=custom_fallback) +``` + +- Must accept the fature name and context as an argument. +- Client will evaluate the fallback function once per call of `is_enabled()`. Please keep this in mind when creating your fallback function! +- 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. + ## Logging Unleash Client uses the built-in logging facility to show information about errors, background jobs (feature-flag updates and metrics), et cetera. diff --git a/docs/strategy.md b/docs/strategy.md index ff1d7005..280ee4a6 100644 --- a/docs/strategy.md +++ b/docs/strategy.md @@ -16,7 +16,7 @@ Method to load data on object initialization, if desired. This should parse the 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). -## `_call_(context)` +## `apply(context)` Strategy implementation goes here. **Arguments** diff --git a/requirements.txt b/requirements.txt index 3efa7e2e..7d5f4898 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,7 @@ pytest pytest-cov pytest-flake8 pytest-html==1.22.0 +pytest-mock pytest-rerunfailures pytest-runner pytest-xdist diff --git a/setup.py b/setup.py index f2214e08..9a476ca8 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ def readme(): "fcache==0.4.7", "mmh3==2.5.1", "apscheduler==3.6.1"], - tests_require=['pytest', "mimesis", "responses"], + tests_require=['pytest', "mimesis", "responses", 'pytest-mock'], zip_safe=False, include_package_data=True, classifiers=[ diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py index bf3bd3cd..2f41277f 100644 --- a/tests/unit_tests/test_client.py +++ b/tests/unit_tests/test_client.py @@ -135,6 +135,40 @@ def test_uc_is_enabled(unleash_client): assert unleash_client.is_enabled("testFlag") +@responses.activate +def test_uc_fallbackfunction(unleash_client, mocker): + def good_fallback(feature_name: str, context: dict) -> bool: + return True + + def bad_fallback(feature_name: str, context: dict) -> bool: + return False + + def context_fallback(feature_name: str, context: dict) -> bool: + return context['wat'] + + # Set up API + responses.add(responses.POST, URL + REGISTER_URL, json={}, status=202) + responses.add(responses.GET, URL + FEATURES_URL, json=MOCK_FEATURE_RESPONSE, status=200) + responses.add(responses.POST, URL + METRICS_URL, json={}, status=202) + fallback_spy = mocker.Mock(wraps=good_fallback) + + # Create Unleash client and check initial load + unleash_client.initialize_client() + time.sleep(1) + # Only fallback function. + assert unleash_client.is_enabled("testFlag", fallback_function=fallback_spy) + assert fallback_spy.call_count == 1 + + # Default value and fallback function. + assert unleash_client.is_enabled("testFlag", default_value=True, fallback_function=bad_fallback) + + # Handle exceptions or invalid feature flags. + assert unleash_client.is_enabled("notFoundTestFlag", fallback_function=good_fallback) + + # Handle execption using context. + assert unleash_client.is_enabled("notFoundTestFlag", context={'wat': True}, fallback_function=context_fallback) + + @responses.activate def test_uc_dirty_cache(unleash_client_nodestroy): unleash_client = unleash_client_nodestroy