Skip to content

Commit af0bcd6

Browse files
authored
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.
1 parent 438479c commit af0bcd6

File tree

9 files changed

+92
-14
lines changed

9 files changed

+92
-14
lines changed

README.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,17 +55,29 @@ custom_strategies | Custom strategies you'd like UnleashClient to support. | N |
5555
### Checking if a feature is enabled
5656

5757
A check of a simple toggle:
58-
```
58+
```Python
5959
client.is_enabled("My Toggle")
6060
```
6161

6262
Specifying a default value:
63-
```
63+
```Python
6464
client.is_enabled("My Toggle", default_value=True)
6565
```
6666

6767
Supplying application context:
68-
```
68+
```Python
6969
app_context = {"userId": "[email protected]"}
7070
client.is_enabled("User ID Toggle", app_context)
7171
```
72+
73+
Supplying a fallback function:
74+
```Python
75+
def custom_fallback(feature_name: str, context: dict) -> bool:
76+
return True
77+
78+
client.is_enabled("My Toggle", fallback_function=custom_fallback)
79+
```
80+
81+
- Must accept the fature name and context as an argument.
82+
- Client will evaluate the fallback function once per call of `is_enabled()`. Please keep this in mind when creating your fallback function!
83+
- 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.

UnleashClient/__init__.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from datetime import datetime, timezone
2-
from typing import Dict
2+
from typing import Dict, Callable
33
from fcache.cache import FileCache
44
from apscheduler.job import Job
55
from apscheduler.schedulers.background import BackgroundScheduler
@@ -164,7 +164,8 @@ def destroy(self):
164164
def is_enabled(self,
165165
feature_name: str,
166166
context: dict = {},
167-
default_value: bool = False) -> bool:
167+
default_value: bool = False,
168+
fallback_function: Callable = None) -> bool:
168169
"""
169170
Checks if a feature toggle is enabled.
170171
@@ -174,17 +175,24 @@ def is_enabled(self,
174175
:param feature_name: Name of the feature
175176
:param context: Dictionary with context (e.g. IPs, email) for feature toggle.
176177
:param default_value: Allows override of default value.
178+
:param fallback_function: Allows users to provide a custom function to set default value.
177179
:return: True/False
178180
"""
179181
context.update(self.unleash_static_context)
180182

181183
if self.is_initialized:
182184
try:
183-
return self.features[feature_name].is_enabled(context, default_value)
185+
return self.features[feature_name].is_enabled(context, default_value, fallback_function)
184186
except Exception as excep:
185187
LOGGER.warning("Returning default value for feature: %s", feature_name)
186188
LOGGER.warning("Error checking feature flag: %s", excep)
187-
return default_value
189+
190+
if fallback_function:
191+
fallback_value = default_value or fallback_function(feature_name, context)
192+
else:
193+
fallback_value = default_value
194+
195+
return fallback_value
188196
else:
189197
LOGGER.warning("Returning default value for feature: %s", feature_name)
190198
LOGGER.warning("Attempted to get feature_flag %s, but client wasn't initialized!", feature_name)

UnleashClient/features/Feature.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from typing import Callable
12
from UnleashClient.utils import LOGGER
23

34

@@ -46,15 +47,20 @@ def increment_stats(self, result: bool) -> None:
4647

4748
def is_enabled(self,
4849
context: dict = None,
49-
default_value: bool = False) -> bool:
50+
default_value: bool = False,
51+
fallback_function: Callable = None) -> bool:
5052
"""
5153
Checks if feature is enabled.
5254
5355
:param context: Context information
5456
:param default_value: Optional, but allows for override.
57+
:param fallback_function: Optional, but allows for fallback function.
5558
:return:
5659
"""
57-
flag_value = default_value
60+
if fallback_function:
61+
flag_value = default_value or fallback_function(self.name, context)
62+
else:
63+
flag_value = default_value
5864

5965
if self.enabled:
6066
try:

docs/changelog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## v3.2.0
2+
**General**
3+
4+
* (Major) Allow users to supply a fallback function to customize the default value of a feature flag.
5+
16
## v3.1.1
27
**Bugfixes**
38

docs/index.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,33 @@ client.destroy()
2626
## Checking if a feature is enabled
2727

2828
A check of a simple toggle:
29-
```
29+
```Python
3030
client.is_enabled("My Toggle")
3131
```
3232

3333
Specifying a default value:
34-
```
34+
```Python
3535
client.is_enabled("My Toggle", default_value=True)
3636
```
3737

3838
Supplying application context:
39-
```
39+
```Python
4040
app_context = {"userId": "[email protected]"}
4141
client.is_enabled("User ID Toggle", app_context)
4242
```
4343

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 once per call of `is_enabled()`. Please keep this in mind when creating your fallback function!
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+
4456
## Logging
4557

4658
Unleash Client uses the built-in logging facility to show information about errors, background jobs (feature-flag updates and metrics), et cetera.

docs/strategy.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Method to load data on object initialization, if desired. This should parse the
1616

1717
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).
1818

19-
## `_call_(context)`
19+
## `apply(context)`
2020
Strategy implementation goes here.
2121

2222
**Arguments**

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pytest
1616
pytest-cov
1717
pytest-flake8
1818
pytest-html==1.22.0
19+
pytest-mock
1920
pytest-rerunfailures
2021
pytest-runner
2122
pytest-xdist

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def readme():
2222
"fcache==0.4.7",
2323
"mmh3==2.5.1",
2424
"apscheduler==3.6.1"],
25-
tests_require=['pytest', "mimesis", "responses"],
25+
tests_require=['pytest', "mimesis", "responses", 'pytest-mock'],
2626
zip_safe=False,
2727
include_package_data=True,
2828
classifiers=[

tests/unit_tests/test_client.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,40 @@ def test_uc_is_enabled(unleash_client):
135135
assert unleash_client.is_enabled("testFlag")
136136

137137

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+
# Only fallback function.
159+
assert unleash_client.is_enabled("testFlag", fallback_function=fallback_spy)
160+
assert fallback_spy.call_count == 1
161+
162+
# Default value and fallback function.
163+
assert unleash_client.is_enabled("testFlag", default_value=True, fallback_function=bad_fallback)
164+
165+
# Handle exceptions or invalid feature flags.
166+
assert unleash_client.is_enabled("notFoundTestFlag", fallback_function=good_fallback)
167+
168+
# Handle execption using context.
169+
assert unleash_client.is_enabled("notFoundTestFlag", context={'wat': True}, fallback_function=context_fallback)
170+
171+
138172
@responses.activate
139173
def test_uc_dirty_cache(unleash_client_nodestroy):
140174
unleash_client = unleash_client_nodestroy

0 commit comments

Comments
 (0)