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