diff --git a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py index 36a74c4c58a..9f881ab97f5 100644 --- a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py +++ b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py @@ -6,6 +6,11 @@ from . import schema from .base import StoreProvider from .exceptions import ConfigurationStoreError +from .time_conditions import ( + compare_datetime_range, + compare_days_of_week, + compare_time_range, +) class FeatureFlags: @@ -59,6 +64,9 @@ def _match_by_action(self, action: str, condition_value: Any, context_value: Any 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.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), } try: @@ -83,10 +91,18 @@ def _evaluate_conditions( return False for condition in conditions: - context_value = context.get(str(condition.get(schema.CONDITION_KEY))) + context_value = context.get(condition.get(schema.CONDITION_KEY, "")) cond_action = condition.get(schema.CONDITION_ACTION, "") cond_value = condition.get(schema.CONDITION_VALUE) + # time based rule actions have no user context. the context is the condition key + if cond_action in ( + schema.RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, + schema.RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value, + schema.RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, + ): + context_value = condition.get(schema.CONDITION_KEY) # e.g., CURRENT_TIME + if not self._match_by_action(action=cond_action, condition_value=cond_value, context_value=context_value): self.logger.debug( f"rule did not match action, rule_name={rule_name}, rule_value={rule_match_value}, " @@ -169,7 +185,7 @@ def get_configuration(self) -> Dict: # parse result conf as JSON, keep in cache for max age defined in store self.logger.debug(f"Fetching schema from registered store, store={self.store}") config: Dict = self.store.get_configuration() - validator = schema.SchemaValidator(schema=config) + validator = schema.SchemaValidator(schema=config, logger=self.logger) validator.validate() return config @@ -228,7 +244,7 @@ def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, defau # method `get_matching_features` returning Dict[feature_name, feature_value] boolean_feature = feature.get( schema.FEATURE_DEFAULT_VAL_TYPE_KEY, True - ) # backwards compatability ,assume feature flag + ) # backwards compatibility, assume feature flag if not rules: self.logger.debug( f"no rules found, returning feature default, name={name}, default={str(feat_default)}, boolean_feature={boolean_feature}" # noqa: E501 @@ -287,7 +303,7 @@ def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> L feature_default_value = feature.get(schema.FEATURE_DEFAULT_VAL_KEY) boolean_feature = feature.get( schema.FEATURE_DEFAULT_VAL_TYPE_KEY, True - ) # backwards compatability ,assume feature flag + ) # backwards compatibility, assume feature flag if feature_default_value and not rules: self.logger.debug(f"feature is enabled by default and has no defined rules, name={name}") diff --git a/aws_lambda_powertools/utilities/feature_flags/schema.py b/aws_lambda_powertools/utilities/feature_flags/schema.py index 2fa3140b15e..48a1eb77129 100644 --- a/aws_lambda_powertools/utilities/feature_flags/schema.py +++ b/aws_lambda_powertools/utilities/feature_flags/schema.py @@ -1,6 +1,10 @@ import logging +import re +from datetime import datetime from enum import Enum -from typing import Any, Dict, List, Optional, Union +from typing import Any, Callable, Dict, List, Optional, Union + +from dateutil import tz from ... import Logger from .base import BaseValidator @@ -14,9 +18,12 @@ CONDITION_VALUE = "value" CONDITION_ACTION = "action" FEATURE_DEFAULT_VAL_TYPE_KEY = "boolean_type" +TIME_RANGE_FORMAT = "%H:%M" # hour:min 24 hours clock +TIME_RANGE_RE_PATTERN = re.compile(r"2[0-3]:[0-5]\d|[0-1]\d:[0-5]\d") # 24 hour clock +HOUR_MIN_SEPARATOR = ":" -class RuleAction(str, Enum): +class RuleAction(Enum): EQUALS = "EQUALS" NOT_EQUALS = "NOT_EQUALS" KEY_GREATER_THAN_VALUE = "KEY_GREATER_THAN_VALUE" @@ -31,6 +38,37 @@ class RuleAction(str, Enum): KEY_NOT_IN_VALUE = "KEY_NOT_IN_VALUE" VALUE_IN_KEY = "VALUE_IN_KEY" VALUE_NOT_IN_KEY = "VALUE_NOT_IN_KEY" + 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 + + +class TimeKeys(Enum): + """ + Possible keys when using time rules + """ + + CURRENT_TIME = "CURRENT_TIME" + CURRENT_DAY_OF_WEEK = "CURRENT_DAY_OF_WEEK" + CURRENT_DATETIME = "CURRENT_DATETIME" + + +class TimeValues(Enum): + """ + Possible values when using time rules + """ + + START = "START" + END = "END" + TIMEZONE = "TIMEZONE" + DAYS = "DAYS" + SUNDAY = "SUNDAY" + MONDAY = "MONDAY" + TUESDAY = "TUESDAY" + WEDNESDAY = "WEDNESDAY" + THURSDAY = "THURSDAY" + FRIDAY = "FRIDAY" + SATURDAY = "SATURDAY" class SchemaValidator(BaseValidator): @@ -143,7 +181,7 @@ def validate(self) -> None: if not isinstance(self.schema, dict): raise SchemaValidationError(f"Features must be a dictionary, schema={str(self.schema)}") - features = FeaturesValidator(schema=self.schema) + features = FeaturesValidator(schema=self.schema, logger=self.logger) features.validate() @@ -158,7 +196,7 @@ def validate(self): for name, feature in self.schema.items(): self.logger.debug(f"Attempting to validate feature '{name}'") boolean_feature: bool = self.validate_feature(name, feature) - rules = RulesValidator(feature=feature, boolean_feature=boolean_feature) + rules = RulesValidator(feature=feature, boolean_feature=boolean_feature, logger=self.logger) rules.validate() # returns True in case the feature is a regular feature flag with a boolean default value @@ -196,14 +234,15 @@ def validate(self): return if not isinstance(self.rules, dict): + self.logger.debug(f"Feature rules must be a dictionary, feature={self.feature_name}") raise SchemaValidationError(f"Feature rules must be a dictionary, feature={self.feature_name}") for rule_name, rule in self.rules.items(): - self.logger.debug(f"Attempting to validate rule '{rule_name}'") + self.logger.debug(f"Attempting to validate rule={rule_name} and feature={self.feature_name}") self.validate_rule( rule=rule, rule_name=rule_name, feature_name=self.feature_name, boolean_feature=self.boolean_feature ) - conditions = ConditionsValidator(rule=rule, rule_name=rule_name) + conditions = ConditionsValidator(rule=rule, rule_name=rule_name, logger=self.logger) conditions.validate() @staticmethod @@ -233,12 +272,14 @@ def __init__(self, rule: Dict[str, Any], rule_name: str, logger: Optional[Union[ self.logger = logger or logging.getLogger(__name__) def validate(self): + if not self.conditions or not isinstance(self.conditions, list): + self.logger.debug(f"Condition is empty or invalid for rule={self.rule_name}") raise SchemaValidationError(f"Invalid condition, rule={self.rule_name}") for condition in self.conditions: # Condition can contain PII data; do not log condition value - self.logger.debug(f"Attempting to validate condition for '{self.rule_name}'") + self.logger.debug(f"Attempting to validate condition for {self.rule_name}") self.validate_condition(rule_name=self.rule_name, condition=condition) @staticmethod @@ -265,8 +306,132 @@ def validate_condition_key(condition: Dict[str, Any], rule_name: str): if not key or not isinstance(key, str): raise SchemaValidationError(f"'key' value must be a non empty string, rule={rule_name}") + # time actions need to have very specific keys + # SCHEDULE_BETWEEN_TIME_RANGE => CURRENT_TIME + # SCHEDULE_BETWEEN_DATETIME_RANGE => CURRENT_DATETIME + # SCHEDULE_BETWEEN_DAYS_OF_WEEK => CURRENT_DAY_OF_WEEK + action = condition.get(CONDITION_ACTION, "") + if action == RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value and key != TimeKeys.CURRENT_TIME.value: + raise SchemaValidationError( + f"'condition with a 'SCHEDULE_BETWEEN_TIME_RANGE' action must have a 'CURRENT_TIME' condition key, rule={rule_name}" # noqa: E501 + ) + if action == RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value and key != TimeKeys.CURRENT_DATETIME.value: + raise SchemaValidationError( + f"'condition with a 'SCHEDULE_BETWEEN_DATETIME_RANGE' action must have a 'CURRENT_DATETIME' condition key, rule={rule_name}" # noqa: E501 + ) + if action == RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value and key != TimeKeys.CURRENT_DAY_OF_WEEK.value: + raise SchemaValidationError( + f"'condition with a 'SCHEDULE_BETWEEN_DAYS_OF_WEEK' action must have a 'CURRENT_DAY_OF_WEEK' condition key, rule={rule_name}" # noqa: E501 + ) + @staticmethod def validate_condition_value(condition: Dict[str, Any], rule_name: str): value = condition.get(CONDITION_VALUE, "") if not value: raise SchemaValidationError(f"'value' key must not be empty, rule={rule_name}") + action = condition.get(CONDITION_ACTION, "") + + # time actions need to be parsed to make sure date and time format is valid and timezone is recognized + if action == RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value: + ConditionsValidator._validate_schedule_between_time_and_datetime_ranges( + value, rule_name, action, ConditionsValidator._validate_time_value + ) + elif action == RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value: + ConditionsValidator._validate_schedule_between_time_and_datetime_ranges( + value, rule_name, action, ConditionsValidator._validate_datetime_value + ) + elif action == RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value: + ConditionsValidator._validate_schedule_between_days_of_week(value, rule_name) + + @staticmethod + def _validate_datetime_value(datetime_str: str, rule_name: str): + date = None + + # We try to parse first with timezone information in order to return the correct error messages + # when a timestamp with timezone is used. Otherwise, the user would get the first error "must be a valid + # ISO8601 time format" which is misleading + + try: + # python < 3.11 don't support the Z timezone on datetime.fromisoformat, + # so we replace any Z with the equivalent "+00:00" + # datetime.fromisoformat is orders of magnitude faster than datetime.strptime + date = datetime.fromisoformat(datetime_str.replace("Z", "+00:00")) + except Exception: + raise SchemaValidationError(f"'START' and 'END' must be a valid ISO8601 time format, rule={rule_name}") + + # we only allow timezone information to be set via the TIMEZONE field + # this way we can encode DST into the calculation. For instance, Copenhagen is + # UTC+2 during winter, and UTC+1 during summer, which would be impossible to define + # using a single ISO datetime string + if date.tzinfo is not None: + raise SchemaValidationError( + "'START' and 'END' must not include timezone information. Set the timezone using the 'TIMEZONE' " + f"field, rule={rule_name} " + ) + + @staticmethod + def _validate_time_value(time: str, rule_name: str): + # Using a regex instead of strptime because it's several orders of magnitude faster + match = TIME_RANGE_RE_PATTERN.match(time) + + if not match: + raise SchemaValidationError( + f"'START' and 'END' must be a valid time format, time_format={TIME_RANGE_FORMAT}, rule={rule_name}" + ) + + @staticmethod + def _validate_schedule_between_days_of_week(value: Any, rule_name: str): + error_str = f"condition with a CURRENT_DAY_OF_WEEK action must have a condition value dictionary with 'DAYS' and 'TIMEZONE' (optional) keys, rule={rule_name}" # noqa: E501 + if not isinstance(value, dict): + raise SchemaValidationError(error_str) + + days = value.get(TimeValues.DAYS.value) + if not isinstance(days, list) or not value: + raise SchemaValidationError(error_str) + for day in days: + if not isinstance(day, str) or day not in [ + TimeValues.MONDAY.value, + TimeValues.TUESDAY.value, + TimeValues.WEDNESDAY.value, + TimeValues.THURSDAY.value, + TimeValues.FRIDAY.value, + TimeValues.SATURDAY.value, + TimeValues.SUNDAY.value, + ]: + raise SchemaValidationError( + f"condition value DAYS must represent a day of the week in 'TimeValues' enum, rule={rule_name}" + ) + + timezone = value.get(TimeValues.TIMEZONE.value, "UTC") + if not isinstance(timezone, str): + raise SchemaValidationError(error_str) + + # 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_schedule_between_time_and_datetime_ranges( + value: Any, rule_name: str, action_name: str, validator: Callable[[str, str], None] + ): + error_str = f"condition with a '{action_name}' action must have a condition value type dictionary with 'START' and 'END' keys, rule={rule_name}" # noqa: E501 + if not isinstance(value, dict): + raise SchemaValidationError(error_str) + + start_time = value.get(TimeValues.START.value) + end_time = value.get(TimeValues.END.value) + if not start_time or not end_time: + raise SchemaValidationError(error_str) + if not isinstance(start_time, str) or not isinstance(end_time, str): + raise SchemaValidationError(f"'START' and 'END' must be a non empty string, rule={rule_name}") + + validator(start_time, rule_name) + validator(end_time, rule_name) + + timezone = value.get(TimeValues.TIMEZONE.value, "UTC") + if not isinstance(timezone, str): + raise SchemaValidationError(f"'TIMEZONE' must be a string, rule={rule_name}") + + # 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}") diff --git a/aws_lambda_powertools/utilities/feature_flags/time_conditions.py b/aws_lambda_powertools/utilities/feature_flags/time_conditions.py new file mode 100644 index 00000000000..80dbc919f1a --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_flags/time_conditions.py @@ -0,0 +1,73 @@ +from datetime import datetime, tzinfo +from typing import Dict, Optional + +from dateutil.tz import gettz + +from .schema import HOUR_MIN_SEPARATOR, TimeValues + + +def _get_now_from_timezone(timezone: Optional[tzinfo]) -> datetime: + """ + Returns now in the specified timezone. Defaults to UTC if not present. + At this stage, we already validated that the passed timezone string is valid, so we assume that + gettz() will return a tzinfo object. + """ + timezone = gettz("UTC") if timezone is None else timezone + return datetime.now(timezone) + + +def compare_days_of_week(action: str, values: Dict) -> bool: + timezone_name = values.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, []) + return current_day in days + + +def compare_datetime_range(action: str, values: Dict) -> bool: + timezone_name = values.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, "") + + # 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 + # the same timezone. + start_date = datetime.fromisoformat(start_date_str).replace(tzinfo=timezone) + end_date = datetime.fromisoformat(end_date_str).replace(tzinfo=timezone) + return start_date <= current_time <= end_date + + +def compare_time_range(action: str, values: Dict) -> bool: + timezone_name = values.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_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)) + + if int(end_hour) < int(start_hour): + # When the end hour is smaller than start hour, it means we are crossing a day's boundary. + # In this case we need to assert that current_time is **either** on one side or the other side of the boundary + # + # ┌─────┐ ┌─────┐ ┌─────┐ + # │20.00│ │00.00│ │04.00│ + # └─────┘ └─────┘ └─────┘ + # ───────────────────────────────────────────┬─────────────────────────────────────────▶ + # ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ + # │ │ │ + # │ either this area │ │ or this area + # │ │ │ + # └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ + # │ + + return (start_time <= current_time) or (current_time <= end_time) + else: + # In normal circumstances, we need to assert **both** conditions + return start_time <= current_time <= end_time diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index ec4c28699e7..2953f6e773c 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -447,6 +447,74 @@ Feature flags can return any JSON values when `boolean_type` parameter is set to } ``` +#### Time based feature flags + +Feature flags can also return enabled features based on time or datetime ranges. +This allows you to have features that are only enabled on certain days of the week, certain time +intervals or between certain calendar dates. + +Use cases: + +* Enable maintenance mode during a weekend +* Disable support/chat feature after working hours +* Launch a new feature on a specific date and time + +You can also have features enabled only at certain times of the day for premium tier customers + +=== "app.py" + + ```python hl_lines="12" + --8<-- "examples/feature_flags/src/timebased_feature.py" + ``` + +=== "event.json" + + ```json hl_lines="3" + --8<-- "examples/feature_flags/src/timebased_feature_event.json" + ``` + +=== "features.json" + + ```json hl_lines="9-11 14-21" + --8<-- "examples/feature_flags/src/timebased_features.json" + ``` + +You can also have features enabled only at certain times of the day. + +=== "app.py" + + ```python hl_lines="9" + --8<-- "examples/feature_flags/src/timebased_happyhour_feature.py" + ``` + +=== "features.json" + + ```json hl_lines="9-15" + --8<-- "examples/feature_flags/src/timebased_happyhour_features.json" + ``` + +You can also have features enabled only at specific days, for example: enable christmas sale discount during specific dates. + +=== "app.py" + + ```python hl_lines="10" + --8<-- "examples/feature_flags/src/datetime_feature.py" + ``` + +=== "features.json" + + ```json hl_lines="9-14" + --8<-- "examples/feature_flags/src/datetime_feature.json" + ``` + +???+ info "How should I use timezones?" + You can use any [IANA time zone](https://www.iana.org/time-zones) (as originally specified + in [PEP 615](https://peps.python.org/pep-0615/)) as part of your rules definition. + Powertools takes care of converting and calculate the correct timestamps for you. + + 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. + ## Advanced ### Adjusting in-memory cache @@ -580,24 +648,39 @@ The `conditions` block is a list of conditions that contain `action`, `key`, and The `action` configuration can have the following values, where the expressions **`a`** is the `key` and **`b`** is the `value` above: -| Action | Equivalent expression | -| ----------------------------------- | ------------------------------ | -| **EQUALS** | `lambda a, b: a == b` | -| **NOT_EQUALS** | `lambda a, b: a != b` | -| **KEY_GREATER_THAN_VALUE** | `lambda a, b: a > b` | -| **KEY_GREATER_THAN_OR_EQUAL_VALUE** | `lambda a, b: a >= b` | -| **KEY_LESS_THAN_VALUE** | `lambda a, b: a < b` | -| **KEY_LESS_THAN_OR_EQUAL_VALUE** | `lambda a, b: a <= b` | -| **STARTSWITH** | `lambda a, b: a.startswith(b)` | -| **ENDSWITH** | `lambda a, b: a.endswith(b)` | -| **KEY_IN_VALUE** | `lambda a, b: a in b` | -| **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` | +| Action | Equivalent expression | +| ----------------------------------- | -------------------------------------------------------- | +| **EQUALS** | `lambda a, b: a == b` | +| **NOT_EQUALS** | `lambda a, b: a != b` | +| **KEY_GREATER_THAN_VALUE** | `lambda a, b: a > b` | +| **KEY_GREATER_THAN_OR_EQUAL_VALUE** | `lambda a, b: a >= b` | +| **KEY_LESS_THAN_VALUE** | `lambda a, b: a < b` | +| **KEY_LESS_THAN_OR_EQUAL_VALUE** | `lambda a, b: a <= b` | +| **STARTSWITH** | `lambda a, b: a.startswith(b)` | +| **ENDSWITH** | `lambda a, b: a.endswith(b)` | +| **KEY_IN_VALUE** | `lambda a, b: a in b` | +| **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_DAYS_OF_WEEK** | `lambda a, b: day_of_week(a) in b` | ???+ info The `**key**` and `**value**` will be compared to the input from the `**context**` parameter. +???+ "Time based keys" + + For time based keys, we provide a list of predefined keys. These will automatically get converted to the corresponding timestamp on each invocation of your Lambda function. + + | Key | Meaning | + | ------------------- | ------------------------------------------------------------------------ | + | CURRENT_TIME | The current time, 24 hour format (HH:mm) | + | CURRENT_DATETIME | The current datetime ([ISO8601](https://en.wikipedia.org/wiki/ISO_8601)) | + | CURRENT_DAY_OF_WEEK | The current day of the week (Monday-Sunday) | + + If not specified, the timezone used for calculations will be UTC. + **For multiple conditions**, we will evaluate the list of conditions as a logical `AND`, so all conditions needs to match to return `when_match` value. #### Rule engine flowchart diff --git a/examples/feature_flags/src/datetime_feature.json b/examples/feature_flags/src/datetime_feature.json new file mode 100644 index 00000000000..191ebf83dc5 --- /dev/null +++ b/examples/feature_flags/src/datetime_feature.json @@ -0,0 +1,21 @@ +{ + "christmas_discount": { + "default": false, + "rules": { + "enable discount during christmas": { + "when_match": true, + "conditions": [ + { + "action": "SCHEDULE_BETWEEN_DATETIME_RANGE", + "key": "CURRENT_DATETIME", + "value": { + "START": "2022-12-25T12:00:00", + "END": "2022-12-31T23:59:59", + "TIMEZONE": "America/New_York" + } + } + ] + } + } + } +} diff --git a/examples/feature_flags/src/datetime_feature.py b/examples/feature_flags/src/datetime_feature.py new file mode 100644 index 00000000000..55c11ea6e7d --- /dev/null +++ b/examples/feature_flags/src/datetime_feature.py @@ -0,0 +1,14 @@ +from aws_lambda_powertools.utilities.feature_flags import AppConfigStore, FeatureFlags + +app_config = AppConfigStore(environment="dev", application="product-catalogue", name="features") + +feature_flags = FeatureFlags(store=app_config) + + +def lambda_handler(event, context): + # Get customer's tier from incoming request + xmas_discount = feature_flags.evaluate(name="christmas_discount", default=False) + + if xmas_discount: + # Enable special discount on christmas: + pass diff --git a/examples/feature_flags/src/timebased_feature.py b/examples/feature_flags/src/timebased_feature.py new file mode 100644 index 00000000000..0b0963489f4 --- /dev/null +++ b/examples/feature_flags/src/timebased_feature.py @@ -0,0 +1,16 @@ +from aws_lambda_powertools.utilities.feature_flags import AppConfigStore, FeatureFlags + +app_config = AppConfigStore(environment="dev", application="product-catalogue", name="features") + +feature_flags = FeatureFlags(store=app_config) + + +def lambda_handler(event, context): + # Get customer's tier from incoming request + ctx = {"tier": event.get("tier", "standard")} + + weekend_premium_discount = feature_flags.evaluate(name="weekend_premium_discount", default=False, context=ctx) + + if weekend_premium_discount: + # Enable special discount for premium members on weekends + pass diff --git a/examples/feature_flags/src/timebased_feature_event.json b/examples/feature_flags/src/timebased_feature_event.json new file mode 100644 index 00000000000..894a250d5ec --- /dev/null +++ b/examples/feature_flags/src/timebased_feature_event.json @@ -0,0 +1,5 @@ +{ + "username": "rubefons", + "tier": "premium", + "basked_id": "random_id" +} diff --git a/examples/feature_flags/src/timebased_features.json b/examples/feature_flags/src/timebased_features.json new file mode 100644 index 00000000000..8e10588a0ac --- /dev/null +++ b/examples/feature_flags/src/timebased_features.json @@ -0,0 +1,28 @@ +{ + "weekend_premium_discount": { + "default": false, + "rules": { + "customer tier equals premium and its time for a discount": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + }, + { + "action": "SCHEDULE_BETWEEN_DAYS_OF_WEEK", + "key": "CURRENT_DAY_OF_WEEK", + "value": { + "DAYS": [ + "SATURDAY", + "SUNDAY" + ], + "TIMEZONE": "America/New_York" + } + } + ] + } + } + } +} \ No newline at end of file diff --git a/examples/feature_flags/src/timebased_happyhour_feature.py b/examples/feature_flags/src/timebased_happyhour_feature.py new file mode 100644 index 00000000000..b008481c722 --- /dev/null +++ b/examples/feature_flags/src/timebased_happyhour_feature.py @@ -0,0 +1,13 @@ +from aws_lambda_powertools.utilities.feature_flags import AppConfigStore, FeatureFlags + +app_config = AppConfigStore(environment="dev", application="product-catalogue", name="features") + +feature_flags = FeatureFlags(store=app_config) + + +def lambda_handler(event, context): + is_happy_hour = feature_flags.evaluate(name="happy_hour", default=False) + + if is_happy_hour: + # Apply special discount + pass diff --git a/examples/feature_flags/src/timebased_happyhour_features.json b/examples/feature_flags/src/timebased_happyhour_features.json new file mode 100644 index 00000000000..22a239882cc --- /dev/null +++ b/examples/feature_flags/src/timebased_happyhour_features.json @@ -0,0 +1,21 @@ +{ + "happy_hour": { + "default": false, + "rules": { + "is happy hour": { + "when_match": true, + "conditions": [ + { + "action": "SCHEDULE_BETWEEN_TIME_RANGE", + "key": "CURRENT_TIME", + "value": { + "START": "17:00", + "END": "19:00", + "TIMEZONE": "Europe/Copenhagen" + } + } + ] + } + } + } +} diff --git a/poetry.lock b/poetry.lock index e8bab459f3f..1c63f88c6dc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2427,6 +2427,18 @@ files = [ doc = ["sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] test = ["mypy", "pytest", "typing-extensions"] +[[package]] +name = "types-python-dateutil" +version = "2.8.19.6" +description = "Typing stubs for python-dateutil" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "types-python-dateutil-2.8.19.6.tar.gz", hash = "sha256:4a6f4cc19ce4ba1a08670871e297bf3802f55d4f129e6aa2443f540b6cf803d2"}, + {file = "types_python_dateutil-2.8.19.6-py3-none-any.whl", hash = "sha256:cfb7d31021c6bce6f3362c69af6e3abb48fe3e08854f02487e844ff910deec2a"}, +] + [[package]] name = "types-requests" version = "2.28.11.8" @@ -2653,4 +2665,4 @@ validation = ["fastjsonschema"] [metadata] lock-version = "2.0" python-versions = "^3.7.4" -content-hash = "68c48ad8be866ea55c784614e529a20ec08eb39b47536c461be0b6adc87c8d38" +content-hash = "ac05041ebadb315e0fd5b9cbbfa2acecb1952458ac60f0433e76d31d34431bda" diff --git a/pyproject.toml b/pyproject.toml index b77f7f3976a..cd43122d7c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,7 @@ aws-sdk = ["boto3"] [tool.poetry.group.dev.dependencies] cfn-lint = "0.67.0" mypy = "^0.982" +types-python-dateutil = "^2.8.19.6" [tool.coverage.run] source = ["aws_lambda_powertools"] diff --git a/tests/functional/feature_flags/test_schema_validation.py b/tests/functional/feature_flags/test_schema_validation.py index 0366a5609ee..73246f97e91 100644 --- a/tests/functional/feature_flags/test_schema_validation.py +++ b/tests/functional/feature_flags/test_schema_validation.py @@ -1,4 +1,5 @@ import logging +import re import pytest # noqa: F401 @@ -18,6 +19,8 @@ RuleAction, RulesValidator, SchemaValidator, + TimeKeys, + TimeValues, ) logger = logging.getLogger(__name__) @@ -355,7 +358,7 @@ def test_validate_rule_invalid_when_match_type_boolean_feature_is_not_set(): def test_validate_rule_boolean_feature_is_set(): # GIVEN a rule with a boolean when_match and feature type boolean # WHEN calling validate_rule - # THEN schema is validated and decalared as valid + # THEN schema is validated and declared as valid rule_name = "dummy" rule = { RULE_MATCH_VALUE: True, @@ -366,3 +369,489 @@ def test_validate_rule_boolean_feature_is_set(): }, } RulesValidator.validate_rule(rule=rule, rule_name=rule_name, feature_name="dummy", boolean_feature=True) + + +def test_validate_time_condition_between_time_range_invalid_condition_key(): + # GIVEN a configuration with a SCHEDULE_BETWEEN_TIME_RANGE action, + # value of between 11:11 to 23:59 and a key of CURRENT_DATETIME + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, + CONDITION_VALUE: {TimeValues.START.value: "11:11", TimeValues.END.value: "23:59"}, + CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value, + } + rule_name = "dummy" + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises( + SchemaValidationError, + match=f"'condition with a 'SCHEDULE_BETWEEN_TIME_RANGE' action must have a 'CURRENT_TIME' condition key, rule={rule_name}", # noqa: E501 + ): + ConditionsValidator.validate_condition_key(condition=condition, rule_name=rule_name) + + +def test_validate_time_condition_between_time_range_invalid_condition_value(): + # GIVEN a configuration with a SCHEDULE_BETWEEN_TIME_RANGE action, key CURRENT_TIME and invalid value of string + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, + CONDITION_VALUE: "11:00-22:33", + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + } + rule_name = "dummy" + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises( + SchemaValidationError, + match=f"condition with a 'SCHEDULE_BETWEEN_TIME_RANGE' action must have a condition value type dictionary with 'START' and 'END' keys, rule={rule_name}", # noqa: E501 + ): + ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name) + + +def test_validate_time_condition_between_time_range_invalid_condition_value_no_start_time(): + # GIVEN a configuration with a SCHEDULE_BETWEEN_TIME_RANGE action, key CURRENT_TIME and invalid value + # dict without START key + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, + CONDITION_VALUE: {TimeValues.END.value: "23:59"}, + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + } + rule_name = "dummy" + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises( + SchemaValidationError, + match=f"condition with a 'SCHEDULE_BETWEEN_TIME_RANGE' action must have a condition value type dictionary with 'START' and 'END' keys, rule={rule_name}", # noqa: E501 + ): + ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name) + + +def test_validate_time_condition_between_time_range_invalid_condition_value_no_end_time(): + # GIVEN a configuration with a SCHEDULE_BETWEEN_TIME_RANGE action, key CURRENT_TIME and invalid value + # dict without END key + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, + CONDITION_VALUE: {TimeValues.START.value: "23:59"}, + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + } + rule_name = "dummy" + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises( + SchemaValidationError, + match=f"condition with a 'SCHEDULE_BETWEEN_TIME_RANGE' action must have a condition value type dictionary with 'START' and 'END' keys, rule={rule_name}", # noqa: E501 + ): + ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name) + + +def test_validate_time_condition_between_time_range_invalid_condition_value_invalid_start_time_type(): + # GIVEN a configuration with a SCHEDULE_BETWEEN_TIME_RANGE action, key CURRENT_TIME and + # invalid START value as a number + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, + CONDITION_VALUE: {TimeValues.START.value: 4, TimeValues.END.value: "23:59"}, + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + } + rule_name = "dummy" + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises( + SchemaValidationError, + match=f"'START' and 'END' must be a non empty string, rule={rule_name}", + ): + ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name) + + +def test_validate_time_condition_between_time_range_invalid_condition_value_invalid_end_time_type(): + # GIVEN a configuration with a SCHEDULE_BETWEEN_TIME_RANGE action, key CURRENT_TIME and + # invalid START value as a number + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, + CONDITION_VALUE: {TimeValues.START.value: "11:11", TimeValues.END.value: 4}, + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + } + rule_name = "dummy" + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises( + SchemaValidationError, + match=f"'START' and 'END' must be a non empty string, rule={rule_name}", + ): + ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name) + + +@pytest.mark.parametrize( + "cond_value", + [ + {TimeValues.START.value: "11-11", TimeValues.END.value: "23:59"}, + {TimeValues.START.value: "24:99", TimeValues.END.value: "23:59"}, + ], +) +def test_validate_time_condition_between_time_range_invalid_condition_value_invalid_start_time_value(cond_value): + # GIVEN a configuration with a SCHEDULE_BETWEEN_TIME_RANGE action, key CURRENT_TIME and + # invalid START value as an invalid time format + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, + CONDITION_VALUE: cond_value, + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + } + rule_name = "dummy" + match_str = f"'START' and 'END' must be a valid time format, time_format=%H:%M, 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) + + +@pytest.mark.parametrize( + "cond_value", + [ + {TimeValues.START.value: "10:11", TimeValues.END.value: "11-11"}, + {TimeValues.START.value: "10:11", TimeValues.END.value: "999:59"}, + ], +) +def test_validate_time_condition_between_time_range_invalid_condition_value_invalid_end_time_value(cond_value): + # GIVEN a configuration with a SCHEDULE_BETWEEN_TIME_RANGE action, key CURRENT_TIME and + # invalid END value as an invalid time format + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, + CONDITION_VALUE: cond_value, + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + } + rule_name = "dummy" + match_str = f"'START' and 'END' must be a valid time format, time_format=%H:%M, 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_time_condition_between_time_range_invalid_timezone(): + # GIVEN a configuration with a SCHEDULE_BETWEEN_TIME_RANGE action, key CURRENT_TIME and + # invalid timezone + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, + CONDITION_VALUE: { + TimeValues.START.value: "10:11", + TimeValues.END.value: "10:59", + TimeValues.TIMEZONE.value: "Europe/Tokyo", + }, + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + } + rule_name = "dummy" + match_str = f"'TIMEZONE' value must represent a valid IANA timezone, 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_time_condition_between_time_range_valid_timezone(): + # GIVEN a configuration with a SCHEDULE_BETWEEN_TIME_RANGE action, key CURRENT_TIME and + # valid timezone + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, + CONDITION_VALUE: { + TimeValues.START.value: "10:11", + TimeValues.END.value: "10:59", + TimeValues.TIMEZONE.value: "Europe/Copenhagen", + }, + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + } + # WHEN calling validate_condition + # THEN nothing is raised + ConditionsValidator.validate_condition_value(condition=condition, rule_name="dummy") + + +def test_validate_time_condition_between_datetime_range_invalid_condition_key(): + # GIVEN a configuration with a SCHEDULE_BETWEEN_DATETIME_RANGE action, + # value of between "2022-10-05T12:15:00Z" to "2022-10-10T12:15:00Z" and a key of CURRENT_TIME + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value, + CONDITION_VALUE: { + TimeValues.START.value: "2022-10-05T12:15:00Z", + TimeValues.END.value: "2022-10-10T12:15:00Z", + }, + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + } + rule_name = "dummy" + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises( + SchemaValidationError, + match=f"'condition with a 'SCHEDULE_BETWEEN_DATETIME_RANGE' action must have a 'CURRENT_DATETIME' condition key, rule={rule_name}", # noqa: E501 + ): + ConditionsValidator.validate_condition_key(condition=condition, rule_name=rule_name) + + +def test_a_validate_time_condition_between_datetime_range_invalid_condition_value(): + # GIVEN a configuration with a SCHEDULE_BETWEEN_DATETIME_RANGE action, key CURRENT_DATETIME and invalid value of string # noqa: E501 + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value, + CONDITION_VALUE: "11:00-22:33", + CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value, + } + rule_name = "dummy" + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises( + SchemaValidationError, + match=f"condition with a 'SCHEDULE_BETWEEN_DATETIME_RANGE' action must have a condition value type dictionary with 'START' and 'END' keys, rule={rule_name}", # noqa: E501 + ): + ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name) + + +def test_validate_time_condition_between_datetime_range_invalid_condition_value_no_start_time(): + # GIVEN a configuration with a SCHEDULE_BETWEEN_DATETIME_RANGE action, key CURRENT_DATETIME and invalid value + # dict without START key + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value, + CONDITION_VALUE: {TimeValues.END.value: "2022-10-10T12:15:00Z"}, + CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value, + } + rule_name = "dummy" + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises( + SchemaValidationError, + match=f"condition with a 'SCHEDULE_BETWEEN_DATETIME_RANGE' action must have a condition value type dictionary with 'START' and 'END' keys, rule={rule_name}", # noqa: E501 + ): + ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name) + + +def test_validate_time_condition_between_datetime_range_invalid_condition_value_no_end_time(): + # GIVEN a configuration with a SCHEDULE_BETWEEN_DATETIME_RANGE action, key CURRENT_DATETIME and invalid value + # dict without END key + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value, + CONDITION_VALUE: {TimeValues.START.value: "2022-10-10T12:15:00Z"}, + CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value, + } + rule_name = "dummy" + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises( + SchemaValidationError, + match=f"condition with a 'SCHEDULE_BETWEEN_DATETIME_RANGE' action must have a condition value type dictionary with 'START' and 'END' keys, rule={rule_name}", # noqa: E501 + ): + ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name) + + +def test_validate_time_condition_between_datetime_range_invalid_condition_value_invalid_start_time_type(): + # GIVEN a configuration with a SCHEDULE_BETWEEN_DATETIME_RANGE action, key CURRENT_DATETIME and + # invalid START value as a number + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value, + CONDITION_VALUE: {TimeValues.START.value: 4, TimeValues.END.value: "2022-10-10T12:15:00Z"}, + CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value, + } + rule_name = "dummy" + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises( + SchemaValidationError, + match=f"'START' and 'END' must be a non empty string, rule={rule_name}", + ): + ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name) + + +def test_validate_time_condition_between_datetime_range_invalid_condition_value_invalid_end_time_type(): + # GIVEN a configuration with a SCHEDULE_BETWEEN_DATETIME_RANGE action, key CURRENT_DATETIME and + # invalid START value as a number + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value, + CONDITION_VALUE: {TimeValues.END.value: 4, TimeValues.START.value: "2022-10-10T12:15:00Z"}, + CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value, + } + rule_name = "dummy" + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises( + SchemaValidationError, + match=f"'START' and 'END' must be a non empty string, rule={rule_name}", + ): + ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name) + + +@pytest.mark.parametrize( + "cond_value", + [ + {TimeValues.START.value: "11:11", TimeValues.END.value: "2022-10-10T12:15:00Z"}, + {TimeValues.START.value: "24:99", TimeValues.END.value: "2022-10-10T12:15:00Z"}, + {TimeValues.START.value: "2022-10-10T", TimeValues.END.value: "2022-10-10T12:15:00Z"}, + ], +) +def test_validate_time_condition_between_datetime_range_invalid_condition_value_invalid_start_time_value(cond_value): + # GIVEN a configuration with a SCHEDULE_BETWEEN_DATETIME_RANGE action, key CURRENT_DATETIME and + # invalid START value as an invalid time format + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value, + CONDITION_VALUE: cond_value, + CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value, + } + rule_name = "dummy" + match_str = f"'START' and 'END' must be a valid ISO8601 time format, 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_time_condition_between_datetime_range_invalid_condition_value_invalid_end_time_value(): + # GIVEN a configuration with a SCHEDULE_BETWEEN_DATETIME_RANGE action, key CURRENT_DATETIME and + # invalid END value as an invalid time format + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value, + CONDITION_VALUE: {TimeValues.END.value: "10:10", TimeValues.START.value: "2022-10-10T12:15:00"}, + CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value, + } + rule_name = "dummy" + match_str = f"'START' and 'END' must be a valid ISO8601 time format, 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_time_condition_between_datetime_range_including_timezone(): + # GIVEN a configuration with a SCHEDULE_BETWEEN_DATETIME_RANGE action, key CURRENT_DATETIME and + # invalid START and END timestamps with timezone information + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value, + CONDITION_VALUE: {TimeValues.END.value: "2022-10-10T11:15:00Z", TimeValues.START.value: "2022-10-10T12:15:00Z"}, + CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value, + } + rule_name = "dummy" + match_str = ( + f"'START' and 'END' must not include timezone information. Set the timezone using the 'TIMEZONE' " + f"field, 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_time_condition_between_days_range_invalid_condition_key(): + # GIVEN a configuration with a SCHEDULE_BETWEEN_DAYS_OF_WEEK action, + # value of SUNDAY and a key of CURRENT_TIME + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, + CONDITION_VALUE: { + TimeValues.DAYS.value: [TimeValues.SUNDAY.value], + }, + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + } + rule_name = "dummy" + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises( + SchemaValidationError, + match=f"'condition with a 'SCHEDULE_BETWEEN_DAYS_OF_WEEK' action must have a 'CURRENT_DAY_OF_WEEK' condition key, rule={rule_name}", # noqa: E501 + ): + ConditionsValidator.validate_condition_key(condition=condition, rule_name=rule_name) + + +def test_validate_time_condition_between_days_range_invalid_condition_type(): + # GIVEN a configuration with a SCHEDULE_BETWEEN_DAYS_OF_WEEK action + # key CURRENT_DAY_OF_WEEK and invalid value type string + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, + CONDITION_VALUE: TimeValues.SATURDAY.value, + CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value, + } + rule_name = "dummy" + match_str = f"condition with a CURRENT_DAY_OF_WEEK action must have a condition value dictionary with 'DAYS' and 'TIMEZONE' (optional) keys, rule={rule_name}" # noqa: E501 + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises( + SchemaValidationError, + match=re.escape(match_str), + ): + ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name) + + +@pytest.mark.parametrize( + "cond_value", + [ + {TimeValues.DAYS.value: [TimeValues.SUNDAY.value, "funday"]}, + {TimeValues.DAYS.value: [TimeValues.SUNDAY, TimeValues.MONDAY.value]}, + ], +) +def test_validate_time_condition_between_days_range_invalid_condition_value(cond_value): + # GIVEN a configuration with a SCHEDULE_BETWEEN_DAYS_OF_WEEK action + # key CURRENT_DAY_OF_WEEK and invalid value not day string + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, + CONDITION_VALUE: cond_value, + CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value, + } + rule_name = "dummy" + match_str = ( + f"condition value DAYS must represent a day of the week in 'TimeValues' enum, 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_time_condition_between_days_range_invalid_timezone(): + # GIVEN a configuration with a SCHEDULE_BETWEEN_DAYS_OF_WEEK action + # key CURRENT_DAY_OF_WEEK and an invalid timezone + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, + CONDITION_VALUE: {TimeValues.DAYS.value: [TimeValues.SUNDAY.value], TimeValues.TIMEZONE.value: "Europe/Tokyo"}, + CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value, + } + rule_name = "dummy" + match_str = f"'TIMEZONE' value must represent a valid IANA timezone, 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_time_condition_between_days_range_valid_timezone(): + # GIVEN a configuration with a SCHEDULE_BETWEEN_DAYS_OF_WEEK action + # key CURRENT_DAY_OF_WEEK and a valid timezone + condition = { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, + CONDITION_VALUE: { + TimeValues.DAYS.value: [TimeValues.SUNDAY.value], + TimeValues.TIMEZONE.value: "Europe/Copenhagen", + }, + CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value, + } + # 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 new file mode 100644 index 00000000000..358f310103f --- /dev/null +++ b/tests/functional/feature_flags/test_time_based_actions.py @@ -0,0 +1,533 @@ +import datetime +from typing import Any, Dict, Optional, Tuple + +from botocore.config import Config +from dateutil.tz import gettz + +from aws_lambda_powertools.shared.types import JSONType +from aws_lambda_powertools.utilities.feature_flags.appconfig import AppConfigStore +from aws_lambda_powertools.utilities.feature_flags.feature_flags import FeatureFlags +from aws_lambda_powertools.utilities.feature_flags.schema import ( + CONDITION_ACTION, + CONDITION_KEY, + CONDITION_VALUE, + CONDITIONS_KEY, + FEATURE_DEFAULT_VAL_KEY, + RULE_MATCH_VALUE, + RULES_KEY, + RuleAction, + TimeKeys, + TimeValues, +) + + +def evaluate_mocked_schema( + mocker, + rules: Dict[str, Any], + mocked_time: Tuple[int, int, int, int, int, int, datetime.tzinfo], # year, month, day, hour, minute, second + context: Optional[Dict[str, Any]] = None, +) -> JSONType: + """ + This helper does the following: + 1. mocks the current time + 2. mocks the feature flag payload returned from AppConfig + 3. evaluates the rules and return True for a rule match, otherwise a False + """ + + # 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.return_value = datetime.datetime( + year=year, month=month, day=day, hour=hour, minute=minute, second=second, microsecond=0, tzinfo=timezone + ) + + # Mock the returned data from AppConfig + mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.parameters.AppConfigProvider.get") + mocked_get_conf.return_value = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: rules, + } + } + + # Create a dummy AppConfigStore that returns our mocked FeatureFlag + app_conf_fetcher = AppConfigStore( + environment="test_env", + application="test_app", + name="test_conf_name", + max_age=600, + sdk_config=Config(region_name="us-east-1"), + ) + feature_flags: FeatureFlags = FeatureFlags(store=app_conf_fetcher) + + # Evaluate our feature flag + context = {} if context is None else context + return feature_flags.evaluate( + name="my_feature", + context=context, + default=False, + ) + + +def test_time_based_utc_in_between_time_range_rule_match(mocker): + assert evaluate_mocked_schema( + mocker=mocker, + rules={ + "lambda time is between UTC 11:11-23:59": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, # this condition matches + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + CONDITION_VALUE: {TimeValues.START.value: "11:11", TimeValues.END.value: "23:59"}, + }, + ], + } + }, + mocked_time=(2022, 2, 15, 11, 12, 0, datetime.timezone.utc), + ) + + +def test_time_based_utc_in_between_time_range_no_rule_match(mocker): + assert not evaluate_mocked_schema( + mocker=mocker, + rules={ + "lambda time is between UTC 11:11-23:59": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, # this condition matches + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + CONDITION_VALUE: {TimeValues.START.value: "11:11", TimeValues.END.value: "23:59"}, + }, + ], + } + }, + mocked_time=(2022, 2, 15, 7, 12, 0, datetime.timezone.utc), # no rule match 7:12 am + ) + + +def test_time_based_utc_in_between_time_range_full_hour_rule_match(mocker): + assert evaluate_mocked_schema( + mocker=mocker, + rules={ + "lambda time is between UTC 20:00-23:00": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, # this condition matches + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + CONDITION_VALUE: {TimeValues.START.value: "20:00", TimeValues.END.value: "23:00"}, + }, + ], + } + }, + mocked_time=(2022, 2, 15, 21, 12, 0, datetime.timezone.utc), # rule match 21:12 + ) + + +def test_time_based_utc_in_between_time_range_between_days_rule_match(mocker): + assert evaluate_mocked_schema( + mocker=mocker, + rules={ + "lambda time is between UTC 23:00-04:00": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, # this condition matches + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + CONDITION_VALUE: {TimeValues.START.value: "23:00", TimeValues.END.value: "04:00"}, + }, + ], + } + }, + mocked_time=(2022, 2, 15, 2, 3, 0, datetime.timezone.utc), # rule match 2:03 am + ) + + +def test_time_based_utc_in_between_time_range_between_days_rule_no_match(mocker): + assert not evaluate_mocked_schema( + mocker=mocker, + rules={ + "lambda time is between UTC 23:00-04:00": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, # this condition matches + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + CONDITION_VALUE: {TimeValues.START.value: "23:00", TimeValues.END.value: "04:00"}, + }, + ], + } + }, + mocked_time=(2022, 2, 15, 5, 0, 0, datetime.timezone.utc), # rule no match 5:00 am + ) + + +def test_time_based_between_time_range_rule_timezone_match(mocker): + timezone_name = "Europe/Copenhagen" + + assert evaluate_mocked_schema( + mocker=mocker, + rules={ + "lambda time is between UTC 11:11-23:59, Copenhagen Time": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, # this condition matches + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + CONDITION_VALUE: { + TimeValues.START.value: "11:11", + TimeValues.END.value: "23:59", + TimeValues.TIMEZONE.value: timezone_name, + }, + }, + ], + } + }, + mocked_time=(2022, 2, 15, 11, 11, 0, gettz(timezone_name)), # rule match 11:11 am, Europe/Copenhagen + ) + + +def test_time_based_between_time_range_rule_timezone_no_match(mocker): + timezone_name = "Europe/Copenhagen" + + assert not evaluate_mocked_schema( + mocker=mocker, + rules={ + "lambda time is between UTC 11:11-23:59, Copenhagen Time": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, # this condition matches + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + CONDITION_VALUE: { + TimeValues.START.value: "11:11", + TimeValues.END.value: "23:59", + TimeValues.TIMEZONE.value: timezone_name, + }, + }, + ], + } + }, + mocked_time=(2022, 2, 15, 10, 11, 0, gettz(timezone_name)), # no rule match 10:11 am, Europe/Copenhagen + ) + + +def test_time_based_utc_in_between_full_time_range_rule_match(mocker): + assert evaluate_mocked_schema( + mocker=mocker, + rules={ + "lambda time is between UTC october 5th 2022 12:14:32PM to october 10th 2022 12:15:00 PM": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value, # condition matches + CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value, + CONDITION_VALUE: { + TimeValues.START.value: "2022-10-05T12:15:00", + TimeValues.END.value: "2022-10-10T12:15:00", + }, + }, + ], + } + }, + mocked_time=(2022, 10, 7, 10, 0, 0, datetime.timezone.utc), # will match rule + ) + + +def test_time_based_utc_in_between_full_time_range_no_rule_match(mocker): + timezone_name = "Europe/Copenhagen" + + assert not evaluate_mocked_schema( + mocker=mocker, + rules={ + "lambda time is between UTC october 5th 2022 12:14:32PM to october 10th 2022 12:15:00 PM": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value, # condition matches + CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value, + CONDITION_VALUE: { + TimeValues.START.value: "2022-10-05T12:15:00", + TimeValues.END.value: "2022-10-10T12:15:00", + TimeValues.TIMEZONE.value: timezone_name, + }, + }, + ], + } + }, + mocked_time=(2022, 9, 7, 10, 0, 0, gettz(timezone_name)), # will not rule match + ) + + +def test_time_based_utc_in_between_full_time_range_timezone_no_match(mocker): + assert not evaluate_mocked_schema( + mocker=mocker, + rules={ + "lambda time is between UTC october 5th 2022 12:14:32PM to october 10th 2022 12:15:00 PM": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value, # condition matches + CONDITION_KEY: TimeKeys.CURRENT_DATETIME.value, + CONDITION_VALUE: { + TimeValues.START.value: "2022-10-05T12:15:00", + TimeValues.END.value: "2022-10-10T12:15:00", + TimeValues.TIMEZONE.value: "Europe/Copenhagen", + }, + }, + ], + } + }, + mocked_time=(2022, 10, 10, 12, 15, 0, gettz("America/New_York")), # will not rule match, it's too late + ) + + +def test_time_based_multiple_conditions_utc_in_between_time_range_rule_match(mocker): + assert evaluate_mocked_schema( + mocker=mocker, + rules={ + "lambda time is between UTC 09:00-17:00 and username is ran": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, # this condition matches + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + CONDITION_VALUE: {TimeValues.START.value: "09:00", TimeValues.END.value: "17:00"}, + }, + { + CONDITION_ACTION: RuleAction.EQUALS.value, + CONDITION_KEY: "username", + CONDITION_VALUE: "ran", + }, + ], + } + }, + mocked_time=(2022, 10, 7, 10, 0, 0, datetime.timezone.utc), # will rule match + context={"username": "ran"}, + ) + + +def test_time_based_multiple_conditions_utc_in_between_time_range_no_rule_match(mocker): + assert not evaluate_mocked_schema( + mocker=mocker, + rules={ + "lambda time is between UTC 09:00-17:00 and username is ran": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, # this condition matches + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + CONDITION_VALUE: {TimeValues.START.value: "09:00", TimeValues.END.value: "17:00"}, + }, + { + CONDITION_ACTION: RuleAction.EQUALS.value, + CONDITION_KEY: "username", + CONDITION_VALUE: "ran", + }, + ], + } + }, + mocked_time=(2022, 10, 7, 7, 0, 0, datetime.timezone.utc), # will cause no rule match, 7:00 + context={"username": "ran"}, + ) + + +def test_time_based_utc_days_range_rule_match(mocker): + assert evaluate_mocked_schema( + mocker=mocker, + rules={ + "match only monday through friday": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, # this condition matches + CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value, # similar to "IN" actions + CONDITION_VALUE: { + TimeValues.DAYS.value: [ + TimeValues.MONDAY.value, + TimeValues.TUESDAY.value, + TimeValues.WEDNESDAY.value, + TimeValues.THURSDAY.value, + TimeValues.FRIDAY.value, + ], + }, + }, + ], + } + }, + mocked_time=(2022, 11, 18, 10, 0, 0, datetime.timezone.utc), # friday + ) + + +def test_time_based_utc_days_range_no_rule_match(mocker): + assert not evaluate_mocked_schema( + mocker=mocker, + rules={ + "match only monday through friday": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, # this condition matches + CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value, # similar to "IN" actions + CONDITION_VALUE: { + TimeValues.DAYS.value: [ + TimeValues.MONDAY.value, + TimeValues.TUESDAY.value, + TimeValues.WEDNESDAY.value, + TimeValues.THURSDAY.value, + TimeValues.FRIDAY.value, + ], + }, + }, + ], + } + }, + mocked_time=(2022, 11, 20, 10, 0, 0, datetime.timezone.utc), # sunday, no match + ) + + +def test_time_based_utc_only_weekend_rule_match(mocker): + assert evaluate_mocked_schema( + mocker=mocker, + rules={ + "match only on weekend": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, # this condition matches + CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value, # similar to "IN" actions + CONDITION_VALUE: { + TimeValues.DAYS.value: [TimeValues.SATURDAY.value, TimeValues.SUNDAY.value], + }, + }, + ], + } + }, + mocked_time=(2022, 11, 19, 10, 0, 0, datetime.timezone.utc), # saturday + ) + + +def test_time_based_utc_only_weekend_with_timezone_rule_match(mocker): + timezone_name = "Europe/Copenhagen" + + assert evaluate_mocked_schema( + mocker=mocker, + rules={ + "match only on weekend": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, # this condition matches + CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value, # similar to "IN" actions + CONDITION_VALUE: { + TimeValues.DAYS.value: [TimeValues.SATURDAY.value, TimeValues.SUNDAY.value], + TimeValues.TIMEZONE.value: timezone_name, + }, + }, + ], + } + }, + mocked_time=(2022, 11, 19, 10, 0, 0, gettz(timezone_name)), # saturday + ) + + +def test_time_based_utc_only_weekend_with_timezone_rule_no_match(mocker): + timezone_name = "Europe/Copenhagen" + + assert not evaluate_mocked_schema( + mocker=mocker, + rules={ + "match only on weekend": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, # this condition matches + CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value, # similar to "IN" actions + CONDITION_VALUE: { + TimeValues.DAYS.value: [TimeValues.SATURDAY.value, TimeValues.SUNDAY.value], + TimeValues.TIMEZONE.value: timezone_name, + }, + }, + ], + } + }, + mocked_time=(2022, 11, 21, 0, 0, 0, gettz("Europe/Copenhagen")), # monday, 00:00 + ) + + +def test_time_based_utc_only_weekend_no_rule_match(mocker): + assert not evaluate_mocked_schema( + mocker=mocker, + rules={ + "match only on weekend": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, # this condition matches + CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value, # similar to "IN" actions + CONDITION_VALUE: { + TimeValues.DAYS.value: [TimeValues.SATURDAY.value, TimeValues.SUNDAY.value], + }, + }, + ], + } + }, + mocked_time=(2022, 11, 18, 10, 0, 0, datetime.timezone.utc), # friday, no match + ) + + +def test_time_based_multiple_conditions_utc_days_range_and_certain_hours_rule_match(mocker): + assert evaluate_mocked_schema( + mocker=mocker, + rules={ + "match when lambda time is between UTC 11:00-23:00 and day is either monday or thursday": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, # this condition matches + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + CONDITION_VALUE: {TimeValues.START.value: "11:00", TimeValues.END.value: "23:00"}, + }, + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, # this condition matches + CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value, + CONDITION_VALUE: {TimeValues.DAYS.value: [TimeValues.MONDAY.value, TimeValues.THURSDAY.value]}, + }, + ], + } + }, + mocked_time=(2022, 11, 17, 16, 0, 0, datetime.timezone.utc), # thursday 16:00 + ) + + +def test_time_based_multiple_conditions_utc_days_range_and_certain_hours_no_rule_match(mocker): + def evaluate(mocked_time: Tuple[int, int, int, int, int, int, datetime.tzinfo]): + evaluate_mocked_schema( + mocker=mocker, + rules={ + "match when lambda time is between UTC 11:00-23:00 and day is either monday or thursday": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + CONDITION_VALUE: {TimeValues.START.value: "11:00", TimeValues.END.value: "23:00"}, + }, + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value, + CONDITION_KEY: TimeKeys.CURRENT_DAY_OF_WEEK.value, + CONDITION_VALUE: { + TimeValues.DAYS.value: [TimeValues.MONDAY.value, TimeValues.THURSDAY.value] + }, + }, + ], + } + }, + mocked_time=mocked_time, + ) + + assert not evaluate(mocked_time=(2022, 11, 17, 9, 0, 0, datetime.timezone.utc)) # thursday 9:00 + assert not evaluate(mocked_time=(2022, 11, 18, 13, 0, 0, datetime.timezone.utc)) # friday 16:00 + assert not evaluate(mocked_time=(2022, 11, 18, 9, 0, 0, datetime.timezone.utc)) # friday 9:00