Skip to content

Commit f89d572

Browse files
committed
refactor: add docstrings and logging to FeatureFlags, Store
1 parent 9c714ab commit f89d572

File tree

6 files changed

+171
-128
lines changed

6 files changed

+171
-128
lines changed

aws_lambda_powertools/utilities/feature_flags/appconfig.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def __init__(
2121
application: str,
2222
name: str,
2323
cache_seconds: int,
24-
config: Optional[Config] = None,
24+
sdk_config: Optional[Config] = None,
2525
envelope: str = "",
2626
jmespath_options: Optional[Dict] = None,
2727
):
@@ -37,8 +37,8 @@ def __init__(
3737
AppConfig configuration name e.g. `my_conf`
3838
cache_seconds: int
3939
cache expiration time, how often to call AppConfig to fetch latest configuration
40-
config: Optional[Config]
41-
boto3 client configuration
40+
sdk_config: Optional[Config]
41+
Botocore Config object to pass during client initialization
4242
envelope : str
4343
JMESPath expression to pluck feature flags data from config
4444
jmespath_options : Dict
@@ -49,13 +49,13 @@ def __init__(
4949
self.application = application
5050
self.name = name
5151
self.cache_seconds = cache_seconds
52-
self.config = config
52+
self.config = sdk_config
5353
self.envelope = envelope
5454
self.jmespath_options = jmespath_options
55-
self._conf_store = AppConfigProvider(environment=environment, application=application, config=config)
55+
self._conf_store = AppConfigProvider(environment=environment, application=application, config=sdk_config)
5656

5757
def get_configuration(self) -> Dict[str, Any]:
58-
"""Get configuration string from AWS AppConfig and return the parsed JSON dictionary
58+
"""Fetch feature schema configuration from AWS AppConfig
5959
6060
Raises
6161
------

aws_lambda_powertools/utilities/feature_flags/base.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
class StoreProvider(ABC):
66
@abstractmethod
77
def get_configuration(self) -> Dict[str, Any]:
8-
"""Get configuration string from any configuration storing application and return the parsed JSON dictionary
8+
"""Get configuration from any store and return the parsed JSON dictionary
99
1010
Raises
1111
------
@@ -16,6 +16,30 @@ def get_configuration(self) -> Dict[str, Any]:
1616
-------
1717
Dict[str, Any]
1818
parsed JSON dictionary
19+
20+
**Example**
21+
22+
```python
23+
{
24+
"premium_features": {
25+
"default": False,
26+
"rules": {
27+
"customer tier equals premium": {
28+
"when_match": True,
29+
"conditions": [
30+
{
31+
"action": "EQUALS",
32+
"key": "tier",
33+
"value": "premium",
34+
}
35+
],
36+
}
37+
},
38+
},
39+
"feature_two": {
40+
"default": False
41+
}
42+
}
1943
"""
2044
return NotImplemented # pragma: no cover
2145

aws_lambda_powertools/utilities/feature_flags/feature_flags.py

Lines changed: 108 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,36 @@
1010

1111
class FeatureFlags:
1212
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+
```
1433
1534
Parameters
1635
----------
1736
store: StoreProvider
18-
A schema JSON fetcher, can be AWS AppConfig, Hashicorp Consul etc.
37+
Store to use to fetch feature flag schema configuration.
1938
"""
20-
self._logger = logger
2139
self._store = store
2240

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:
2443
if not context_value:
2544
return False
2645
mapping_by_action = {
@@ -34,88 +53,91 @@ def _match_by_action(self, action: str, condition_value: Any, context_value: Any
3453
func = mapping_by_action.get(action, lambda a, b: False)
3554
return func(context_value, condition_value)
3655
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)}")
3857
return False
3958

40-
def _is_rule_matched(
59+
def _evaluate_conditions(
4160
self, rule_name: str, feature_name: str, rule: Dict[str, Any], context: Dict[str, Any]
4261
) -> 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)
4464
conditions = cast(List[Dict], rule.get(schema.CONDITIONS_KEY))
4565

4666
for condition in conditions:
4767
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):
5372
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}, "
5574
f"name={feature_name}, context_value={str(context_value)} "
5675
)
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}")
6379
return True
6480
return False
6581

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]
7384
) -> bool:
85+
"""Evaluates whether context matches rules and conditions, otherwise return feature default"""
7486
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+
7894
# 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
8497
return False
8598

8699
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.
88103
89104
Raises
90105
------
91106
ConfigurationError
92-
Any validation error or appconfig error that can occur
107+
Any validation error
93108
94109
Returns
95110
------
96111
Dict[str, Dict]
97112
parsed JSON dictionary
98113
114+
**Example**
115+
116+
```python
99117
{
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,
106123
"conditions": [
107124
{
108125
"action": "EQUALS",
109-
"key": "tenant_id",
110-
"value": "345345435",
126+
"key": "tier",
127+
"value": "premium",
111128
}
112129
],
113-
},
114-
],
130+
}
131+
},
132+
},
133+
"feature_two": {
134+
"default": False
115135
}
116136
}
137+
```
117138
"""
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}")
119141
config = self._store.get_configuration()
120142

121143
validator = schema.SchemaValidator(schema=config)
@@ -124,68 +146,56 @@ def get_configuration(self) -> Union[Dict[str, Dict], Dict]:
124146
return config
125147

126148
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**
128152
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
130156
131157
Parameters
132158
----------
133159
name: str
134-
feature name that you wish to fetch
160+
feature name to evaluate
135161
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"}`
138165
default: bool
139166
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
141168
142169
Returns
143170
------
144171
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
151173
"""
152174
if context is None:
153175
context = {}
154176

155177
try:
156178
features = self.get_configuration()
157179
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}")
159181
return default
160182

161183
feature = features.get(name)
162184
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}")
166186
return default
167187

168188
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)
170190
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)
186196

187197
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
189199
(when a feature has defined rules)
190200
191201
Parameters
@@ -197,8 +207,13 @@ def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> L
197207
Returns
198208
----------
199209
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+
```
202217
"""
203218
if context is None:
204219
context = {}
@@ -207,23 +222,20 @@ def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> L
207222

208223
try:
209224
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}")
212227
return features_enabled
213228

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)
217232
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
225237
):
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)
228240

229241
return features_enabled

0 commit comments

Comments
 (0)