Skip to content

Commit

Permalink
Add fallback function so users can customize default values. (#79)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
ivanklee86 committed Oct 24, 2019
1 parent 438479c commit af0bcd6
Show file tree
Hide file tree
Showing 9 changed files with 92 additions and 14 deletions.
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 12 additions & 4 deletions UnleashClient/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down
10 changes: 8 additions & 2 deletions UnleashClient/features/Feature.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from typing import Callable
from UnleashClient.utils import LOGGER


Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -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**

Expand Down
18 changes: 15 additions & 3 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pytest
pytest-cov
pytest-flake8
pytest-html==1.22.0
pytest-mock
pytest-rerunfailures
pytest-runner
pytest-xdist
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=[
Expand Down
34 changes: 34 additions & 0 deletions tests/unit_tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit af0bcd6

Please sign in to comment.