10
10
11
11
class FeatureFlags :
12
12
def __init__ (self , store : StoreProvider ):
13
- """constructor
13
+ """Evaluates whether feature flags should be enabled based on a given context.
14
+
15
+ It uses the provided store to fetch feature flag rules before evaluating them.
16
+
17
+ Examples
18
+ --------
19
+
20
+ ```python
21
+ from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore
22
+
23
+ app_config = AppConfigStore(
24
+ environment="test",
25
+ application="powertools",
26
+ name="test_conf_name",
27
+ cache_seconds=300,
28
+ envelope="features"
29
+ )
30
+
31
+ feature_flags: FeatureFlags = FeatureFlags(store=app_config)
32
+ ```
14
33
15
34
Parameters
16
35
----------
17
36
store: StoreProvider
18
- A schema JSON fetcher, can be AWS AppConfig, Hashicorp Consul etc .
37
+ Store to use to fetch feature flag schema configuration .
19
38
"""
20
- self ._logger = logger
21
39
self ._store = store
22
40
23
- def _match_by_action (self , action : str , condition_value : Any , context_value : Any ) -> bool :
41
+ @staticmethod
42
+ def _match_by_action (action : str , condition_value : Any , context_value : Any ) -> bool :
24
43
if not context_value :
25
44
return False
26
45
mapping_by_action = {
@@ -34,88 +53,91 @@ def _match_by_action(self, action: str, condition_value: Any, context_value: Any
34
53
func = mapping_by_action .get (action , lambda a , b : False )
35
54
return func (context_value , condition_value )
36
55
except Exception as exc :
37
- self . _logger .debug (f"caught exception while matching action: action={ action } , exception={ str (exc )} " )
56
+ logger .debug (f"caught exception while matching action: action={ action } , exception={ str (exc )} " )
38
57
return False
39
58
40
- def _is_rule_matched (
59
+ def _evaluate_conditions (
41
60
self , rule_name : str , feature_name : str , rule : Dict [str , Any ], context : Dict [str , Any ]
42
61
) -> bool :
43
- rule_default_value = rule .get (schema .RULE_DEFAULT_VALUE )
62
+ """Evaluates whether context matches conditions, return False otherwise"""
63
+ rule_match_value = rule .get (schema .RULE_MATCH_VALUE )
44
64
conditions = cast (List [Dict ], rule .get (schema .CONDITIONS_KEY ))
45
65
46
66
for condition in conditions :
47
67
context_value = context .get (str (condition .get (schema .CONDITION_KEY )))
48
- if not self ._match_by_action (
49
- condition .get (schema .CONDITION_ACTION , "" ),
50
- condition .get (schema .CONDITION_VALUE ),
51
- context_value ,
52
- ):
68
+ cond_action = condition .get (schema .CONDITION_ACTION , "" )
69
+ cond_value = condition .get (schema .CONDITION_VALUE )
70
+
71
+ if not self ._match_by_action (action = cond_action , condition_value = cond_value , context_value = context_value ):
53
72
logger .debug (
54
- f"rule did not match action, rule_name={ rule_name } , rule_default_value= { rule_default_value } , "
73
+ f"rule did not match action, rule_name={ rule_name } , rule_value= { rule_match_value } , "
55
74
f"name={ feature_name } , context_value={ str (context_value )} "
56
75
)
57
- # context doesn't match condition
58
- return False
59
- # if we got here, all conditions match
60
- logger .debug (
61
- f"rule matched, rule_name={ rule_name } , rule_default_value={ rule_default_value } , name={ feature_name } "
62
- )
76
+ return False # context doesn't match condition
77
+
78
+ logger .debug (f"rule matched, rule_name={ rule_name } , rule_value={ rule_match_value } , name={ feature_name } " )
63
79
return True
64
80
return False
65
81
66
- def _handle_rules (
67
- self ,
68
- * ,
69
- feature_name : str ,
70
- context : Dict [str , Any ],
71
- feature_default_value : bool ,
72
- rules : Dict [str , Any ],
82
+ def _evaluate_rules (
83
+ self , * , feature_name : str , context : Dict [str , Any ], feat_default : bool , rules : Dict [str , Any ]
73
84
) -> bool :
85
+ """Evaluates whether context matches rules and conditions, otherwise return feature default"""
74
86
for rule_name , rule in rules .items ():
75
- rule_default_value = rule .get (schema .RULE_DEFAULT_VALUE )
76
- if self ._is_rule_matched (rule_name = rule_name , feature_name = feature_name , rule = rule , context = context ):
77
- return bool (rule_default_value )
87
+ rule_match_value = rule .get (schema .RULE_MATCH_VALUE )
88
+
89
+ # Context might contain PII data; do not log its value
90
+ logger .debug (f"Evaluating rule matching, rule={ rule_name } , feature={ feature_name } , default={ feat_default } " )
91
+ if self ._evaluate_conditions (rule_name = rule_name , feature_name = feature_name , rule = rule , context = context ):
92
+ return bool (rule_match_value )
93
+
78
94
# no rule matched, return default value of feature
79
- logger .debug (
80
- f"no rule matched, returning default value of feature, feature_default_value={ feature_default_value } , "
81
- f"name={ feature_name } "
82
- )
83
- return feature_default_value
95
+ logger .debug (f"no rule matched, returning feature default, default={ feat_default } , name={ feature_name } " )
96
+ return feat_default
84
97
return False
85
98
86
99
def get_configuration (self ) -> Union [Dict [str , Dict ], Dict ]:
87
- """Get configuration string from AWs AppConfig and returned the parsed JSON dictionary
100
+ """Get validated feature flag schema from configured store.
101
+
102
+ Largely used to aid testing, since it's called by `evaluate` and `get_enabled_features` methods.
88
103
89
104
Raises
90
105
------
91
106
ConfigurationError
92
- Any validation error or appconfig error that can occur
107
+ Any validation error
93
108
94
109
Returns
95
110
------
96
111
Dict[str, Dict]
97
112
parsed JSON dictionary
98
113
114
+ **Example**
115
+
116
+ ```python
99
117
{
100
- "my_feature": {
101
- "feature_default_value": True,
102
- "rules": [
103
- {
104
- "rule_name": "tenant id equals 345345435",
105
- "value_when_applies": False,
118
+ "premium_features": {
119
+ "default": False,
120
+ "rules": {
121
+ "customer tier equals premium": {
122
+ "when_match": True,
106
123
"conditions": [
107
124
{
108
125
"action": "EQUALS",
109
- "key": "tenant_id ",
110
- "value": "345345435 ",
126
+ "key": "tier ",
127
+ "value": "premium ",
111
128
}
112
129
],
113
- },
114
- ],
130
+ }
131
+ },
132
+ },
133
+ "feature_two": {
134
+ "default": False
115
135
}
116
136
}
137
+ ```
117
138
"""
118
- # parse result conf as JSON, keep in cache for self.max_age seconds
139
+ # parse result conf as JSON, keep in cache for max age defined in store
140
+ logger .debug (f"Fetching schema from registered store, store={ self ._store } " )
119
141
config = self ._store .get_configuration ()
120
142
121
143
validator = schema .SchemaValidator (schema = config )
@@ -124,68 +146,56 @@ def get_configuration(self) -> Union[Dict[str, Dict], Dict]:
124
146
return config
125
147
126
148
def evaluate (self , * , name : str , context : Optional [Dict [str , Any ]] = None , default : bool ) -> bool :
127
- """Get a feature toggle boolean value. Value is calculated according to a set of rules and conditions.
149
+ """Evaluate whether a feature flag should be enabled according to stored schema and input context
150
+
151
+ **Logic when evaluating a feature flag**
128
152
129
- See below for explanation.
153
+ 1. Feature exists and a rule matches, returns when_match value
154
+ 2. Feature exists but has either no rules or no match, return feature default value
155
+ 3. Feature doesn't exist in stored schema, encountered an error when fetching -> return default value provided
130
156
131
157
Parameters
132
158
----------
133
159
name: str
134
- feature name that you wish to fetch
160
+ feature name to evaluate
135
161
context: Optional[Dict[str, Any]]
136
- dict of attributes that you would like to match the rules
137
- against, can be {'tenant_id: 'X', 'username':' 'Y', 'region': 'Z'} etc.
162
+ Attributes that should be evaluated against the stored schema.
163
+
164
+ for example: `{"tenant_id": "X", "username": "Y", "region": "Z"}`
138
165
default: bool
139
166
default value if feature flag doesn't exist in the schema,
140
- or there has been an error while fetching the configuration from appconfig
167
+ or there has been an error when fetching the configuration from the store
141
168
142
169
Returns
143
170
------
144
171
bool
145
- calculated feature toggle value. several possibilities:
146
- 1. if the feature doesn't appear in the schema or there has been an error fetching the
147
- configuration -> error/warning log would appear and value_if_missing is returned
148
- 2. feature exists and has no rules or no rules have matched -> return feature_default_value of
149
- the defined feature
150
- 3. feature exists and a rule matches -> rule_default_value of rule is returned
172
+ whether feature should be enabled or not
151
173
"""
152
174
if context is None :
153
175
context = {}
154
176
155
177
try :
156
178
features = self .get_configuration ()
157
179
except ConfigurationError as err :
158
- logger .debug (f"Unable to get feature toggles JSON , returning provided default value , reason={ err } " )
180
+ logger .debug (f"Failed to fetch feature flags from store , returning default provided , reason={ err } " )
159
181
return default
160
182
161
183
feature = features .get (name )
162
184
if feature is None :
163
- logger .debug (
164
- f"feature does not appear in configuration, using provided default, name={ name } , default={ default } "
165
- )
185
+ logger .debug (f"Feature not found; returning default provided, name={ name } , default={ default } " )
166
186
return default
167
187
168
188
rules = feature .get (schema .RULES_KEY )
169
- feature_default_value = feature .get (schema .FEATURE_DEFAULT_VAL_KEY )
189
+ feat_default = feature .get (schema .FEATURE_DEFAULT_VAL_KEY )
170
190
if not rules :
171
- # no rules but value
172
- logger .debug (
173
- f"no rules found, returning feature default value, name={ name } , "
174
- f"default_value={ feature_default_value } "
175
- )
176
- return bool (feature_default_value )
177
-
178
- # look for first rule match
179
- logger .debug (f"looking for rule match, name={ name } , feature_default_value={ feature_default_value } " )
180
- return self ._handle_rules (
181
- feature_name = name ,
182
- context = context ,
183
- feature_default_value = bool (feature_default_value ),
184
- rules = rules ,
185
- )
191
+ logger .debug (f"no rules found, returning feature default, name={ name } , default={ feat_default } " )
192
+ return bool (feat_default )
193
+
194
+ logger .debug (f"looking for rule match, name={ name } , default={ feat_default } " )
195
+ return self ._evaluate_rules (feature_name = name , context = context , feat_default = bool (feat_default ), rules = rules )
186
196
187
197
def get_enabled_features (self , * , context : Optional [Dict [str , Any ]] = None ) -> List [str ]:
188
- """Get all enabled feature toggles while also taking into account rule_context
198
+ """Get all enabled feature flags while also taking into account context
189
199
(when a feature has defined rules)
190
200
191
201
Parameters
@@ -197,8 +207,13 @@ def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> L
197
207
Returns
198
208
----------
199
209
List[str]
200
- a list of all features name that are enabled by also taking into account
201
- rule_context (when a feature has defined rules)
210
+ list of all feature names that either matches context or have True as default
211
+
212
+ **Example**
213
+
214
+ ```python
215
+ ["premium_features", "my_feature_two", "always_true_feature"]
216
+ ```
202
217
"""
203
218
if context is None :
204
219
context = {}
@@ -207,23 +222,20 @@ def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> L
207
222
208
223
try :
209
224
features : Dict [str , Any ] = self .get_configuration ()
210
- except ConfigurationError :
211
- logger .debug ("unable to get feature toggles JSON " )
225
+ except ConfigurationError as err :
226
+ logger .debug (f"Failed to fetch feature flags from store, returning empty list, reason= { err } " )
212
227
return features_enabled
213
228
214
- for feature_name , feature_dict_def in features .items ():
215
- rules = feature_dict_def .get (schema .RULES_KEY , {})
216
- feature_default_value = feature_dict_def .get (schema .FEATURE_DEFAULT_VAL_KEY )
229
+ for name , feature in features .items ():
230
+ rules = feature .get (schema .RULES_KEY , {})
231
+ feature_default_value = feature .get (schema .FEATURE_DEFAULT_VAL_KEY )
217
232
if feature_default_value and not rules :
218
- self ._logger .debug (f"feature is enabled by default and has no defined rules, name={ feature_name } " )
219
- features_enabled .append (feature_name )
220
- elif self ._handle_rules (
221
- feature_name = feature_name ,
222
- context = context ,
223
- feature_default_value = feature_default_value ,
224
- rules = rules ,
233
+ logger .debug (f"feature is enabled by default and has no defined rules, name={ name } " )
234
+ features_enabled .append (name )
235
+ elif self ._evaluate_rules (
236
+ feature_name = name , context = context , feat_default = feature_default_value , rules = rules
225
237
):
226
- self . _logger . debug (f"feature's calculated value is True, name={ feature_name } " )
227
- features_enabled .append (feature_name )
238
+ logger . debug (f"feature's calculated value is True, name={ name } " )
239
+ features_enabled .append (name )
228
240
229
241
return features_enabled
0 commit comments