Skip to content

refactor(feature-flags): add intersection tests; structure refinement #3775

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a222159
fix(parameters): make cache aware of single vs multiple calls
heitorlessa Jul 25, 2023
971e70f
chore: cleanup, add test for single and nested
heitorlessa Jul 25, 2023
a0ca2c4
chore: update deprecated ruff config
heitorlessa Feb 15, 2024
59dc776
refactor: define rule action mapping once
heitorlessa Feb 15, 2024
04f67b7
chore: add tests for ANY_IN_VALUE
heitorlessa Feb 15, 2024
52e9dd6
chore: add tests for ALL_IN_VALUE
heitorlessa Feb 15, 2024
19bec81
chore: add no match test for ANY_IN_VALUE
heitorlessa Feb 15, 2024
1681ceb
chore: add tests for NONE_IN_VALUE
heitorlessa Feb 15, 2024
4d6a36c
refactor: typing
heitorlessa Feb 15, 2024
4d24cb3
refactor: use any and all built-in instead
heitorlessa Feb 15, 2024
6087741
refactor: add docstrings
heitorlessa Feb 15, 2024
cc6079d
refactor: use getattr to allow for specialized validators
heitorlessa Feb 16, 2024
c42d477
refactor: use getattr for modulo range
heitorlessa Feb 16, 2024
8729f85
refactor: use getattr for time range
heitorlessa Feb 16, 2024
b3d0f89
refactor: use getattr for datetime range
heitorlessa Feb 16, 2024
46bd225
refactor: use getattr for condition keys
heitorlessa Feb 16, 2024
3f43628
refactor: split condition from context validation
heitorlessa Feb 16, 2024
ba89f4e
chore: test non-list intersection contexts
heitorlessa Feb 16, 2024
62c3f18
refactor: reduce quadractic loop
heitorlessa Feb 16, 2024
95be065
Merge branch 'develop' into chore/feat-flags-adjustments
heitorlessa Feb 16, 2024
b34a3d8
feat: add exception handler mechanism
heitorlessa Feb 16, 2024
4936897
chore: improve docstring
heitorlessa Feb 19, 2024
f8e7c44
chore: explain getattr dispatcher over if/elif/elif
heitorlessa Feb 19, 2024
1131246
Merge branch 'develop' into chore/feat-flags-adjustments
heitorlessa Feb 19, 2024
9473c28
chore: rename to validation_exception_handler
heitorlessa Feb 19, 2024
2828fe6
refactor: use LRU cache for days classmethod
heitorlessa Feb 19, 2024
8e115bb
chore: log debug custom validators
heitorlessa Feb 19, 2024
4ec3c7e
chore: fix missing sentinel getattr
heitorlessa Feb 19, 2024
9d149f1
chore: typo
heitorlessa Feb 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Advanced feature flags utility"""

from .appconfig import AppConfigStore
from .base import StoreProvider
from .exceptions import ConfigurationStoreError
Expand Down
84 changes: 54 additions & 30 deletions aws_lambda_powertools/utilities/feature_flags/comparators.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from __future__ import annotations

from datetime import datetime, tzinfo
from typing import Any, Dict, Optional

from dateutil.tz import gettz

from .schema import HOUR_MIN_SEPARATOR, ModuloRangeValues, TimeValues
from .exceptions import SchemaValidationError


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


def compare_any_in_list(key_list, value_list):
if not (isinstance(key_list, list) and isinstance(value_list, list)):
raise SchemaValidationError()

results = False
for key in key_list:
if key in value_list:
results = True
break

return results
def compare_any_in_list(context_value: list, condition_value: list) -> bool:
"""Comparator for ANY_IN_VALUE action

Parameters
----------
context_value : list
user-defined context for flag evaluation
condition_value : list
schema value available for condition being evaluated

Returns
-------
bool
Whether any list item in context_value is available in condition_value
"""
if not isinstance(context_value, list):
raise ValueError("Context provided must be a list. Unable to compare ANY_IN_VALUE action.")

return any(key in condition_value for key in context_value)


def compare_all_in_list(context_value: list, condition_value: list) -> bool:
"""Comparator for ALL_IN_VALUE action

def compare_all_in_list(key_list, value_list):
if not (isinstance(key_list, list) and isinstance(value_list, list)):
raise SchemaValidationError()
Parameters
----------
context_value : list
user-defined context for flag evaluation
condition_value : list
schema value available for condition being evaluated

results = True
for key in key_list:
if key not in value_list:
results = False
break
Returns
-------
bool
Whether all list items in context_value are available in condition_value
"""
if not isinstance(context_value, list):
raise ValueError("Context provided must be a list. Unable to compare ALL_IN_VALUE action.")

return results
return all(key in condition_value for key in context_value)


def compare_none_in_list(key_list, value_list):
if not (isinstance(key_list, list) and isinstance(value_list, list)):
raise SchemaValidationError()
def compare_none_in_list(context_value: list, condition_value: list) -> bool:
"""Comparator for NONE_IN_VALUE action

results = True
for key in key_list:
if key in value_list:
results = False
break
Parameters
----------
context_value : list
user-defined context for flag evaluation
condition_value : list
schema value available for condition being evaluated

return results
Returns
-------
bool
Whether list items in context_value are **not** available in condition_value
"""
if not isinstance(context_value, list):
raise ValueError("Context provided must be a list. Unable to compare NONE_IN_VALUE action.")

return all(key not in condition_value for key in context_value)
155 changes: 126 additions & 29 deletions aws_lambda_powertools/utilities/feature_flags/feature_flags.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,52 @@
from __future__ import annotations

import logging
from typing import Any, Dict, List, Optional, Union, cast
from typing import Any, Callable, Dict, List, Optional, TypeVar, Union, cast

from typing_extensions import ParamSpec

from ... import Logger
from ...shared.types import JSONType
from . import schema
from .base import StoreProvider
from .comparators import (
compare_all_in_list,
compare_any_in_list,
compare_datetime_range,
compare_days_of_week,
compare_modulo_range,
compare_none_in_list,
compare_time_range,
compare_all_in_list,
compare_any_in_list,
compare_none_in_list
)
from .exceptions import ConfigurationStoreError

T = TypeVar("T")
P = ParamSpec("P")

RULE_ACTION_MAPPING = {
schema.RuleAction.EQUALS.value: lambda a, b: a == b,
schema.RuleAction.NOT_EQUALS.value: lambda a, b: a != b,
schema.RuleAction.KEY_GREATER_THAN_VALUE.value: lambda a, b: a > b,
schema.RuleAction.KEY_GREATER_THAN_OR_EQUAL_VALUE.value: lambda a, b: a >= b,
schema.RuleAction.KEY_LESS_THAN_VALUE.value: lambda a, b: a < b,
schema.RuleAction.KEY_LESS_THAN_OR_EQUAL_VALUE.value: lambda a, b: a <= b,
schema.RuleAction.STARTSWITH.value: lambda a, b: a.startswith(b),
schema.RuleAction.ENDSWITH.value: lambda a, b: a.endswith(b),
schema.RuleAction.IN.value: lambda a, b: a in b,
schema.RuleAction.NOT_IN.value: lambda a, b: a not in b,
schema.RuleAction.KEY_IN_VALUE.value: lambda a, b: a in b,
schema.RuleAction.KEY_NOT_IN_VALUE.value: lambda a, b: a not in b,
schema.RuleAction.VALUE_IN_KEY.value: lambda a, b: b in a,
schema.RuleAction.VALUE_NOT_IN_KEY.value: lambda a, b: b not in a,
schema.RuleAction.ALL_IN_VALUE.value: lambda a, b: compare_all_in_list(a, b),
schema.RuleAction.ANY_IN_VALUE.value: lambda a, b: compare_any_in_list(a, b),
schema.RuleAction.NONE_IN_VALUE.value: lambda a, b: compare_none_in_list(a, b),
schema.RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value: lambda a, b: compare_time_range(a, b),
schema.RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value: lambda a, b: compare_datetime_range(a, b),
schema.RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value: lambda a, b: compare_days_of_week(a, b),
schema.RuleAction.MODULO_RANGE.value: lambda a, b: compare_modulo_range(a, b),
}


class FeatureFlags:
def __init__(self, store: StoreProvider, logger: Optional[Union[logging.Logger, Logger]] = None):
Expand Down Expand Up @@ -49,37 +80,20 @@ def __init__(self, store: StoreProvider, logger: Optional[Union[logging.Logger,
"""
self.store = store
self.logger = logger or logging.getLogger(__name__)
self._exception_handlers: dict[Exception, Callable] = {}

def _match_by_action(self, action: str, condition_value: Any, context_value: Any) -> bool:
mapping_by_action = {
schema.RuleAction.EQUALS.value: lambda a, b: a == b,
schema.RuleAction.NOT_EQUALS.value: lambda a, b: a != b,
schema.RuleAction.KEY_GREATER_THAN_VALUE.value: lambda a, b: a > b,
schema.RuleAction.KEY_GREATER_THAN_OR_EQUAL_VALUE.value: lambda a, b: a >= b,
schema.RuleAction.KEY_LESS_THAN_VALUE.value: lambda a, b: a < b,
schema.RuleAction.KEY_LESS_THAN_OR_EQUAL_VALUE.value: lambda a, b: a <= b,
schema.RuleAction.STARTSWITH.value: lambda a, b: a.startswith(b),
schema.RuleAction.ENDSWITH.value: lambda a, b: a.endswith(b),
schema.RuleAction.IN.value: lambda a, b: a in b,
schema.RuleAction.NOT_IN.value: lambda a, b: a not in b,
schema.RuleAction.KEY_IN_VALUE.value: lambda a, b: a in b,
schema.RuleAction.KEY_NOT_IN_VALUE.value: lambda a, b: a not in b,
schema.RuleAction.VALUE_IN_KEY.value: lambda a, b: b in a,
schema.RuleAction.VALUE_NOT_IN_KEY.value: lambda a, b: b not in a,
schema.RuleAction.ALL_IN_VALUE.value: lambda a, b: compare_all_in_list(a, b),
schema.RuleAction.ANY_IN_VALUE.value: lambda a, b: compare_any_in_list(a, b),
schema.RuleAction.NONE_IN_VALUE.value: lambda a, b: compare_none_in_list(a, b),
schema.RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value: lambda a, b: compare_time_range(a, b),
schema.RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value: lambda a, b: compare_datetime_range(a, b),
schema.RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value: lambda a, b: compare_days_of_week(a, b),
schema.RuleAction.MODULO_RANGE.value: lambda a, b: compare_modulo_range(a, b),
}

try:
func = mapping_by_action.get(action, lambda a, b: False)
func = RULE_ACTION_MAPPING.get(action, lambda a, b: False)
return func(context_value, condition_value)
except Exception as exc:
self.logger.debug(f"caught exception while matching action: action={action}, exception={str(exc)}")

handler = self._lookup_exception_handler(exc)
if handler:
self.logger.debug("Exception handler found! Delegating response.")
return handler(exc)

return False

def _evaluate_conditions(
Expand Down Expand Up @@ -209,6 +223,22 @@ def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, defau
2. Feature exists but has either no rules or no match, return feature default value
3. Feature doesn't exist in stored schema, encountered an error when fetching -> return default value provided

┌────────────────────────┐ ┌────────────────────────┐ ┌────────────────────────┐
│ Feature flags │──────▶ Get Configuration ├───────▶ Evaluate rules │
└────────────────────────┘ │ │ │ │
│┌──────────────────────┐│ │┌──────────────────────┐│
││ Fetch schema ││ ││ Match rule ││
│└───────────┬──────────┘│ │└───────────┬──────────┘│
│ │ │ │ │ │
│┌───────────▼──────────┐│ │┌───────────▼──────────┐│
││ Cache schema ││ ││ Match condition ││
│└───────────┬──────────┘│ │└───────────┬──────────┘│
│ │ │ │ │ │
│┌───────────▼──────────┐│ │┌───────────▼──────────┐│
││ Validate schema ││ ││ Match action ││
│└──────────────────────┘│ │└──────────────────────┘│
└────────────────────────┘ └────────────────────────┘

Parameters
----------
name: str
Expand All @@ -222,6 +252,31 @@ def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, defau
or there has been an error when fetching the configuration from the store
Can be boolean or any JSON values for non-boolean features.


Examples
--------

```python
from aws_lambda_powertools.utilities.feature_flags import AppConfigStore, FeatureFlags
from aws_lambda_powertools.utilities.typing import LambdaContext

app_config = AppConfigStore(environment="dev", application="product-catalogue", name="features")

feature_flags = FeatureFlags(store=app_config)


def lambda_handler(event: dict, context: LambdaContext):
# Get customer's tier from incoming request
ctx = {"tier": event.get("tier", "standard")}

# Evaluate whether customer's tier has access to premium features
# based on `has_premium_features` rules
has_premium_features: bool = feature_flags.evaluate(name="premium_features", context=ctx, default=False)
if has_premium_features:
# enable premium features
...
```

Returns
------
JSONType
Expand Down Expand Up @@ -335,3 +390,45 @@ def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> L
features_enabled.append(name)

return features_enabled

def validation_exception_handler(self, exc_class: Exception | list[Exception]):
"""Registers function to handle unexpected exceptions when evaluating flags.

It does not override the function of a default flag value in case of network and IAM permissions.
For example, you won't be able to catch ConfigurationStoreError exception.

Parameters
----------
exc_class : Exception | list[Exception]
One or more exceptions to catch

Examples
--------

```python
feature_flags = FeatureFlags(store=app_config)

@feature_flags.validation_exception_handler(Exception) # any exception
def catch_exception(exc):
raise TypeError("re-raised") from exc
```
"""

def register_exception_handler(func: Callable[P, T]) -> Callable[P, T]:
if isinstance(exc_class, list):
for exp in exc_class:
self._exception_handlers[exp] = func
else:
self._exception_handlers[exc_class] = func

return func

return register_exception_handler

def _lookup_exception_handler(self, exc: BaseException) -> Callable | None:
# Use "Method Resolution Order" to allow for matching against a base class
# of an exception
for cls in type(exc).__mro__:
if cls in self._exception_handlers:
return self._exception_handlers[cls] # type: ignore[index] # index is correct
return None
Loading