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 7 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
65 changes: 64 additions & 1 deletion aws_lambda_powertools/utilities/feature_flags/feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,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 @@ -236,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 @@ -350,7 +391,29 @@ def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> L

return features_enabled

def exception_handler(self, exc_class: Exception | list[Exception]):
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:
Expand Down
76 changes: 53 additions & 23 deletions aws_lambda_powertools/utilities/feature_flags/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import re
from datetime import datetime
from enum import Enum
from functools import lru_cache
from typing import Any, Dict, List, Optional, Union

from dateutil import tz
Expand All @@ -24,6 +25,8 @@
TIME_RANGE_PATTERN = re.compile(r"2[0-3]:[0-5]\d|[0-1]\d:[0-5]\d") # 24 hour clock
HOUR_MIN_SEPARATOR = ":"

LOGGER: logging.Logger | Logger = logging.getLogger(__name__)


class RuleAction(str, Enum):
EQUALS = "EQUALS"
Expand Down Expand Up @@ -77,6 +80,7 @@ class TimeValues(Enum):
SATURDAY = "SATURDAY"

@classmethod
@lru_cache(maxsize=1)
def days(cls) -> list[str]:
return [day.value for day in cls if day.value not in ["START", "END", "TIMEZONE"]]

Expand Down Expand Up @@ -194,7 +198,12 @@ class SchemaValidator(BaseValidator):

def __init__(self, schema: Dict[str, Any], logger: Optional[Union[logging.Logger, Logger]] = None):
self.schema = schema
self.logger = logger or logging.getLogger(__name__)
self.logger = logger or LOGGER

# Validators are designed for modular testing
# therefore we link the custom logger with global LOGGER
# so custom validators can use them when necessary
SchemaValidator._link_global_logger(self.logger)

def validate(self) -> None:
self.logger.debug("Validating schema")
Expand All @@ -204,13 +213,18 @@ def validate(self) -> None:
features = FeaturesValidator(schema=self.schema, logger=self.logger)
features.validate()

@staticmethod
def _link_global_logger(logger: logging.Logger | Logger):
global LOGGER
LOGGER = logger


class FeaturesValidator(BaseValidator):
"""Validates each feature and calls RulesValidator to validate its rules"""

def __init__(self, schema: Dict, logger: Optional[Union[logging.Logger, Logger]] = None):
self.schema = schema
self.logger = logger or logging.getLogger(__name__)
self.logger = logger or LOGGER

def validate(self):
for name, feature in self.schema.items():
Expand Down Expand Up @@ -248,7 +262,7 @@ def __init__(
self.feature = feature
self.feature_name = next(iter(self.feature))
self.rules: Optional[Dict] = self.feature.get(RULES_KEY)
self.logger = logger or logging.getLogger(__name__)
self.logger = logger or LOGGER
self.boolean_feature = boolean_feature

def validate(self):
Expand Down Expand Up @@ -295,7 +309,7 @@ class ConditionsValidator(BaseValidator):
def __init__(self, rule: Dict[str, Any], rule_name: str, logger: Optional[Union[logging.Logger, Logger]] = None):
self.conditions: List[Dict[str, Any]] = rule.get(CONDITIONS_KEY, {})
self.rule_name = rule_name
self.logger = logger or logging.getLogger(__name__)
self.logger = logger or LOGGER

def validate(self):
if not self.conditions or not isinstance(self.conditions, list):
Expand Down Expand Up @@ -332,15 +346,25 @@ def validate_condition_key(condition: Dict[str, Any], rule_name: str):
raise SchemaValidationError(f"'key' value must be a non empty string, rule={rule_name}")

action = condition.get(CONDITION_ACTION, "")
# maintenance: we may need a new design if we end up with more exceptions like datetime/time range
# e.g., visitor pattern, registry etc.
validator = getattr(
ConditionsValidator,
f"_validate_{action.lower()}_key",
ConditionsValidator._validate_noop_value,
)

validator(key, rule_name)
# To allow for growth and prevent if/elif chains, we align extra validators based on the action name.
# for example:
#
# SCHEDULE_BETWEEN_DAYS_OF_WEEK_KEY
# - extra validation: `_validate_schedule_between_days_of_week_key`
#
# maintenance: we should split to separate file/classes for better organization, e.g., visitor pattern.

custom_validator = getattr(ConditionsValidator, f"_validate_{action.lower()}_key", None)

# ~90% of actions available don't require a custom validator
# logging a debug statement for no-match will increase CPU cycles for most customers
# for that reason only, we invert and log only when extra validation is found.
if custom_validator is None:
return

LOGGER.debug(f"{action} requires key validation. Running '{custom_validator}' validator.")
custom_validator(key, rule_name)

@staticmethod
def validate_condition_value(condition: Dict[str, Any], rule_name: str):
Expand All @@ -349,19 +373,25 @@ def validate_condition_value(condition: Dict[str, Any], rule_name: str):
raise SchemaValidationError(f"'value' key must not be null, rule={rule_name}")
action = condition.get(CONDITION_ACTION, "")

# maintenance: we may need a new design if we end up with more exceptions like datetime/time range
# e.g., visitor pattern, registry etc.
validator = getattr(
ConditionsValidator,
f"_validate_{action.lower()}_value",
ConditionsValidator._validate_noop_value,
)
# To allow for growth and prevent if/elif chains, we align extra validators based on the action name.
# for example:
#
# SCHEDULE_BETWEEN_DAYS_OF_WEEK_KEY
# - extra validation: `_validate_schedule_between_days_of_week_value`
#
# maintenance: we should split to separate file/classes for better organization, e.g., visitor pattern.

validator(value, rule_name)
custom_validator = getattr(ConditionsValidator, f"_validate_{action.lower()}_value", None)

@staticmethod
def _validate_noop_value(*args, **kwargs):
return True
# ~90% of actions available don't require a custom validator
# logging a debug statement for no-match will increase CPU cycles for most customers
# for that reason only, we invert and log only when extra validation is found.
if custom_validator is None:
return

LOGGER.debug(f"{action} requires value validation. Running '{custom_validator}' validator.")

custom_validator(value, rule_name)

@staticmethod
def _validate_schedule_between_days_of_week_key(key: str, rule_name: str):
Expand Down
2 changes: 1 addition & 1 deletion tests/functional/feature_flags/test_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -1655,7 +1655,7 @@ def test_exception_handler(mocker, config):

feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)

@feature_flags.exception_handler(ValueError)
@feature_flags.validation_exception_handler(ValueError)
def catch_exception(exc):
raise TypeError("re-raised")

Expand Down
6 changes: 2 additions & 4 deletions tests/functional/feature_flags/test_schema_validation.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import logging
import re

import pytest # noqa: F401
import pytest

from aws_lambda_powertools.logging.logger import Logger # noqa: F401
from aws_lambda_powertools.utilities.feature_flags.exceptions import (
SchemaValidationError,
)
Expand All @@ -24,8 +24,6 @@
TimeValues,
)

logger = logging.getLogger(__name__)

EMPTY_SCHEMA = {"": ""}


Expand Down