Skip to content

Commit b266979

Browse files
refactor(feature-flags): add intersection tests; structure refinement (#3775)
Co-authored-by: Ruben Fonseca <[email protected]>
1 parent d957228 commit b266979

File tree

7 files changed

+664
-187
lines changed

7 files changed

+664
-187
lines changed

aws_lambda_powertools/utilities/feature_flags/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Advanced feature flags utility"""
2+
23
from .appconfig import AppConfigStore
34
from .base import StoreProvider
45
from .exceptions import ConfigurationStoreError

aws_lambda_powertools/utilities/feature_flags/comparators.py

+54-30
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
from __future__ import annotations
2+
13
from datetime import datetime, tzinfo
24
from typing import Any, Dict, Optional
35

46
from dateutil.tz import gettz
57

68
from .schema import HOUR_MIN_SEPARATOR, ModuloRangeValues, TimeValues
7-
from .exceptions import SchemaValidationError
89

910

1011
def _get_now_from_timezone(timezone: Optional[tzinfo]) -> datetime:
@@ -85,41 +86,64 @@ def compare_modulo_range(context_value: int, condition_value: Dict) -> bool:
8586
return start <= context_value % base <= end
8687

8788

88-
def compare_any_in_list(key_list, value_list):
89-
if not (isinstance(key_list, list) and isinstance(value_list, list)):
90-
raise SchemaValidationError()
91-
92-
results = False
93-
for key in key_list:
94-
if key in value_list:
95-
results = True
96-
break
97-
98-
return results
89+
def compare_any_in_list(context_value: list, condition_value: list) -> bool:
90+
"""Comparator for ANY_IN_VALUE action
91+
92+
Parameters
93+
----------
94+
context_value : list
95+
user-defined context for flag evaluation
96+
condition_value : list
97+
schema value available for condition being evaluated
98+
99+
Returns
100+
-------
101+
bool
102+
Whether any list item in context_value is available in condition_value
103+
"""
104+
if not isinstance(context_value, list):
105+
raise ValueError("Context provided must be a list. Unable to compare ANY_IN_VALUE action.")
106+
107+
return any(key in condition_value for key in context_value)
108+
99109

110+
def compare_all_in_list(context_value: list, condition_value: list) -> bool:
111+
"""Comparator for ALL_IN_VALUE action
100112
101-
def compare_all_in_list(key_list, value_list):
102-
if not (isinstance(key_list, list) and isinstance(value_list, list)):
103-
raise SchemaValidationError()
113+
Parameters
114+
----------
115+
context_value : list
116+
user-defined context for flag evaluation
117+
condition_value : list
118+
schema value available for condition being evaluated
104119
105-
results = True
106-
for key in key_list:
107-
if key not in value_list:
108-
results = False
109-
break
120+
Returns
121+
-------
122+
bool
123+
Whether all list items in context_value are available in condition_value
124+
"""
125+
if not isinstance(context_value, list):
126+
raise ValueError("Context provided must be a list. Unable to compare ALL_IN_VALUE action.")
110127

111-
return results
128+
return all(key in condition_value for key in context_value)
112129

113130

114-
def compare_none_in_list(key_list, value_list):
115-
if not (isinstance(key_list, list) and isinstance(value_list, list)):
116-
raise SchemaValidationError()
131+
def compare_none_in_list(context_value: list, condition_value: list) -> bool:
132+
"""Comparator for NONE_IN_VALUE action
117133
118-
results = True
119-
for key in key_list:
120-
if key in value_list:
121-
results = False
122-
break
134+
Parameters
135+
----------
136+
context_value : list
137+
user-defined context for flag evaluation
138+
condition_value : list
139+
schema value available for condition being evaluated
123140
124-
return results
141+
Returns
142+
-------
143+
bool
144+
Whether list items in context_value are **not** available in condition_value
145+
"""
146+
if not isinstance(context_value, list):
147+
raise ValueError("Context provided must be a list. Unable to compare NONE_IN_VALUE action.")
125148

149+
return all(key not in condition_value for key in context_value)

aws_lambda_powertools/utilities/feature_flags/feature_flags.py

+126-29
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,52 @@
1+
from __future__ import annotations
2+
13
import logging
2-
from typing import Any, Dict, List, Optional, Union, cast
4+
from typing import Any, Callable, Dict, List, Optional, TypeVar, Union, cast
5+
6+
from typing_extensions import ParamSpec
37

48
from ... import Logger
59
from ...shared.types import JSONType
610
from . import schema
711
from .base import StoreProvider
812
from .comparators import (
13+
compare_all_in_list,
14+
compare_any_in_list,
915
compare_datetime_range,
1016
compare_days_of_week,
1117
compare_modulo_range,
18+
compare_none_in_list,
1219
compare_time_range,
13-
compare_all_in_list,
14-
compare_any_in_list,
15-
compare_none_in_list
1620
)
1721
from .exceptions import ConfigurationStoreError
1822

23+
T = TypeVar("T")
24+
P = ParamSpec("P")
25+
26+
RULE_ACTION_MAPPING = {
27+
schema.RuleAction.EQUALS.value: lambda a, b: a == b,
28+
schema.RuleAction.NOT_EQUALS.value: lambda a, b: a != b,
29+
schema.RuleAction.KEY_GREATER_THAN_VALUE.value: lambda a, b: a > b,
30+
schema.RuleAction.KEY_GREATER_THAN_OR_EQUAL_VALUE.value: lambda a, b: a >= b,
31+
schema.RuleAction.KEY_LESS_THAN_VALUE.value: lambda a, b: a < b,
32+
schema.RuleAction.KEY_LESS_THAN_OR_EQUAL_VALUE.value: lambda a, b: a <= b,
33+
schema.RuleAction.STARTSWITH.value: lambda a, b: a.startswith(b),
34+
schema.RuleAction.ENDSWITH.value: lambda a, b: a.endswith(b),
35+
schema.RuleAction.IN.value: lambda a, b: a in b,
36+
schema.RuleAction.NOT_IN.value: lambda a, b: a not in b,
37+
schema.RuleAction.KEY_IN_VALUE.value: lambda a, b: a in b,
38+
schema.RuleAction.KEY_NOT_IN_VALUE.value: lambda a, b: a not in b,
39+
schema.RuleAction.VALUE_IN_KEY.value: lambda a, b: b in a,
40+
schema.RuleAction.VALUE_NOT_IN_KEY.value: lambda a, b: b not in a,
41+
schema.RuleAction.ALL_IN_VALUE.value: lambda a, b: compare_all_in_list(a, b),
42+
schema.RuleAction.ANY_IN_VALUE.value: lambda a, b: compare_any_in_list(a, b),
43+
schema.RuleAction.NONE_IN_VALUE.value: lambda a, b: compare_none_in_list(a, b),
44+
schema.RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value: lambda a, b: compare_time_range(a, b),
45+
schema.RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value: lambda a, b: compare_datetime_range(a, b),
46+
schema.RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value: lambda a, b: compare_days_of_week(a, b),
47+
schema.RuleAction.MODULO_RANGE.value: lambda a, b: compare_modulo_range(a, b),
48+
}
49+
1950

2051
class FeatureFlags:
2152
def __init__(self, store: StoreProvider, logger: Optional[Union[logging.Logger, Logger]] = None):
@@ -49,37 +80,20 @@ def __init__(self, store: StoreProvider, logger: Optional[Union[logging.Logger,
4980
"""
5081
self.store = store
5182
self.logger = logger or logging.getLogger(__name__)
83+
self._exception_handlers: dict[Exception, Callable] = {}
5284

5385
def _match_by_action(self, action: str, condition_value: Any, context_value: Any) -> bool:
54-
mapping_by_action = {
55-
schema.RuleAction.EQUALS.value: lambda a, b: a == b,
56-
schema.RuleAction.NOT_EQUALS.value: lambda a, b: a != b,
57-
schema.RuleAction.KEY_GREATER_THAN_VALUE.value: lambda a, b: a > b,
58-
schema.RuleAction.KEY_GREATER_THAN_OR_EQUAL_VALUE.value: lambda a, b: a >= b,
59-
schema.RuleAction.KEY_LESS_THAN_VALUE.value: lambda a, b: a < b,
60-
schema.RuleAction.KEY_LESS_THAN_OR_EQUAL_VALUE.value: lambda a, b: a <= b,
61-
schema.RuleAction.STARTSWITH.value: lambda a, b: a.startswith(b),
62-
schema.RuleAction.ENDSWITH.value: lambda a, b: a.endswith(b),
63-
schema.RuleAction.IN.value: lambda a, b: a in b,
64-
schema.RuleAction.NOT_IN.value: lambda a, b: a not in b,
65-
schema.RuleAction.KEY_IN_VALUE.value: lambda a, b: a in b,
66-
schema.RuleAction.KEY_NOT_IN_VALUE.value: lambda a, b: a not in b,
67-
schema.RuleAction.VALUE_IN_KEY.value: lambda a, b: b in a,
68-
schema.RuleAction.VALUE_NOT_IN_KEY.value: lambda a, b: b not in a,
69-
schema.RuleAction.ALL_IN_VALUE.value: lambda a, b: compare_all_in_list(a, b),
70-
schema.RuleAction.ANY_IN_VALUE.value: lambda a, b: compare_any_in_list(a, b),
71-
schema.RuleAction.NONE_IN_VALUE.value: lambda a, b: compare_none_in_list(a, b),
72-
schema.RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value: lambda a, b: compare_time_range(a, b),
73-
schema.RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value: lambda a, b: compare_datetime_range(a, b),
74-
schema.RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value: lambda a, b: compare_days_of_week(a, b),
75-
schema.RuleAction.MODULO_RANGE.value: lambda a, b: compare_modulo_range(a, b),
76-
}
77-
7886
try:
79-
func = mapping_by_action.get(action, lambda a, b: False)
87+
func = RULE_ACTION_MAPPING.get(action, lambda a, b: False)
8088
return func(context_value, condition_value)
8189
except Exception as exc:
8290
self.logger.debug(f"caught exception while matching action: action={action}, exception={str(exc)}")
91+
92+
handler = self._lookup_exception_handler(exc)
93+
if handler:
94+
self.logger.debug("Exception handler found! Delegating response.")
95+
return handler(exc)
96+
8397
return False
8498

8599
def _evaluate_conditions(
@@ -209,6 +223,22 @@ def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, defau
209223
2. Feature exists but has either no rules or no match, return feature default value
210224
3. Feature doesn't exist in stored schema, encountered an error when fetching -> return default value provided
211225
226+
┌────────────────────────┐ ┌────────────────────────┐ ┌────────────────────────┐
227+
│ Feature flags │──────▶ Get Configuration ├───────▶ Evaluate rules │
228+
└────────────────────────┘ │ │ │ │
229+
│┌──────────────────────┐│ │┌──────────────────────┐│
230+
││ Fetch schema ││ ││ Match rule ││
231+
│└───────────┬──────────┘│ │└───────────┬──────────┘│
232+
│ │ │ │ │ │
233+
│┌───────────▼──────────┐│ │┌───────────▼──────────┐│
234+
││ Cache schema ││ ││ Match condition ││
235+
│└───────────┬──────────┘│ │└───────────┬──────────┘│
236+
│ │ │ │ │ │
237+
│┌───────────▼──────────┐│ │┌───────────▼──────────┐│
238+
││ Validate schema ││ ││ Match action ││
239+
│└──────────────────────┘│ │└──────────────────────┘│
240+
└────────────────────────┘ └────────────────────────┘
241+
212242
Parameters
213243
----------
214244
name: str
@@ -222,6 +252,31 @@ def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, defau
222252
or there has been an error when fetching the configuration from the store
223253
Can be boolean or any JSON values for non-boolean features.
224254
255+
256+
Examples
257+
--------
258+
259+
```python
260+
from aws_lambda_powertools.utilities.feature_flags import AppConfigStore, FeatureFlags
261+
from aws_lambda_powertools.utilities.typing import LambdaContext
262+
263+
app_config = AppConfigStore(environment="dev", application="product-catalogue", name="features")
264+
265+
feature_flags = FeatureFlags(store=app_config)
266+
267+
268+
def lambda_handler(event: dict, context: LambdaContext):
269+
# Get customer's tier from incoming request
270+
ctx = {"tier": event.get("tier", "standard")}
271+
272+
# Evaluate whether customer's tier has access to premium features
273+
# based on `has_premium_features` rules
274+
has_premium_features: bool = feature_flags.evaluate(name="premium_features", context=ctx, default=False)
275+
if has_premium_features:
276+
# enable premium features
277+
...
278+
```
279+
225280
Returns
226281
------
227282
JSONType
@@ -335,3 +390,45 @@ def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> L
335390
features_enabled.append(name)
336391

337392
return features_enabled
393+
394+
def validation_exception_handler(self, exc_class: Exception | list[Exception]):
395+
"""Registers function to handle unexpected validation exceptions when evaluating flags.
396+
397+
It does not override the function of a default flag value in case of network and IAM permissions.
398+
For example, you won't be able to catch ConfigurationStoreError exception.
399+
400+
Parameters
401+
----------
402+
exc_class : Exception | list[Exception]
403+
One or more exceptions to catch
404+
405+
Examples
406+
--------
407+
408+
```python
409+
feature_flags = FeatureFlags(store=app_config)
410+
411+
@feature_flags.validation_exception_handler(Exception) # any exception
412+
def catch_exception(exc):
413+
raise TypeError("re-raised") from exc
414+
```
415+
"""
416+
417+
def register_exception_handler(func: Callable[P, T]) -> Callable[P, T]:
418+
if isinstance(exc_class, list):
419+
for exp in exc_class:
420+
self._exception_handlers[exp] = func
421+
else:
422+
self._exception_handlers[exc_class] = func
423+
424+
return func
425+
426+
return register_exception_handler
427+
428+
def _lookup_exception_handler(self, exc: BaseException) -> Callable | None:
429+
# Use "Method Resolution Order" to allow for matching against a base class
430+
# of an exception
431+
for cls in type(exc).__mro__:
432+
if cls in self._exception_handlers:
433+
return self._exception_handlers[cls] # type: ignore[index] # index is correct
434+
return None

0 commit comments

Comments
 (0)