diff --git a/.github/workflows/reusable_publish_docs.yml b/.github/workflows/reusable_publish_docs.yml index c8d22738f06..162b216dce2 100644 --- a/.github/workflows/reusable_publish_docs.yml +++ b/.github/workflows/reusable_publish_docs.yml @@ -65,7 +65,7 @@ jobs: poetry run mike set-default --push latest - name: Release API docs - uses: peaceiris/actions-gh-pages@64b46b4226a4a12da2239ba3ea5aa73e3163c75b # v3.9.1 + uses: peaceiris/actions-gh-pages@bd8c6b06eba6b3d25d72b7a1767993c0aeee42e7 # v3.9.2 env: VERSION: ${{ inputs.version }} with: @@ -75,7 +75,7 @@ jobs: destination_dir: ${{ env.VERSION }}/api - name: Release API docs to latest if: ${{ inputs.alias == 'latest' }} - uses: peaceiris/actions-gh-pages@64b46b4226a4a12da2239ba3ea5aa73e3163c75b # v3.9.1 + uses: peaceiris/actions-gh-pages@bd8c6b06eba6b3d25d72b7a1767993c0aeee42e7 # v3.9.2 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./api diff --git a/.github/workflows/secure_workflows.yml b/.github/workflows/secure_workflows.yml index e0a3f3af616..cfb0a51a9a1 100644 --- a/.github/workflows/secure_workflows.yml +++ b/.github/workflows/secure_workflows.yml @@ -16,7 +16,7 @@ jobs: - name: Checkout code uses: actions/checkout@v3 - name: Ensure 3rd party workflows have SHA pinned - uses: zgosalvez/github-actions-ensure-sha-pinned-actions@afbf9b485669c7ad13347734c9f146175e83cb43 # v2.0.4 + uses: zgosalvez/github-actions-ensure-sha-pinned-actions@bd2868d14a756969608c618665394415a238de69 # v2.0.5 with: # Trusted GitHub Actions and/or organizations allowlist: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e6dc30b814..6df040df4ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,35 @@ ## Bug Fixes +* git-chlg docker image is broken + +## Features + +* **feature_flags:** Add Time based feature flags actions ([#1846](https://github.com/awslabs/aws-lambda-powertools-python/issues/1846)) + +## Maintenance + +* **deps:** bump peaceiris/actions-gh-pages from 3.9.1 to 3.9.2 ([#1841](https://github.com/awslabs/aws-lambda-powertools-python/issues/1841)) +* **deps:** bump future from 0.18.2 to 0.18.3 ([#1836](https://github.com/awslabs/aws-lambda-powertools-python/issues/1836)) +* **deps:** bump zgosalvez/github-actions-ensure-sha-pinned-actions from 2.0.4 to 2.0.5 ([#1837](https://github.com/awslabs/aws-lambda-powertools-python/issues/1837)) +* **deps-dev:** bump mkdocs-material from 9.0.5 to 9.0.6 ([#1851](https://github.com/awslabs/aws-lambda-powertools-python/issues/1851)) +* **deps-dev:** bump types-requests from 2.28.11.7 to 2.28.11.8 ([#1843](https://github.com/awslabs/aws-lambda-powertools-python/issues/1843)) +* **deps-dev:** bump mypy-boto3-cloudwatch from 1.26.30 to 1.26.52 ([#1847](https://github.com/awslabs/aws-lambda-powertools-python/issues/1847)) +* **deps-dev:** bump pytest from 7.2.0 to 7.2.1 ([#1838](https://github.com/awslabs/aws-lambda-powertools-python/issues/1838)) +* **deps-dev:** bump mkdocs-material from 9.0.4 to 9.0.5 ([#1840](https://github.com/awslabs/aws-lambda-powertools-python/issues/1840)) +* **deps-dev:** bump aws-cdk-lib from 2.60.0 to 2.61.1 ([#1849](https://github.com/awslabs/aws-lambda-powertools-python/issues/1849)) +* **deps-dev:** bump mypy-boto3-logs from 1.26.49 to 1.26.53 ([#1850](https://github.com/awslabs/aws-lambda-powertools-python/issues/1850)) +* **deps-dev:** bump mkdocs-material from 9.0.3 to 9.0.4 ([#1833](https://github.com/awslabs/aws-lambda-powertools-python/issues/1833)) +* **deps-dev:** bump mypy-boto3-logs from 1.26.43 to 1.26.49 ([#1834](https://github.com/awslabs/aws-lambda-powertools-python/issues/1834)) +* **deps-dev:** bump mypy-boto3-secretsmanager from 1.26.40 to 1.26.49 ([#1835](https://github.com/awslabs/aws-lambda-powertools-python/issues/1835)) +* **deps-dev:** bump mypy-boto3-lambda from 1.26.18 to 1.26.49 ([#1832](https://github.com/awslabs/aws-lambda-powertools-python/issues/1832)) +* **deps-dev:** bump aws-cdk-lib from 2.59.0 to 2.60.0 ([#1831](https://github.com/awslabs/aws-lambda-powertools-python/issues/1831)) + + + +## [v2.6.0] - 2023-01-12 +## Bug Fixes + * **api_gateway:** fixed custom metrics issue when using debug mode ([#1827](https://github.com/awslabs/aws-lambda-powertools-python/issues/1827)) ## Documentation @@ -15,29 +44,30 @@ ## Maintenance +* update v2 layer ARN on documentation * **deps:** bump pydantic from 1.10.2 to 1.10.4 ([#1817](https://github.com/awslabs/aws-lambda-powertools-python/issues/1817)) * **deps:** bump zgosalvez/github-actions-ensure-sha-pinned-actions from 2.0.1 to 2.0.3 ([#1801](https://github.com/awslabs/aws-lambda-powertools-python/issues/1801)) * **deps:** bump release-drafter/release-drafter from 5.21.1 to 5.22.0 ([#1802](https://github.com/awslabs/aws-lambda-powertools-python/issues/1802)) -* **deps:** bump zgosalvez/github-actions-ensure-sha-pinned-actions from 2.0.3 to 2.0.4 ([#1821](https://github.com/awslabs/aws-lambda-powertools-python/issues/1821)) * **deps:** bump gitpython from 3.1.29 to 3.1.30 ([#1812](https://github.com/awslabs/aws-lambda-powertools-python/issues/1812)) +* **deps:** bump zgosalvez/github-actions-ensure-sha-pinned-actions from 2.0.3 to 2.0.4 ([#1821](https://github.com/awslabs/aws-lambda-powertools-python/issues/1821)) * **deps:** bump peaceiris/actions-gh-pages from 3.9.0 to 3.9.1 ([#1814](https://github.com/awslabs/aws-lambda-powertools-python/issues/1814)) -* **deps-dev:** bump coverage from 7.0.4 to 7.0.5 ([#1829](https://github.com/awslabs/aws-lambda-powertools-python/issues/1829)) +* **deps-dev:** bump mkdocs-material from 8.5.11 to 9.0.2 ([#1808](https://github.com/awslabs/aws-lambda-powertools-python/issues/1808)) * **deps-dev:** bump mypy-boto3-ssm from 1.26.11.post1 to 1.26.43 ([#1819](https://github.com/awslabs/aws-lambda-powertools-python/issues/1819)) * **deps-dev:** bump mypy-boto3-logs from 1.26.27 to 1.26.43 ([#1820](https://github.com/awslabs/aws-lambda-powertools-python/issues/1820)) * **deps-dev:** bump filelock from 3.8.2 to 3.9.0 ([#1816](https://github.com/awslabs/aws-lambda-powertools-python/issues/1816)) * **deps-dev:** bump mypy-boto3-cloudformation from 1.26.11.post1 to 1.26.35.post1 ([#1818](https://github.com/awslabs/aws-lambda-powertools-python/issues/1818)) -* **deps-dev:** bump mkdocs-material from 8.5.11 to 9.0.2 ([#1808](https://github.com/awslabs/aws-lambda-powertools-python/issues/1808)) +* **deps-dev:** bump ijson from 3.1.4 to 3.2.0.post0 ([#1815](https://github.com/awslabs/aws-lambda-powertools-python/issues/1815)) * **deps-dev:** bump coverage from 6.5.0 to 7.0.3 ([#1806](https://github.com/awslabs/aws-lambda-powertools-python/issues/1806)) * **deps-dev:** bump flake8-builtins from 2.0.1 to 2.1.0 ([#1799](https://github.com/awslabs/aws-lambda-powertools-python/issues/1799)) -* **deps-dev:** bump ijson from 3.1.4 to 3.2.0.post0 ([#1815](https://github.com/awslabs/aws-lambda-powertools-python/issues/1815)) +* **deps-dev:** bump coverage from 7.0.3 to 7.0.4 ([#1822](https://github.com/awslabs/aws-lambda-powertools-python/issues/1822)) * **deps-dev:** bump mypy-boto3-secretsmanager from 1.26.12 to 1.26.40 ([#1811](https://github.com/awslabs/aws-lambda-powertools-python/issues/1811)) * **deps-dev:** bump isort from 5.11.3 to 5.11.4 ([#1809](https://github.com/awslabs/aws-lambda-powertools-python/issues/1809)) * **deps-dev:** bump aws-cdk-lib from 2.55.1 to 2.59.0 ([#1810](https://github.com/awslabs/aws-lambda-powertools-python/issues/1810)) * **deps-dev:** bump importlib-metadata from 5.1.0 to 6.0.0 ([#1804](https://github.com/awslabs/aws-lambda-powertools-python/issues/1804)) -* **deps-dev:** bump coverage from 7.0.3 to 7.0.4 ([#1822](https://github.com/awslabs/aws-lambda-powertools-python/issues/1822)) +* **deps-dev:** bump mkdocs-material from 9.0.2 to 9.0.3 ([#1823](https://github.com/awslabs/aws-lambda-powertools-python/issues/1823)) * **deps-dev:** bump black from 22.10.0 to 22.12.0 ([#1770](https://github.com/awslabs/aws-lambda-powertools-python/issues/1770)) * **deps-dev:** bump flake8-black from 0.3.5 to 0.3.6 ([#1792](https://github.com/awslabs/aws-lambda-powertools-python/issues/1792)) -* **deps-dev:** bump mkdocs-material from 9.0.2 to 9.0.3 ([#1823](https://github.com/awslabs/aws-lambda-powertools-python/issues/1823)) +* **deps-dev:** bump coverage from 7.0.4 to 7.0.5 ([#1829](https://github.com/awslabs/aws-lambda-powertools-python/issues/1829)) * **deps-dev:** bump types-requests from 2.28.11.5 to 2.28.11.7 ([#1795](https://github.com/awslabs/aws-lambda-powertools-python/issues/1795)) @@ -2748,7 +2778,8 @@ * Merge pull request [#5](https://github.com/awslabs/aws-lambda-powertools-python/issues/5) from jfuss/feat/python38 -[Unreleased]: https://github.com/awslabs/aws-lambda-powertools-python/compare/v2.5.0...HEAD +[Unreleased]: https://github.com/awslabs/aws-lambda-powertools-python/compare/v2.6.0...HEAD +[v2.6.0]: https://github.com/awslabs/aws-lambda-powertools-python/compare/v2.5.0...v2.6.0 [v2.5.0]: https://github.com/awslabs/aws-lambda-powertools-python/compare/v2.4.0...v2.5.0 [v2.4.0]: https://github.com/awslabs/aws-lambda-powertools-python/compare/v2.3.1...v2.4.0 [v2.3.1]: https://github.com/awslabs/aws-lambda-powertools-python/compare/v2.3.0...v2.3.1 diff --git a/Makefile b/Makefile index 6ab1a8b4434..47854f3dd27 100644 --- a/Makefile +++ b/Makefile @@ -102,7 +102,7 @@ changelog: git fetch --tags origin CURRENT_VERSION=$(shell git describe --abbrev=0 --tag) ;\ echo "[+] Pre-generating CHANGELOG for tag: $$CURRENT_VERSION" ;\ - docker run -v "${PWD}":/workdir quay.io/git-chglog/git-chglog > CHANGELOG.md + docker run -v "${PWD}":/workdir quay.io/git-chglog/git-chglog:0.15.1 > CHANGELOG.md mypy: poetry run mypy --pretty aws_lambda_powertools examples diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 8ced47f81e2..7b4001c7265 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -728,21 +728,26 @@ def _call_exception_handler(self, exp: Exception, route: Route) -> Optional[Resp return None - def _to_response(self, result: Union[Dict, Response]) -> Response: + def _to_response(self, result: Union[Dict, Tuple, Response]) -> Response: """Convert the route's result to a Response - 2 main result types are supported: + 3 main result types are supported: - Dict[str, Any]: Rest api response with just the Dict to json stringify and content-type is set to application/json + - Tuple[dict, int]: Same dict handling as above but with the option of including a status code - Response: returned as is, and allows for more flexibility """ + status_code = HTTPStatus.OK if isinstance(result, Response): return result + elif isinstance(result, tuple) and len(result) == 2: + # Unpack result dict and status code from tuple + result, status_code = result logger.debug("Simple response detected, serializing return before constructing final response") return Response( - status_code=200, + status_code=status_code, content_type=content_types.APPLICATION_JSON, body=self._json_dump(result), ) 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/core/event_handler/api_gateway.md b/docs/core/event_handler/api_gateway.md index cee848c24c3..802b6112e04 100644 --- a/docs/core/event_handler/api_gateway.md +++ b/docs/core/event_handler/api_gateway.md @@ -45,7 +45,12 @@ A resolver will handle request resolution, including [one or more routers](#spli For resolvers, we provide: `APIGatewayRestResolver`, `APIGatewayHttpResolver`, `ALBResolver`, and `LambdaFunctionUrlResolver`. From here on, we will default to `APIGatewayRestResolver` across examples. ???+ info "Auto-serialization" - We serialize `Dict` responses as JSON, trim whitespace for compact responses, and set content-type to `application/json`. + We serialize `Dict` responses as JSON, trim whitespace for compact responses, set content-type to `application/json`, and + return a 200 OK HTTP status. You can optionally set a different HTTP status code as the second argument of the tuple: + + ```python hl_lines="15 16" + --8<-- "examples/event_handler_rest/src/getting_started_return_tuple.py" + ``` #### API Gateway REST API diff --git a/docs/index.md b/docs/index.md index f09265b29f7..58e4322da00 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,8 +26,8 @@ A suite of utilities for AWS Lambda functions to ease adopting best practices su Powertools is available in the following formats: -* **Lambda Layer (x86_64)**: [**arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPythonV2:18**](#){: .copyMe}:clipboard: -* **Lambda Layer (arm64)**: [**arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18**](#){: .copyMe}:clipboard: +* **Lambda Layer (x86_64)**: [**arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPythonV2:19**](#){: .copyMe}:clipboard: +* **Lambda Layer (arm64)**: [**arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19**](#){: .copyMe}:clipboard: * **PyPi**: **`pip install "aws-lambda-powertools"`** ???+ info "Some utilities require additional dependencies" @@ -67,55 +67,55 @@ You can include Powertools Lambda Layer using [AWS Lambda Console](https://docs. | Region | Layer ARN | | ---------------- | ---------------------------------------------------------------------------------------------------------- | - | `af-south-1` | [arn:aws:lambda:af-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | - | `ap-east-1` | [arn:aws:lambda:ap-east-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | - | `ap-northeast-1` | [arn:aws:lambda:ap-northeast-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | - | `ap-northeast-2` | [arn:aws:lambda:ap-northeast-2:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | - | `ap-northeast-3` | [arn:aws:lambda:ap-northeast-3:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | - | `ap-south-1` | [arn:aws:lambda:ap-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | - | `ap-southeast-1` | [arn:aws:lambda:ap-southeast-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | - | `ap-southeast-2` | [arn:aws:lambda:ap-southeast-2:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | - | `ap-southeast-3` | [arn:aws:lambda:ap-southeast-3:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | - | `ca-central-1` | [arn:aws:lambda:ca-central-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | - | `eu-central-1` | [arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | - | `eu-north-1` | [arn:aws:lambda:eu-north-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | - | `eu-south-1` | [arn:aws:lambda:eu-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | - | `eu-west-1` | [arn:aws:lambda:eu-west-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | - | `eu-west-2` | [arn:aws:lambda:eu-west-2:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | - | `eu-west-3` | [arn:aws:lambda:eu-west-3:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | - | `me-south-1` | [arn:aws:lambda:me-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | - | `sa-east-1` | [arn:aws:lambda:sa-east-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | - | `us-east-1` | [arn:aws:lambda:us-east-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | - | `us-east-2` | [arn:aws:lambda:us-east-2:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | - | `us-west-1` | [arn:aws:lambda:us-west-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | - | `us-west-2` | [arn:aws:lambda:us-west-2:017000801446:layer:AWSLambdaPowertoolsPythonV2:18](#){: .copyMe}:clipboard: | + | `af-south-1` | [arn:aws:lambda:af-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | + | `ap-east-1` | [arn:aws:lambda:ap-east-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | + | `ap-northeast-1` | [arn:aws:lambda:ap-northeast-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | + | `ap-northeast-2` | [arn:aws:lambda:ap-northeast-2:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | + | `ap-northeast-3` | [arn:aws:lambda:ap-northeast-3:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | + | `ap-south-1` | [arn:aws:lambda:ap-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | + | `ap-southeast-1` | [arn:aws:lambda:ap-southeast-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | + | `ap-southeast-2` | [arn:aws:lambda:ap-southeast-2:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | + | `ap-southeast-3` | [arn:aws:lambda:ap-southeast-3:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | + | `ca-central-1` | [arn:aws:lambda:ca-central-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | + | `eu-central-1` | [arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | + | `eu-north-1` | [arn:aws:lambda:eu-north-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | + | `eu-south-1` | [arn:aws:lambda:eu-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | + | `eu-west-1` | [arn:aws:lambda:eu-west-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | + | `eu-west-2` | [arn:aws:lambda:eu-west-2:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | + | `eu-west-3` | [arn:aws:lambda:eu-west-3:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | + | `me-south-1` | [arn:aws:lambda:me-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | + | `sa-east-1` | [arn:aws:lambda:sa-east-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | + | `us-east-1` | [arn:aws:lambda:us-east-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | + | `us-east-2` | [arn:aws:lambda:us-east-2:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | + | `us-west-1` | [arn:aws:lambda:us-west-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | + | `us-west-2` | [arn:aws:lambda:us-west-2:017000801446:layer:AWSLambdaPowertoolsPythonV2:19](#){: .copyMe}:clipboard: | === "arm64" | Region | Layer ARN | | ---------------- | ---------------------------------------------------------------------------------------------------------------- | - | `af-south-1` | [arn:aws:lambda:af-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | - | `ap-east-1` | [arn:aws:lambda:ap-east-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | - | `ap-northeast-1` | [arn:aws:lambda:ap-northeast-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | - | `ap-northeast-2` | [arn:aws:lambda:ap-northeast-2:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | - | `ap-northeast-3` | [arn:aws:lambda:ap-northeast-3:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | - | `ap-south-1` | [arn:aws:lambda:ap-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | - | `ap-southeast-1` | [arn:aws:lambda:ap-southeast-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | - | `ap-southeast-2` | [arn:aws:lambda:ap-southeast-2:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | - | `ap-southeast-3` | [arn:aws:lambda:ap-southeast-3:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | - | `ca-central-1` | [arn:aws:lambda:ca-central-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | - | `eu-central-1` | [arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | - | `eu-north-1` | [arn:aws:lambda:eu-north-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | - | `eu-south-1` | [arn:aws:lambda:eu-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | - | `eu-west-1` | [arn:aws:lambda:eu-west-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | - | `eu-west-2` | [arn:aws:lambda:eu-west-2:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | - | `eu-west-3` | [arn:aws:lambda:eu-west-3:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | - | `me-south-1` | [arn:aws:lambda:me-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | - | `sa-east-1` | [arn:aws:lambda:sa-east-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | - | `us-east-1` | [arn:aws:lambda:us-east-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | - | `us-east-2` | [arn:aws:lambda:us-east-2:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | - | `us-west-1` | [arn:aws:lambda:us-west-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | - | `us-west-2` | [arn:aws:lambda:us-west-2:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18](#){: .copyMe}:clipboard: | + | `af-south-1` | [arn:aws:lambda:af-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | + | `ap-east-1` | [arn:aws:lambda:ap-east-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | + | `ap-northeast-1` | [arn:aws:lambda:ap-northeast-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | + | `ap-northeast-2` | [arn:aws:lambda:ap-northeast-2:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | + | `ap-northeast-3` | [arn:aws:lambda:ap-northeast-3:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | + | `ap-south-1` | [arn:aws:lambda:ap-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | + | `ap-southeast-1` | [arn:aws:lambda:ap-southeast-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | + | `ap-southeast-2` | [arn:aws:lambda:ap-southeast-2:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | + | `ap-southeast-3` | [arn:aws:lambda:ap-southeast-3:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | + | `ca-central-1` | [arn:aws:lambda:ca-central-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | + | `eu-central-1` | [arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | + | `eu-north-1` | [arn:aws:lambda:eu-north-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | + | `eu-south-1` | [arn:aws:lambda:eu-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | + | `eu-west-1` | [arn:aws:lambda:eu-west-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | + | `eu-west-2` | [arn:aws:lambda:eu-west-2:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | + | `eu-west-3` | [arn:aws:lambda:eu-west-3:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | + | `me-south-1` | [arn:aws:lambda:me-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | + | `sa-east-1` | [arn:aws:lambda:sa-east-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | + | `us-east-1` | [arn:aws:lambda:us-east-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | + | `us-east-2` | [arn:aws:lambda:us-east-2:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | + | `us-west-1` | [arn:aws:lambda:us-west-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | + | `us-west-2` | [arn:aws:lambda:us-west-2:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19](#){: .copyMe}:clipboard: | ??? note "Note: Click to expand and copy code snippets for popular frameworks" @@ -128,7 +128,7 @@ You can include Powertools Lambda Layer using [AWS Lambda Console](https://docs. Type: AWS::Serverless::Function Properties: Layers: - - !Sub arn:aws:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPythonV2:18 + - !Sub arn:aws:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPythonV2:19 ``` === "Serverless framework" @@ -138,7 +138,7 @@ You can include Powertools Lambda Layer using [AWS Lambda Console](https://docs. hello: handler: lambda_function.lambda_handler layers: - - arn:aws:lambda:${aws:region}:017000801446:layer:AWSLambdaPowertoolsPythonV2:18 + - arn:aws:lambda:${aws:region}:017000801446:layer:AWSLambdaPowertoolsPythonV2:19 ``` === "CDK" @@ -154,7 +154,7 @@ You can include Powertools Lambda Layer using [AWS Lambda Console](https://docs. powertools_layer = aws_lambda.LayerVersion.from_layer_version_arn( self, id="lambda-powertools", - layer_version_arn=f"arn:aws:lambda:{env.region}:017000801446:layer:AWSLambdaPowertoolsPythonV2:18" + layer_version_arn=f"arn:aws:lambda:{env.region}:017000801446:layer:AWSLambdaPowertoolsPythonV2:19" ) aws_lambda.Function(self, 'sample-app-lambda', @@ -203,7 +203,7 @@ You can include Powertools Lambda Layer using [AWS Lambda Console](https://docs. role = aws_iam_role.iam_for_lambda.arn handler = "index.test" runtime = "python3.9" - layers = ["arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPythonV2:18"] + layers = ["arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPythonV2:19"] source_code_hash = filebase64sha256("lambda_function_payload.zip") } @@ -256,7 +256,7 @@ You can include Powertools Lambda Layer using [AWS Lambda Console](https://docs. ? Do you want to configure advanced settings? Yes ... ? Do you want to enable Lambda layers for this function? Yes - ? Enter up to 5 existing Lambda layer ARNs (comma-separated): arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:18 + ? Enter up to 5 existing Lambda layer ARNs (comma-separated): arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:19 ❯ amplify push -y @@ -267,7 +267,7 @@ You can include Powertools Lambda Layer using [AWS Lambda Console](https://docs. - Name: ? Which setting do you want to update? Lambda layers configuration ? Do you want to enable Lambda layers for this function? Yes - ? Enter up to 5 existing Lambda layer ARNs (comma-separated): arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:18 + ? Enter up to 5 existing Lambda layer ARNs (comma-separated): arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:19 ? Do you want to edit the local lambda function now? No ``` @@ -276,7 +276,7 @@ You can include Powertools Lambda Layer using [AWS Lambda Console](https://docs. Change {region} to your AWS region, e.g. `eu-west-1` ```bash title="AWS CLI" - aws lambda get-layer-version-by-arn --arn arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPythonV2:18 --region {region} + aws lambda get-layer-version-by-arn --arn arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPythonV2:19 --region {region} ``` The pre-signed URL to download this Lambda Layer will be within `Location` key. @@ -291,7 +291,7 @@ You can include Powertools Lambda Layer using [AWS Lambda Console](https://docs. Properties: Architectures: [arm64] Layers: - - !Sub arn:aws:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18 + - !Sub arn:aws:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19 ``` === "Serverless framework" @@ -302,7 +302,7 @@ You can include Powertools Lambda Layer using [AWS Lambda Console](https://docs. handler: lambda_function.lambda_handler architecture: arm64 layers: - - arn:aws:lambda:${aws:region}:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18 + - arn:aws:lambda:${aws:region}:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19 ``` === "CDK" @@ -318,7 +318,7 @@ You can include Powertools Lambda Layer using [AWS Lambda Console](https://docs. powertools_layer = aws_lambda.LayerVersion.from_layer_version_arn( self, id="lambda-powertools", - layer_version_arn=f"arn:aws:lambda:{env.region}:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18" + layer_version_arn=f"arn:aws:lambda:{env.region}:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19" ) aws_lambda.Function(self, 'sample-app-lambda', @@ -368,7 +368,7 @@ You can include Powertools Lambda Layer using [AWS Lambda Console](https://docs. role = aws_iam_role.iam_for_lambda.arn handler = "index.test" runtime = "python3.9" - layers = ["arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18"] + layers = ["arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19"] architectures = ["arm64"] source_code_hash = filebase64sha256("lambda_function_payload.zip") @@ -424,7 +424,7 @@ You can include Powertools Lambda Layer using [AWS Lambda Console](https://docs. ? Do you want to configure advanced settings? Yes ... ? Do you want to enable Lambda layers for this function? Yes - ? Enter up to 5 existing Lambda layer ARNs (comma-separated): arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18 + ? Enter up to 5 existing Lambda layer ARNs (comma-separated): arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19 ❯ amplify push -y @@ -435,7 +435,7 @@ You can include Powertools Lambda Layer using [AWS Lambda Console](https://docs. - Name: ? Which setting do you want to update? Lambda layers configuration ? Do you want to enable Lambda layers for this function? Yes - ? Enter up to 5 existing Lambda layer ARNs (comma-separated): arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18 + ? Enter up to 5 existing Lambda layer ARNs (comma-separated): arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19 ? Do you want to edit the local lambda function now? No ``` @@ -443,7 +443,7 @@ You can include Powertools Lambda Layer using [AWS Lambda Console](https://docs. Change {region} to your AWS region, e.g. `eu-west-1` ```bash title="AWS CLI" - aws lambda get-layer-version-by-arn --arn arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:18 --region {region} + aws lambda get-layer-version-by-arn --arn arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:19 --region {region} ``` The pre-signed URL to download this Lambda Layer will be within `Location` key. 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/event_handler_rest/src/getting_started_return_tuple.py b/examples/event_handler_rest/src/getting_started_return_tuple.py new file mode 100644 index 00000000000..1c26970c1c1 --- /dev/null +++ b/examples/event_handler_rest/src/getting_started_return_tuple.py @@ -0,0 +1,20 @@ +import requests +from requests import Response + +from aws_lambda_powertools.event_handler import ALBResolver +from aws_lambda_powertools.utilities.typing import LambdaContext + +app = ALBResolver() + + +@app.post("/todo") +def create_todo(): + data: dict = app.current_event.json_body + todo: Response = requests.post("https://jsonplaceholder.typicode.com/todos", data=data) + + # Returns the created todo object, with a HTTP 201 Created status + return {"todo": todo.json()}, 201 + + +def lambda_handler(event: dict, context: LambdaContext) -> dict: + return app.resolve(event, context) 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 e492bb83d6e..8b7320b4381 100644 --- a/poetry.lock +++ b/poetry.lock @@ -20,14 +20,14 @@ tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy [[package]] name = "aws-cdk-asset-awscli-v1" -version = "2.2.31" +version = "2.2.49" description = "A library that contains the AWS CLI for use in Lambda Layers" category = "dev" optional = false python-versions = "~=3.7" files = [ - {file = "aws-cdk.asset-awscli-v1-2.2.31.tar.gz", hash = "sha256:c7e01034ae5f128f6853332a61282e51d8112bd975df6897436678bf358eceed"}, - {file = "aws_cdk.asset_awscli_v1-2.2.31-py3-none-any.whl", hash = "sha256:3194fbf956578d85d16cd9bb0541f9011bc5564baa9c4495b47dbe3889165d44"}, + {file = "aws-cdk.asset-awscli-v1-2.2.49.tar.gz", hash = "sha256:d367da8bdc83357792b1ef16b6166d400ef15f2389cf0032b607b6327768a41a"}, + {file = "aws_cdk.asset_awscli_v1-2.2.49-py3-none-any.whl", hash = "sha256:28df4487e2fa5314d5c39c114e12d366714a1fab2de3269d55c4e544876cae44"}, ] [package.dependencies] @@ -110,22 +110,22 @@ typeguard = ">=2.13.3,<2.14.0" [[package]] name = "aws-cdk-lib" -version = "2.59.0" +version = "2.61.1" description = "Version 2 of the AWS Cloud Development Kit library" category = "dev" optional = false python-versions = "~=3.7" files = [ - {file = "aws-cdk-lib-2.59.0.tar.gz", hash = "sha256:1faeced63e37a4caa58472ba368664e3aea935b3193ccbc3f4f55a81d93b59d1"}, - {file = "aws_cdk_lib-2.59.0-py3-none-any.whl", hash = "sha256:0f8718be6951facac8044c286e49c6459a00a833d9d8c56274f27af41ed194c6"}, + {file = "aws-cdk-lib-2.61.1.tar.gz", hash = "sha256:d2bb672be182e0cd675717648fa100e1a49f01de606ffa4f7a0b580093f31b27"}, + {file = "aws_cdk_lib-2.61.1-py3-none-any.whl", hash = "sha256:e0b5d49c73be945ca176b38ff3d2ccfc9474639000afb6af65a1eaf8b6aa3385"}, ] [package.dependencies] -"aws-cdk.asset-awscli-v1" = ">=2.2.30,<3.0.0" +"aws-cdk.asset-awscli-v1" = ">=2.2.49,<3.0.0" "aws-cdk.asset-kubectl-v20" = ">=2.1.1,<3.0.0" "aws-cdk.asset-node-proxy-agent-v5" = ">=2.0.38,<3.0.0" constructs = ">=10.0.0,<11.0.0" -jsii = ">=1.72.0,<2.0.0" +jsii = ">=1.73.0,<2.0.0" publication = ">=0.0.3" typeguard = ">=2.13.3,<2.14.0" @@ -708,13 +708,13 @@ files = [ [[package]] name = "future" -version = "0.18.2" +version = "0.18.3" description = "Clean single-source support for Python 3 and 2" category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" files = [ - {file = "future-0.18.2.tar.gz", hash = "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"}, + {file = "future-0.18.3.tar.gz", hash = "sha256:34a17436ed1e96697a86f9de3d15a3b0be01d8bc8de9c1dffd59fb8234ed5307"}, ] [[package]] @@ -966,14 +966,14 @@ pbr = "*" [[package]] name = "jsii" -version = "1.72.0" +version = "1.73.0" description = "Python client for jsii runtime" category = "dev" optional = false python-versions = "~=3.7" files = [ - {file = "jsii-1.72.0-py3-none-any.whl", hash = "sha256:4dcf65eca9400c15a6a7c9d85b27f4bfe15c96ad3e36272a502f391556858151"}, - {file = "jsii-1.72.0.tar.gz", hash = "sha256:6daf1c17362bd07c50c299e08d9a4454075550eb78e035a160ecf9ea68ded3cf"}, + {file = "jsii-1.73.0-py3-none-any.whl", hash = "sha256:13e8496c3afee70d85401ad1eef2ddedbdb88e7e7abb3e68302dd6e61527191e"}, + {file = "jsii-1.73.0.tar.gz", hash = "sha256:be6458236e787be0b02c2fe869b6f4ed906398b6cc537190d61a60d2b5c9dfbb"}, ] [package.dependencies] @@ -1281,14 +1281,14 @@ mkdocs = ">=0.17" [[package]] name = "mkdocs-material" -version = "9.0.3" +version = "9.0.6" description = "Documentation that simply works" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "mkdocs_material-9.0.3-py3-none-any.whl", hash = "sha256:cedbbf84e156370489907d3c5b79999fcf6563f61a96965ec4c2513d303fa706"}, - {file = "mkdocs_material-9.0.3.tar.gz", hash = "sha256:918fe38f504ca397b388b6c45445c22cb9acab61f00ade78d5f3edf299b6c9df"}, + {file = "mkdocs_material-9.0.6-py3-none-any.whl", hash = "sha256:4a71195ddc100dddf07d4b23b53373f36c5f0f1010fa4ea301ca7a8e949dd9e7"}, + {file = "mkdocs_material-9.0.6.tar.gz", hash = "sha256:6065b573e38746dc267d7fc84252be31b73da955b2ce553687806b6030e51ee0"}, ] [package.dependencies] @@ -1298,7 +1298,7 @@ markdown = ">=3.2" mkdocs = ">=1.4.2" mkdocs-material-extensions = ">=1.1" pygments = ">=2.14" -pymdown-extensions = ">=9.9" +pymdown-extensions = ">=9.9.1" regex = ">=2022.4.24" requests = ">=2.26" @@ -1406,14 +1406,14 @@ typing-extensions = ">=4.1.0" [[package]] name = "mypy-boto3-cloudwatch" -version = "1.26.30" -description = "Type annotations for boto3.CloudWatch 1.26.30 service generated with mypy-boto3-builder 7.12.0" +version = "1.26.52" +description = "Type annotations for boto3.CloudWatch 1.26.52 service generated with mypy-boto3-builder 7.12.3" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "mypy-boto3-cloudwatch-1.26.30.tar.gz", hash = "sha256:2d24b37a6476e19e5e3bba97590b23b33522c418f8963aba5773b2e9c922c6df"}, - {file = "mypy_boto3_cloudwatch-1.26.30-py3-none-any.whl", hash = "sha256:9d96012f3a55ff87ede8645b24a725b56ee8caaa37a7e15772b7d786d73446b8"}, + {file = "mypy-boto3-cloudwatch-1.26.52.tar.gz", hash = "sha256:d77194f2e359a9b17d54aee4de64f1f9502469987ff48fdc94f9f846cd08818b"}, + {file = "mypy_boto3_cloudwatch-1.26.52-py3-none-any.whl", hash = "sha256:412a76f354b637206365d93c6311e5b32cd0e1c9863f3edd5979a55ead4c4dee"}, ] [package.dependencies] @@ -1436,14 +1436,14 @@ typing-extensions = ">=4.1.0" [[package]] name = "mypy-boto3-lambda" -version = "1.26.18" -description = "Type annotations for boto3.Lambda 1.26.18 service generated with mypy-boto3-builder 7.11.11" +version = "1.26.49" +description = "Type annotations for boto3.Lambda 1.26.49 service generated with mypy-boto3-builder 7.12.3" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "mypy-boto3-lambda-1.26.18.tar.gz", hash = "sha256:ad36a98e4bd7c95eb6bf750bc63932367cbed8bbe79bca1fdb7e753e2a689a8b"}, - {file = "mypy_boto3_lambda-1.26.18-py3-none-any.whl", hash = "sha256:8514bf21fe3158c3f555906c2575403b3bbbc3891b3cff5869ec75a7fa8477ce"}, + {file = "mypy-boto3-lambda-1.26.49.tar.gz", hash = "sha256:748222e6dfd602a667b76b9ce0e8c8b31664bc3bd78cc43363fb22ca2885b4c3"}, + {file = "mypy_boto3_lambda-1.26.49-py3-none-any.whl", hash = "sha256:ef346c1fbbc80a907c1d44f19ea9335f8c3889fb48766cb0f7e4439e339fae2b"}, ] [package.dependencies] @@ -1451,14 +1451,14 @@ typing-extensions = ">=4.1.0" [[package]] name = "mypy-boto3-logs" -version = "1.26.43" -description = "Type annotations for boto3.CloudWatchLogs 1.26.43 service generated with mypy-boto3-builder 7.12.2" +version = "1.26.53" +description = "Type annotations for boto3.CloudWatchLogs 1.26.53 service generated with mypy-boto3-builder 7.12.3" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "mypy-boto3-logs-1.26.43.tar.gz", hash = "sha256:fe992d6d973518668151ec6308c59b86ceb4c93eb4b5d3733007ba1d7b5d1aac"}, - {file = "mypy_boto3_logs-1.26.43-py3-none-any.whl", hash = "sha256:47c7a1d4d38f369e2464ff66779cdf60c0dd879d8fe98a5004edae21eda16353"}, + {file = "mypy-boto3-logs-1.26.53.tar.gz", hash = "sha256:9b2d70e9a8f33e5f141ebf4abd3a78c50e80b60152eb400e69a23b5085f50ce0"}, + {file = "mypy_boto3_logs-1.26.53-py3-none-any.whl", hash = "sha256:d40de12136ef71b1effe2e00f5d608c3cd7c89796d830867617ae9e9f3668fc4"}, ] [package.dependencies] @@ -1481,14 +1481,14 @@ typing-extensions = ">=4.1.0" [[package]] name = "mypy-boto3-secretsmanager" -version = "1.26.40" -description = "Type annotations for boto3.SecretsManager 1.26.40 service generated with mypy-boto3-builder 7.12.2" +version = "1.26.49" +description = "Type annotations for boto3.SecretsManager 1.26.49 service generated with mypy-boto3-builder 7.12.3" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "mypy-boto3-secretsmanager-1.26.40.tar.gz", hash = "sha256:278b6c83df97a6db7411a8d0b75e0f49a093b2ba502f9d330ec92656a1b797b7"}, - {file = "mypy_boto3_secretsmanager-1.26.40-py3-none-any.whl", hash = "sha256:6d00a8fac86eca9139d6af05ad5dd61fbbe8de25e6f4e8022e4f2d51a3fb72b8"}, + {file = "mypy-boto3-secretsmanager-1.26.49.tar.gz", hash = "sha256:bd5bda2a8b65bf799793bad67c66bcb2662b182b8632ae1561aecc1bd30ac04d"}, + {file = "mypy_boto3_secretsmanager-1.26.49-py3-none-any.whl", hash = "sha256:2adbf9b5a32975c97479f12bbb5d98ceea30feb6a16012d308eee46fc3dd8b17"}, ] [package.dependencies] @@ -1799,14 +1799,14 @@ plugins = ["importlib-metadata"] [[package]] name = "pymdown-extensions" -version = "9.9" +version = "9.9.1" description = "Extension pack for Python Markdown." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "pymdown_extensions-9.9-py3-none-any.whl", hash = "sha256:ac698c15265680db5eb13cd4342abfcde2079ac01e5486028f47a1b41547b859"}, - {file = "pymdown_extensions-9.9.tar.gz", hash = "sha256:0f8fb7b74a37a61cc34e90b2c91865458b713ec774894ffad64353a5fce85cfc"}, + {file = "pymdown_extensions-9.9.1-py3-none-any.whl", hash = "sha256:8a8973933ab45b6fe8f5f8da1de25766356b1f91dee107bf4a34efd158dc340b"}, + {file = "pymdown_extensions-9.9.1.tar.gz", hash = "sha256:abed29926960bbb3b40f5ed5fa6375e29724d4e3cb86ced7c2bbd37ead1afeea"}, ] [package.dependencies] @@ -1861,14 +1861,14 @@ files = [ [[package]] name = "pytest" -version = "7.2.0" +version = "7.2.1" description = "pytest: simple powerful testing with Python" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"}, - {file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"}, + {file = "pytest-7.2.1-py3-none-any.whl", hash = "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5"}, + {file = "pytest-7.2.1.tar.gz", hash = "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42"}, ] [package.dependencies] @@ -2427,16 +2427,28 @@ 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.7" +version = "2.28.11.8" description = "Typing stubs for requests" category = "dev" optional = false python-versions = "*" files = [ - {file = "types-requests-2.28.11.7.tar.gz", hash = "sha256:0ae38633734990d019b80f5463dfa164ebd3581998ac8435f526da6fe4d598c3"}, - {file = "types_requests-2.28.11.7-py3-none-any.whl", hash = "sha256:b6a2fca8109f4fdba33052f11ed86102bddb2338519e1827387137fefc66a98b"}, + {file = "types-requests-2.28.11.8.tar.gz", hash = "sha256:e67424525f84adfbeab7268a159d3c633862dafae15c5b19547ce1b55954f0a3"}, + {file = "types_requests-2.28.11.8-py3-none-any.whl", hash = "sha256:61960554baca0008ae7e2db2bd3b322ca9a144d3e80ce270f5fb640817e40994"}, ] [package.dependencies] @@ -2653,4 +2665,4 @@ validation = ["fastjsonschema"] [metadata] lock-version = "2.0" python-versions = "^3.7.4" -content-hash = "f0f65f1a63f7a02134227ccc6208b0023b91cb6a51b62c826571f4bb02df4a64" +content-hash = "5b925c7de2441a1193ae75efe1548070b10b71cb99bc4627e5cabef683b05f95" diff --git a/pyproject.toml b/pyproject.toml index 58fe9fa1852..29e6aa21609 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "aws_lambda_powertools" -version = "2.6.0" +version = "2.7.0" description = "A suite of utilities for AWS Lambda functions to ease adopting best practices such as tracing, structured logging, custom metrics, batching, idempotency, feature flags, and more." authors = ["Amazon Web Services"] include = ["aws_lambda_powertools/py.typed", "THIRD-PARTY-LICENSES"] @@ -28,7 +28,7 @@ typing-extensions = "^4.4.0" [tool.poetry.dev-dependencies] coverage = {extras = ["toml"], version = "^7.0"} -pytest = "^7.0.1" +pytest = "^7.2.1" black = "^22.12" boto3 = "^1.18" flake8 = [ @@ -56,24 +56,24 @@ mkdocs-git-revision-date-plugin = "^0.3.2" mike = "^1.1.2" retry = "^0.9.2" pytest-xdist = "^3.1.0" -aws-cdk-lib = "^2.59.0" +aws-cdk-lib = "^2.61.1" "aws-cdk.aws-apigatewayv2-alpha" = "^2.38.1-alpha.0" "aws-cdk.aws-apigatewayv2-integrations-alpha" = "^2.38.1-alpha.0" pytest-benchmark = "^4.0.0" python-snappy = "^0.6.1" mypy-boto3-appconfig = "^1.26.0" mypy-boto3-cloudformation = "^1.26.35" -mypy-boto3-cloudwatch = "^1.26.30" +mypy-boto3-cloudwatch = "^1.26.52" mypy-boto3-dynamodb = "^1.26.24" -mypy-boto3-lambda = "^1.26.12" -mypy-boto3-logs = "^1.26.43" -mypy-boto3-secretsmanager = "^1.26.40" +mypy-boto3-lambda = "^1.26.49" +mypy-boto3-logs = "^1.26.53" +mypy-boto3-secretsmanager = "^1.26.49" mypy-boto3-ssm = "^1.26.43" mypy-boto3-s3 = "^1.26.0" mypy-boto3-xray = "^1.26.11" types-requests = "^2.28.11" typing-extensions = "^4.4.0" -mkdocs-material = "^9.0.3" +mkdocs-material = "^9.0.6" filelock = "^3.9.0" checksumdir = "^1.2.0" mypy-boto3-appconfigdata = "^1.26.0" @@ -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/event_handler/test_api_gateway.py b/tests/functional/event_handler/test_api_gateway.py index cf560fbcc34..ad9f834dbb2 100644 --- a/tests/functional/event_handler/test_api_gateway.py +++ b/tests/functional/event_handler/test_api_gateway.py @@ -1579,3 +1579,39 @@ def get_lambda() -> Response: # AND set the current_event type as APIGatewayProxyEvent assert result["statusCode"] == 200 assert result2["statusCode"] == 200 + + +def test_dict_response(): + # GIVEN a dict is returned + app = ApiGatewayResolver() + + @app.get("/lambda") + def get_message(): + return {"message": "success"} + + # WHEN calling handler + response = app({"httpMethod": "GET", "path": "/lambda"}, None) + + # THEN the body is correctly formatted, the status code is 200 and the content type is json + assert response["statusCode"] == 200 + assert response["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] + response_body = json.loads(response["body"]) + assert response_body["message"] == "success" + + +def test_dict_response_with_status_code(): + # GIVEN a dict is returned with a status code + app = ApiGatewayResolver() + + @app.get("/lambda") + def get_message(): + return {"message": "success"}, 201 + + # WHEN calling handler + response = app({"httpMethod": "GET", "path": "/lambda"}, None) + + # THEN the body is correctly formatted, the status code is 201 and the content type is json + assert response["statusCode"] == 201 + assert response["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] + response_body = json.loads(response["body"]) + assert response_body["message"] == "success" 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