diff --git a/aws_lambda_powertools/utilities/feature_flags/time_conditions.py b/aws_lambda_powertools/utilities/feature_flags/comparators.py similarity index 70% rename from aws_lambda_powertools/utilities/feature_flags/time_conditions.py rename to aws_lambda_powertools/utilities/feature_flags/comparators.py index 80dbc919f1a..78370f1b5b1 100644 --- a/aws_lambda_powertools/utilities/feature_flags/time_conditions.py +++ b/aws_lambda_powertools/utilities/feature_flags/comparators.py @@ -1,9 +1,9 @@ from datetime import datetime, tzinfo -from typing import Dict, Optional +from typing import Any, Dict, Optional from dateutil.tz import gettz -from .schema import HOUR_MIN_SEPARATOR, TimeValues +from .schema import HOUR_MIN_SEPARATOR, ModuloRangeValues, TimeValues def _get_now_from_timezone(timezone: Optional[tzinfo]) -> datetime: @@ -16,23 +16,23 @@ def _get_now_from_timezone(timezone: Optional[tzinfo]) -> datetime: return datetime.now(timezone) -def compare_days_of_week(action: str, values: Dict) -> bool: - timezone_name = values.get(TimeValues.TIMEZONE.value, "UTC") +def compare_days_of_week(context_value: Any, condition_value: Dict) -> bool: + timezone_name = condition_value.get(TimeValues.TIMEZONE.value, "UTC") # %A = Weekday as locale’s full name. current_day = _get_now_from_timezone(gettz(timezone_name)).strftime("%A").upper() - days = values.get(TimeValues.DAYS.value, []) + days = condition_value.get(TimeValues.DAYS.value, []) return current_day in days -def compare_datetime_range(action: str, values: Dict) -> bool: - timezone_name = values.get(TimeValues.TIMEZONE.value, "UTC") +def compare_datetime_range(context_value: Any, condition_value: Dict) -> bool: + timezone_name = condition_value.get(TimeValues.TIMEZONE.value, "UTC") timezone = gettz(timezone_name) current_time: datetime = _get_now_from_timezone(timezone) - start_date_str = values.get(TimeValues.START.value, "") - end_date_str = values.get(TimeValues.END.value, "") + start_date_str = condition_value.get(TimeValues.START.value, "") + end_date_str = condition_value.get(TimeValues.END.value, "") # Since start_date and end_date doesn't include timezone information, we mark the timestamp # with the same timezone as the current_time. This way all the 3 timestamps will be on @@ -42,12 +42,12 @@ def compare_datetime_range(action: str, values: Dict) -> bool: return start_date <= current_time <= end_date -def compare_time_range(action: str, values: Dict) -> bool: - timezone_name = values.get(TimeValues.TIMEZONE.value, "UTC") +def compare_time_range(context_value: Any, condition_value: Dict) -> bool: + timezone_name = condition_value.get(TimeValues.TIMEZONE.value, "UTC") current_time: datetime = _get_now_from_timezone(gettz(timezone_name)) - start_hour, start_min = values.get(TimeValues.START.value, "").split(HOUR_MIN_SEPARATOR) - end_hour, end_min = values.get(TimeValues.END.value, "").split(HOUR_MIN_SEPARATOR) + start_hour, start_min = condition_value.get(TimeValues.START.value, "").split(HOUR_MIN_SEPARATOR) + end_hour, end_min = condition_value.get(TimeValues.END.value, "").split(HOUR_MIN_SEPARATOR) start_time = current_time.replace(hour=int(start_hour), minute=int(start_min)) end_time = current_time.replace(hour=int(end_hour), minute=int(end_min)) @@ -71,3 +71,14 @@ def compare_time_range(action: str, values: Dict) -> bool: else: # In normal circumstances, we need to assert **both** conditions return start_time <= current_time <= end_time + + +def compare_modulo_range(context_value: int, condition_value: Dict) -> bool: + """ + Returns for a given context 'a' and modulo condition 'b' -> b.start <= a % b.base <= b.end + """ + base = condition_value.get(ModuloRangeValues.BASE.value, 1) + start = condition_value.get(ModuloRangeValues.START.value, 1) + end = condition_value.get(ModuloRangeValues.END.value, 1) + + return start <= context_value % base <= end diff --git a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py index a1c57bf7033..4849dd450ac 100644 --- a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py +++ b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py @@ -5,12 +5,13 @@ from ...shared.types import JSONType from . import schema from .base import StoreProvider -from .exceptions import ConfigurationStoreError -from .time_conditions import ( +from .comparators import ( compare_datetime_range, compare_days_of_week, + compare_modulo_range, compare_time_range, ) +from .exceptions import ConfigurationStoreError class FeatureFlags: @@ -65,6 +66,7 @@ def _match_by_action(self, action: str, condition_value: Any, context_value: Any 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: diff --git a/aws_lambda_powertools/utilities/feature_flags/schema.py b/aws_lambda_powertools/utilities/feature_flags/schema.py index 08f2ee13819..0ada75c238d 100644 --- a/aws_lambda_powertools/utilities/feature_flags/schema.py +++ b/aws_lambda_powertools/utilities/feature_flags/schema.py @@ -41,6 +41,7 @@ class RuleAction(str, Enum): SCHEDULE_BETWEEN_TIME_RANGE = "SCHEDULE_BETWEEN_TIME_RANGE" # hour:min 24 hours clock SCHEDULE_BETWEEN_DATETIME_RANGE = "SCHEDULE_BETWEEN_DATETIME_RANGE" # full datetime format, excluding timezone SCHEDULE_BETWEEN_DAYS_OF_WEEK = "SCHEDULE_BETWEEN_DAYS_OF_WEEK" # MONDAY, TUESDAY, .... see TimeValues enum + MODULO_RANGE = "MODULO_RANGE" class TimeKeys(Enum): @@ -71,6 +72,16 @@ class TimeValues(Enum): SATURDAY = "SATURDAY" +class ModuloRangeValues(Enum): + """ + Possible values when using modulo range rule + """ + + BASE = "BASE" + START = "START" + END = "END" + + class SchemaValidator(BaseValidator): """Validates feature flag schema configuration @@ -341,6 +352,9 @@ def validate_condition_value(condition: Dict[str, Any], rule_name: str): ) elif action == RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value: ConditionsValidator._validate_schedule_between_days_of_week(value, rule_name) + # modulo range condition needs validation on base, start, and end attributes + elif action == RuleAction.MODULO_RANGE.value: + ConditionsValidator._validate_modulo_range(value, rule_name) @staticmethod def _validate_datetime_value(datetime_str: str, rule_name: str): @@ -434,3 +448,22 @@ def _validate_schedule_between_time_and_datetime_ranges( # try to see if the timezone string corresponds to any known timezone if not tz.gettz(timezone): raise SchemaValidationError(f"'TIMEZONE' value must represent a valid IANA timezone, rule={rule_name}") + + @staticmethod + def _validate_modulo_range(value: Any, rule_name: str): + error_str = f"condition with a 'MODULO_RANGE' action must have a condition value type dictionary with 'BASE', 'START' and 'END' keys, rule={rule_name}" # noqa: E501 + if not isinstance(value, dict): + raise SchemaValidationError(error_str) + + base = value.get(ModuloRangeValues.BASE.value) + start = value.get(ModuloRangeValues.START.value) + end = value.get(ModuloRangeValues.END.value) + if base is None or start is None or end is None: + raise SchemaValidationError(error_str) + if not isinstance(base, int) or not isinstance(start, int) or not isinstance(end, int): + raise SchemaValidationError(f"'BASE', 'START' and 'END' must be integers, rule={rule_name}") + + if not 0 <= start <= end <= base - 1: + raise SchemaValidationError( + f"condition with 'MODULO_RANGE' action must satisfy 0 <= START <= END <= BASE-1, rule={rule_name}" + ) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index c8a1faf4ab4..a10303fb1ca 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -262,6 +262,54 @@ You can also have features enabled only at specific days, for example: enable ch When using `SCHEDULE_BETWEEN_DATETIME_RANGE`, use timestamps without timezone information, and specify the timezone manually. This way, you'll avoid hitting problems with day light savings. +### Modulo Range Segmented Experimentation + +Feature flags can also be used to run experiments on a segment of users based on modulo range conditions on context variables. +This allows you to have features that are only enabled for a certain segment of users, comparing across multiple variants +of the same experiment. + +Use cases: + +* Enable an experiment for a percentage of users +* Scale up an experiment incrementally in production - canary release +* Run multiple experiments or variants simultaneously by assigning a spectrum segment to each experiment variant. + +The modulo range condition takes three values - `BASE`, `START` and `END`. + +The condition evaluates `START <= CONTEXT_VALUE % BASE <= END`. + +=== "modulo_range_feature.py" + + ```python hl_lines="1 6 38" + --8<-- "examples/feature_flags/src/modulo_range_feature.py" + ``` + +=== "modulo_range_feature_event.json" + + ```json hl_lines="2" + --8<-- "examples/feature_flags/src/modulo_range_feature_event.json" + ``` + +=== "modulo_range_features.json" + + ```json hl_lines="13-21" + --8<-- "examples/feature_flags/src/modulo_range_features.json" + ``` + +You can run multiple experiments on your users with the spectrum of your choice. + +=== "modulo_range_multiple_feature.py" + + ```python hl_lines="1 6 67" + --8<-- "examples/feature_flags/src/modulo_range_multiple_feature.py" + ``` + +=== "modulo_range_multiple_features.json" + + ```json hl_lines="9-16 23-31 37-45" + --8<-- "examples/feature_flags/src/modulo_range_multiple_features.json" + ``` + ### Beyond boolean feature flags ???+ info "When is this useful?" @@ -385,9 +433,10 @@ The `action` configuration can have the following values, where the expressions | **KEY_NOT_IN_VALUE** | `lambda a, b: a not in b` | | **VALUE_IN_KEY** | `lambda a, b: b in a` | | **VALUE_NOT_IN_KEY** | `lambda a, b: b not in a` | -| **SCHEDULE_BETWEEN_TIME_RANGE** | `lambda a, b: time(a).start <= b <= time(a).end` | -| **SCHEDULE_BETWEEN_DATETIME_RANGE** | `lambda a, b: datetime(a).start <= b <= datetime(b).end` | +| **SCHEDULE_BETWEEN_TIME_RANGE** | `lambda a, b: b.start <= time(a) <= b.end` | +| **SCHEDULE_BETWEEN_DATETIME_RANGE** | `lambda a, b: b.start <= datetime(a) <= b.end` | | **SCHEDULE_BETWEEN_DAYS_OF_WEEK** | `lambda a, b: day_of_week(a) in b` | +| **MODULO_RANGE** | `lambda a, b: b.start <= a % b.base <= b.end` | ???+ info The `key` and `value` will be compared to the input from the `context` parameter. diff --git a/examples/feature_flags/src/modulo_range_feature.py b/examples/feature_flags/src/modulo_range_feature.py new file mode 100644 index 00000000000..20e26b84619 --- /dev/null +++ b/examples/feature_flags/src/modulo_range_feature.py @@ -0,0 +1,44 @@ +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): + """ + This feature flag is enabled under the following conditions: + - The request payload contains a field 'tier' with the value 'standard'. + - If the user_id belongs to the spectrum 0-19 modulo 100, (20% users) on whom we want to run the sale experiment. + + Rule condition to be evaluated: + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "standard" + }, + { + "action": "MODULO_RANGE", + "key": "user_id", + "value": { + "BASE": 100, + "START": 0, + "END": 19 + } + } + ] + """ + + # Get customer's tier and identifier from incoming request + ctx = {"tier": event.get("tier", "standard"), "user_id": event.get("user_id", 0)} + + # Checking if the sale_experiment is enable + sale_experiment = feature_flags.evaluate(name="sale_experiment", default=False, context=ctx) + + if sale_experiment: + # Enable special discount for sale experiment segment users: + return {"message": "The sale experiment is enabled."} + + return {"message": "The sale experiment is not enabled."} diff --git a/examples/feature_flags/src/modulo_range_feature_event.json b/examples/feature_flags/src/modulo_range_feature_event.json new file mode 100644 index 00000000000..6e5f6d64fd1 --- /dev/null +++ b/examples/feature_flags/src/modulo_range_feature_event.json @@ -0,0 +1,5 @@ +{ + "user_id": 134532511, + "tier": "standard", + "basked_id": "random_id" +} diff --git a/examples/feature_flags/src/modulo_range_features.json b/examples/feature_flags/src/modulo_range_features.json new file mode 100644 index 00000000000..936ce0c6f07 --- /dev/null +++ b/examples/feature_flags/src/modulo_range_features.json @@ -0,0 +1,26 @@ +{ + "sale_experiment": { + "default": false, + "rules": { + "experiment 1 segment - 20% users": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "standard" + }, + { + "action": "MODULO_RANGE", + "key": "user_id", + "value": { + "BASE": 100, + "START": 0, + "END": 19 + } + } + ] + } + } + } +} diff --git a/examples/feature_flags/src/modulo_range_multiple_feature.py b/examples/feature_flags/src/modulo_range_multiple_feature.py new file mode 100644 index 00000000000..08f84f70943 --- /dev/null +++ b/examples/feature_flags/src/modulo_range_multiple_feature.py @@ -0,0 +1,69 @@ +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): + """ + This non-boolean feature flag returns the percentage discount depending on the sale experiment segment: + - 10% standard discount if the user_id belongs to the spectrum 0-3 modulo 10, (40% users). + - 15% experiment discount if the user_id belongs to the spectrum 4-6 modulo 10, (30% users). + - 18% experiment discount if the user_id belongs to the spectrum 7-9 modulo 10, (30% users). + + Rule conditions to be evaluated: + "rules": { + "control group - standard 10% discount segment": { + "when_match": 10, + "conditions": [ + { + "action": "MODULO_RANGE", + "key": "user_id", + "value": { + "BASE": 10, + "START": 0, + "END": 3 + } + } + ] + }, + "test experiment 1 - 15% discount segment": { + "when_match": 15, + "conditions": [ + { + "action": "MODULO_RANGE", + "key": "user_id", + "value": { + "BASE": 10, + "START": 4, + "END": 6 + } + } + ] + }, + "test experiment 2 - 18% discount segment": { + "when_match": 18, + "conditions": [ + { + "action": "MODULO_RANGE", + "key": "user_id", + "value": { + "BASE": 10, + "START": 7, + "END": 9 + } + } + ] + } + } + """ + + # Get customer's tier and identifier from incoming request + ctx = {"tier": event.get("tier", "standard"), "user_id": event.get("user_id", 0)} + + # Get sale discount percentage from feature flag. + sale_experiment_discount = feature_flags.evaluate(name="sale_experiment_discount", default=0, context=ctx) + + return {"message": f" {sale_experiment_discount}% discount applied."} diff --git a/examples/feature_flags/src/modulo_range_multiple_features.json b/examples/feature_flags/src/modulo_range_multiple_features.json new file mode 100644 index 00000000000..d465e74012c --- /dev/null +++ b/examples/feature_flags/src/modulo_range_multiple_features.json @@ -0,0 +1,50 @@ +{ + "sale_experiment_discount": { + "boolean_type": false, + "default": 0, + "rules": { + "control group - standard 10% discount segment": { + "when_match": 10, + "conditions": [ + { + "action": "MODULO_RANGE", + "key": "user_id", + "value": { + "BASE": 10, + "START": 0, + "END": 3 + } + } + ] + }, + "test experiment 1 - 15% discount segment": { + "when_match": 15, + "conditions": [ + { + "action": "MODULO_RANGE", + "key": "user_id", + "value": { + "BASE": 10, + "START": 4, + "END": 6 + } + } + ] + }, + "test experiment 2 - 18% discount segment": { + "when_match": 18, + "conditions": [ + { + "action": "MODULO_RANGE", + "key": "user_id", + "value": { + "BASE": 10, + "START": 7, + "END": 9 + } + } + ] + } + } + } +} diff --git a/tests/functional/feature_flags/test_feature_flags.py b/tests/functional/feature_flags/test_feature_flags.py index 5329dc33acd..4dea0e65aca 100644 --- a/tests/functional/feature_flags/test_feature_flags.py +++ b/tests/functional/feature_flags/test_feature_flags.py @@ -19,6 +19,7 @@ FEATURE_DEFAULT_VAL_TYPE_KEY, RULE_MATCH_VALUE, RULES_KEY, + ModuloRangeValues, RuleAction, ) from aws_lambda_powertools.utilities.parameters import GetParameterError @@ -1168,6 +1169,103 @@ def test_flags_greater_than_or_equal_match_2(mocker, config): assert toggle == expected_value +# Test modulo range +def test_flags_modulo_range_no_match(mocker, config): + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": expected_value, + "rules": { + "tenant_id mod 100 less than 30": { + "when_match": False, + "conditions": [ + { + "action": RuleAction.MODULO_RANGE.value, + "key": "tenant_id", + "value": { + ModuloRangeValues.BASE.value: 100, + ModuloRangeValues.START.value: 0, + ModuloRangeValues.END.value: 29, + }, + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate( + name="my_feature", + context={"tenant_id": 3453454, "username": "a"}, + default=False, + ) + assert toggle == expected_value + + +def test_flags_modulo_range_match_1(mocker, config): + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": False, + "rules": { + "tenant_id mod 100 less than 40": { + "when_match": expected_value, + "conditions": [ + { + "action": RuleAction.MODULO_RANGE.value, + "key": "tenant_id", + "value": { + ModuloRangeValues.BASE.value: 100, + ModuloRangeValues.START.value: 0, + ModuloRangeValues.END.value: 39, + }, + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate( + name="my_feature", + context={"tenant_id": 345345435, "username": "a"}, + default=False, + ) + assert toggle == expected_value + + +def test_flags_modulo_range_match_2(mocker, config): + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": False, + "rules": { + "tenant_id mod 100 between 35 and 10 incl": { + "when_match": expected_value, + "conditions": [ + { + "action": RuleAction.MODULO_RANGE.value, + "key": "tenant_id", + "value": { + ModuloRangeValues.BASE.value: 100, + ModuloRangeValues.START.value: 10, + ModuloRangeValues.END.value: 35, + }, + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate( + name="my_feature", + context={"tenant_id": 345345435, "username": "a"}, + default=False, + ) + assert toggle == expected_value + + def test_non_boolean_feature_match(mocker, config): expected_value = ["value1"] # GIVEN diff --git a/tests/functional/feature_flags/test_schema_validation.py b/tests/functional/feature_flags/test_schema_validation.py index 8d3b97ad814..86cba07bbd8 100644 --- a/tests/functional/feature_flags/test_schema_validation.py +++ b/tests/functional/feature_flags/test_schema_validation.py @@ -16,6 +16,7 @@ RULE_MATCH_VALUE, RULES_KEY, ConditionsValidator, + ModuloRangeValues, RuleAction, RulesValidator, SchemaValidator, @@ -892,3 +893,142 @@ def test_validate_time_condition_between_days_range_valid_timezone(): # WHEN calling validate_condition # THEN nothing is raised ConditionsValidator.validate_condition_value(condition=condition, rule_name="dummy") + + +def test_validate_modulo_range_condition_invalid_value(): + # GIVEN a condition with a MODULO_RANGE action and invalid value + condition = {CONDITION_ACTION: RuleAction.MODULO_RANGE.value, CONDITION_VALUE: "invalid", CONDITION_KEY: "a"} + rule_name = "dummy" + match_str = f"condition with a 'MODULO_RANGE' action must have a condition value type dictionary with 'BASE', 'START' and 'END' keys, rule={rule_name}" # noqa: E501 + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises( + SchemaValidationError, + match=match_str, + ): + ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name) + + +def test_validate_modulo_range_condition_missing_parameter(): + # GIVEN a condition with a MODULO_RANGE action and missing required parameter + condition = { + CONDITION_ACTION: RuleAction.MODULO_RANGE.value, + CONDITION_VALUE: { + ModuloRangeValues.BASE.value: 100, + ModuloRangeValues.START.value: 0, + }, + CONDITION_KEY: "a", + } + rule_name = "dummy" + match_str = f"condition with a 'MODULO_RANGE' action must have a condition value type dictionary with 'BASE', 'START' and 'END' keys, rule={rule_name}" # noqa: E501 + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises( + SchemaValidationError, + match=match_str, + ): + ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name) + + +def test_validate_modulo_range_condition_non_integer_parameters(): + # GIVEN a condition with a MODULO_RANGE action and non integer parameters + condition = { + CONDITION_ACTION: RuleAction.MODULO_RANGE.value, + CONDITION_VALUE: { + ModuloRangeValues.BASE.value: "100", + ModuloRangeValues.START.value: "0", + ModuloRangeValues.END.value: "49", + }, + CONDITION_KEY: "a", + } + rule_name = "dummy" + match_str = f"'BASE', 'START' and 'END' must be integers, rule={rule_name}" + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises( + SchemaValidationError, + match=match_str, + ): + ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name) + + +def test_validate_modulo_range_condition_start_greater_than_end(): + # GIVEN a condition with a MODULO_RANGE action and invalid parameters + condition = { + CONDITION_ACTION: RuleAction.MODULO_RANGE.value, + CONDITION_VALUE: { + ModuloRangeValues.BASE.value: 100, + ModuloRangeValues.START.value: 50, + ModuloRangeValues.END.value: 49, + }, + CONDITION_KEY: "a", + } + rule_name = "dummy" + match_str = f"condition with 'MODULO_RANGE' action must satisfy 0 <= START <= END <= BASE-1, rule={rule_name}" + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises( + SchemaValidationError, + match=match_str, + ): + ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name) + + +def test_validate_modulo_range_condition_start_less_than_zero(): + # GIVEN a condition with a MODULO_RANGE action and invalid parameters + condition = { + CONDITION_ACTION: RuleAction.MODULO_RANGE.value, + CONDITION_VALUE: { + ModuloRangeValues.BASE.value: 100, + ModuloRangeValues.START.value: -10, + ModuloRangeValues.END.value: 49, + }, + CONDITION_KEY: "a", + } + rule_name = "dummy" + match_str = f"condition with 'MODULO_RANGE' action must satisfy 0 <= START <= END <= BASE-1, rule={rule_name}" + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises( + SchemaValidationError, + match=match_str, + ): + ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name) + + +def test_validate_modulo_range_condition_end_greater_than_equal_to_base(): + # GIVEN a condition with a MODULO_RANGE action and invalid parameters + condition = { + CONDITION_ACTION: RuleAction.MODULO_RANGE.value, + CONDITION_VALUE: { + ModuloRangeValues.BASE.value: 100, + ModuloRangeValues.START.value: 0, + ModuloRangeValues.END.value: 100, + }, + CONDITION_KEY: "a", + } + rule_name = "dummy" + match_str = f"condition with 'MODULO_RANGE' action must satisfy 0 <= START <= END <= BASE-1, rule={rule_name}" + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises( + SchemaValidationError, + match=match_str, + ): + ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name) + + +def test_validate_modulo_range_condition_valid(): + # GIVEN a condition with a MODULO_RANGE action and valid parameters + condition = { + CONDITION_ACTION: RuleAction.MODULO_RANGE.value, + CONDITION_VALUE: { + ModuloRangeValues.BASE.value: 100, + ModuloRangeValues.START.value: 0, + ModuloRangeValues.END.value: 19, + }, + CONDITION_KEY: "a", + } + # WHEN calling validate_condition + # THEN nothing is raised + ConditionsValidator.validate_condition_value(condition=condition, rule_name="dummy") diff --git a/tests/functional/feature_flags/test_time_based_actions.py b/tests/functional/feature_flags/test_time_based_actions.py index 358f310103f..20d95a35580 100644 --- a/tests/functional/feature_flags/test_time_based_actions.py +++ b/tests/functional/feature_flags/test_time_based_actions.py @@ -36,7 +36,7 @@ def evaluate_mocked_schema( # Mock the current time year, month, day, hour, minute, second, timezone = mocked_time - time = mocker.patch("aws_lambda_powertools.utilities.feature_flags.time_conditions._get_now_from_timezone") + time = mocker.patch("aws_lambda_powertools.utilities.feature_flags.comparators._get_now_from_timezone") time.return_value = datetime.datetime( year=year, month=month, day=day, hour=hour, minute=minute, second=second, microsecond=0, tzinfo=timezone )