From 03f7dcd3442b0193cec19e155eb7b566118e8f9e Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Fri, 19 Feb 2021 17:24:34 +0100 Subject: [PATCH 01/21] feat: Idempotency helper utility (#245) * feat: initial commit for idempotency utility * fix: ensure region is configured in botocore for tests * chore: ignore security warning for md5 usage * chore: add debug logging * feat: add local caching for idempotency lookups * feat: replace simple dict cache with LRU * feat: remove idempotent exception handling * feat: remove unused logic to create ddb table - will handle in documentation instead * fix: remove redundant code from table creation logic * chore: move tests to own dir * chore: remove redundant code for exception handling * feat: add payload validation logic and functionality to use different hash functions from hashlib * feat: optimization to reduce number of database calls, reorganize persistence layer modules * chore: type corrections * chore: add more logging statements * fix: Use variable for ddb attribute name * chore: clarify docstring for abstract method * feat: Refactor to cover corner cases where state changes between calls to db * chore: correct stubbed ddb responses for test case * docs: add first of a few seq diagrams to support documentation * feat: use boto3 session for constructing clients to allow customization of credentials * chore: move cache dict implementation to shared dir * chore: refactor with improvements for readability, variable names, and exception handling * chore: remove dead code, rename variable for clarity, change args to kwargs in function call * chore: improve test coverage, refactor fixtures * chore: skip tests using pytest-mock's spy for python < 3.8 due to issues with lib * chore: update test fixtures to use jmespath * docs: first draft of docs for idempotency util * fix: Allow event_key_jmespath to be left empty to use entire event as payload * docs: add section for compatibility with other utils * chore: improvements to func tests * chore: add unit tests for lru cache * feat: add support for decimals in json serializer * chore: Add docstring for LRU cache dict * chore: Remove unused status constants * chore: Rename method for clarity * chore: Correct example in docstring * fix: make data attribute of data record optional in get_record so we don't throw the wrong error for INPROGRESS * docs: clarify behaviour for concurrent executions and DDB behaviour for large items * Update aws_lambda_powertools/shared/cache_dict.py Co-authored-by: Michael Brewer * Update aws_lambda_powertools/shared/cache_dict.py Co-authored-by: Michael Brewer * Update aws_lambda_powertools/utilities/idempotency/persistence/base.py Co-authored-by: Michael Brewer * chore: add test for invalid status on data record * Update aws_lambda_powertools/utilities/idempotency/persistence/base.py Co-authored-by: Michael Brewer Co-authored-by: Michael Brewer --- aws_lambda_powertools/shared/cache_dict.py | 31 + aws_lambda_powertools/shared/json_encoder.py | 16 + .../utilities/idempotency/__init__.py | 10 + .../utilities/idempotency/exceptions.py | 45 ++ .../utilities/idempotency/idempotency.py | 221 +++++++ .../idempotency/persistence/__init__.py | 0 .../utilities/idempotency/persistence/base.py | 432 +++++++++++++ .../idempotency/persistence/dynamodb.py | 163 +++++ docs/diagram_src/idempotent_sequence.puml | 29 + .../idempotent_sequence_exception.puml | 18 + docs/index.md | 1 + docs/media/idempotent_sequence.png | Bin 0 -> 74622 bytes docs/media/idempotent_sequence_exception.png | Bin 0 -> 46647 bytes docs/utilities/idempotency.md | 362 +++++++++++ mkdocs.yml | 1 + tests/events/apiGatewayProxyV2Event.json | 2 +- tests/functional/idempotency/__init__.py | 0 tests/functional/idempotency/conftest.py | 185 ++++++ .../idempotency/test_idempotency.py | 596 ++++++++++++++++++ tests/unit/test_json_encoder.py | 14 + tests/unit/test_lru_cache.py | 58 ++ 21 files changed, 2183 insertions(+), 1 deletion(-) create mode 100644 aws_lambda_powertools/shared/cache_dict.py create mode 100644 aws_lambda_powertools/shared/json_encoder.py create mode 100644 aws_lambda_powertools/utilities/idempotency/__init__.py create mode 100644 aws_lambda_powertools/utilities/idempotency/exceptions.py create mode 100644 aws_lambda_powertools/utilities/idempotency/idempotency.py create mode 100644 aws_lambda_powertools/utilities/idempotency/persistence/__init__.py create mode 100644 aws_lambda_powertools/utilities/idempotency/persistence/base.py create mode 100644 aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py create mode 100644 docs/diagram_src/idempotent_sequence.puml create mode 100644 docs/diagram_src/idempotent_sequence_exception.puml create mode 100644 docs/media/idempotent_sequence.png create mode 100644 docs/media/idempotent_sequence_exception.png create mode 100644 docs/utilities/idempotency.md create mode 100644 tests/functional/idempotency/__init__.py create mode 100644 tests/functional/idempotency/conftest.py create mode 100644 tests/functional/idempotency/test_idempotency.py create mode 100644 tests/unit/test_json_encoder.py create mode 100644 tests/unit/test_lru_cache.py diff --git a/aws_lambda_powertools/shared/cache_dict.py b/aws_lambda_powertools/shared/cache_dict.py new file mode 100644 index 00000000000..d7184cc1e2b --- /dev/null +++ b/aws_lambda_powertools/shared/cache_dict.py @@ -0,0 +1,31 @@ +from collections import OrderedDict + + +class LRUDict(OrderedDict): + """ + Cache implementation based on ordered dict with a maximum number of items. Last accessed item will be evicted + first. Currently used by idempotency utility. + """ + + def __init__(self, max_items=1024, *args, **kwargs): + self.max_items = max_items + super().__init__(*args, **kwargs) + + def __getitem__(self, key): + value = super().__getitem__(key) + self.move_to_end(key) + return value + + def __setitem__(self, key, value): + if key in self: + self.move_to_end(key) + super().__setitem__(key, value) + if len(self) > self.max_items: + oldest = next(iter(self)) + del self[oldest] + + def get(self, key, *args, **kwargs): + item = super(LRUDict, self).get(key, *args, **kwargs) + if item: + self.move_to_end(key=key) + return item diff --git a/aws_lambda_powertools/shared/json_encoder.py b/aws_lambda_powertools/shared/json_encoder.py new file mode 100644 index 00000000000..32a094abd85 --- /dev/null +++ b/aws_lambda_powertools/shared/json_encoder.py @@ -0,0 +1,16 @@ +import decimal +import json +import math + + +class Encoder(json.JSONEncoder): + """ + Custom JSON encoder to allow for serialization of Decimals, similar to the serializer used by Lambda internally. + """ + + def default(self, obj): + if isinstance(obj, decimal.Decimal): + if obj.is_nan(): + return math.nan + return str(obj) + return super().default(obj) diff --git a/aws_lambda_powertools/utilities/idempotency/__init__.py b/aws_lambda_powertools/utilities/idempotency/__init__.py new file mode 100644 index 00000000000..98e2be15415 --- /dev/null +++ b/aws_lambda_powertools/utilities/idempotency/__init__.py @@ -0,0 +1,10 @@ +""" +Utility for adding idempotency to lambda functions +""" + +from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer +from aws_lambda_powertools.utilities.idempotency.persistence.dynamodb import DynamoDBPersistenceLayer + +from .idempotency import idempotent + +__all__ = ("DynamoDBPersistenceLayer", "BasePersistenceLayer", "idempotent") diff --git a/aws_lambda_powertools/utilities/idempotency/exceptions.py b/aws_lambda_powertools/utilities/idempotency/exceptions.py new file mode 100644 index 00000000000..1d7a8acab1f --- /dev/null +++ b/aws_lambda_powertools/utilities/idempotency/exceptions.py @@ -0,0 +1,45 @@ +""" +Idempotency errors +""" + + +class IdempotencyItemAlreadyExistsError(Exception): + """ + Item attempting to be inserted into persistence store already exists and is not expired + """ + + +class IdempotencyItemNotFoundError(Exception): + """ + Item does not exist in persistence store + """ + + +class IdempotencyAlreadyInProgressError(Exception): + """ + Execution with idempotency key is already in progress + """ + + +class IdempotencyInvalidStatusError(Exception): + """ + An invalid status was provided + """ + + +class IdempotencyValidationError(Exception): + """ + Payload does not match stored idempotency record + """ + + +class IdempotencyInconsistentStateError(Exception): + """ + State is inconsistent across multiple requests to persistence store + """ + + +class IdempotencyPersistenceLayerError(Exception): + """ + Unrecoverable error from the data store + """ diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py new file mode 100644 index 00000000000..bc556f49912 --- /dev/null +++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py @@ -0,0 +1,221 @@ +""" +Primary interface for idempotent Lambda functions utility +""" +import logging +from typing import Any, Callable, Dict, Optional + +from aws_lambda_powertools.middleware_factory import lambda_handler_decorator +from aws_lambda_powertools.utilities.idempotency.exceptions import ( + IdempotencyAlreadyInProgressError, + IdempotencyInconsistentStateError, + IdempotencyItemAlreadyExistsError, + IdempotencyItemNotFoundError, + IdempotencyPersistenceLayerError, + IdempotencyValidationError, +) +from aws_lambda_powertools.utilities.idempotency.persistence.base import ( + STATUS_CONSTANTS, + BasePersistenceLayer, + DataRecord, +) +from aws_lambda_powertools.utilities.typing import LambdaContext + +logger = logging.getLogger(__name__) + + +@lambda_handler_decorator +def idempotent( + handler: Callable[[Any, LambdaContext], Any], + event: Dict[str, Any], + context: LambdaContext, + persistence_store: BasePersistenceLayer, +) -> Any: + """ + Middleware to handle idempotency + + Parameters + ---------- + handler: Callable + Lambda's handler + event: Dict + Lambda's Event + context: Dict + Lambda's Context + persistence_store: BasePersistenceLayer + Instance of BasePersistenceLayer to store data + + Examples + -------- + **Processes Lambda's event in an idempotent manner** + >>> from aws_lambda_powertools.utilities.idempotency import idempotent, DynamoDBPersistenceLayer + >>> + >>> persistence_store = DynamoDBPersistenceLayer(event_key_jmespath="body", table_name="idempotency_store") + >>> + >>> @idempotent(persistence_store=persistence_store) + >>> def handler(event, context): + >>> return {"StatusCode": 200} + """ + + idempotency_handler = IdempotencyHandler(handler, event, context, persistence_store) + + # IdempotencyInconsistentStateError can happen under rare but expected cases when persistent state changes in the + # small time between put & get requests. In most cases we can retry successfully on this exception. + max_handler_retries = 2 + for i in range(max_handler_retries + 1): + try: + return idempotency_handler.handle() + except IdempotencyInconsistentStateError: + if i < max_handler_retries: + continue + else: + # Allow the exception to bubble up after max retries exceeded + raise + + +class IdempotencyHandler: + """ + Class to orchestrate calls to persistence layer. + """ + + def __init__( + self, + lambda_handler: Callable[[Any, LambdaContext], Any], + event: Dict[str, Any], + context: LambdaContext, + persistence_store: BasePersistenceLayer, + ): + """ + Initialize the IdempotencyHandler + + Parameters + ---------- + lambda_handler : Callable[[Any, LambdaContext], Any] + Lambda function handler + event : Dict[str, Any] + Event payload lambda handler will be called with + context : LambdaContext + Context object which will be passed to lambda handler + persistence_store : BasePersistenceLayer + Instance of persistence layer to store idempotency records + """ + self.persistence_store = persistence_store + self.context = context + self.event = event + self.lambda_handler = lambda_handler + self.max_handler_retries = 2 + + def handle(self) -> Any: + """ + Main entry point for handling idempotent execution of lambda handler. + + Returns + ------- + Any + lambda handler response + + """ + try: + # We call save_inprogress first as an optimization for the most common case where no idempotent record + # already exists. If it succeeds, there's no need to call get_record. + self.persistence_store.save_inprogress(event=self.event) + except IdempotencyItemAlreadyExistsError: + # Now we know the item already exists, we can retrieve it + record = self._get_idempotency_record() + return self._handle_for_status(record) + + return self._call_lambda_handler() + + def _get_idempotency_record(self) -> DataRecord: + """ + Retrieve the idempotency record from the persistence layer. + + Raises + ---------- + IdempotencyInconsistentStateError + + """ + try: + event_record = self.persistence_store.get_record(self.event) + except IdempotencyItemNotFoundError: + # This code path will only be triggered if the record is removed between save_inprogress and get_record. + logger.debug( + "An existing idempotency record was deleted before we could retrieve it. Proceeding with lambda " + "handler" + ) + raise IdempotencyInconsistentStateError("save_inprogress and get_record return inconsistent results.") + + # Allow this exception to bubble up + except IdempotencyValidationError: + raise + + # Wrap remaining unhandled exceptions with IdempotencyPersistenceLayerError to ease exception handling for + # clients + except Exception as exc: + raise IdempotencyPersistenceLayerError("Failed to get record from idempotency store") from exc + + return event_record + + def _handle_for_status(self, event_record: DataRecord) -> Optional[Dict[Any, Any]]: + """ + Take appropriate action based on event_record's status + + Parameters + ---------- + event_record: DataRecord + + Returns + ------- + Optional[Dict[Any, Any] + Lambda response previously used for this idempotency key, if it has successfully executed already. + + Raises + ------ + AlreadyInProgressError + A lambda execution is already in progress + IdempotencyInconsistentStateError + The persistence store reports inconsistent states across different requests. Retryable. + """ + # This code path will only be triggered if the record becomes expired between the save_inprogress call and here + if event_record.status == STATUS_CONSTANTS["EXPIRED"]: + raise IdempotencyInconsistentStateError("save_inprogress and get_record return inconsistent results.") + + if event_record.status == STATUS_CONSTANTS["INPROGRESS"]: + raise IdempotencyAlreadyInProgressError( + f"Execution already in progress with idempotency key: " + f"{self.persistence_store.event_key_jmespath}={event_record.idempotency_key}" + ) + + return event_record.response_json_as_dict() + + def _call_lambda_handler(self) -> Any: + """ + Call the lambda handler function and update the persistence store appropriate depending on the output + + Returns + ------- + Any + lambda handler response + + """ + try: + handler_response = self.lambda_handler(self.event, self.context) + except Exception as handler_exception: + # We need these nested blocks to preserve lambda handler exception in case the persistence store operation + # also raises an exception + try: + self.persistence_store.delete_record(event=self.event, exception=handler_exception) + except Exception as delete_exception: + raise IdempotencyPersistenceLayerError( + "Failed to delete record from idempotency store" + ) from delete_exception + raise + + else: + try: + self.persistence_store.save_success(event=self.event, result=handler_response) + except Exception as save_exception: + raise IdempotencyPersistenceLayerError( + "Failed to update record state to success in idempotency store" + ) from save_exception + + return handler_response diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/__init__.py b/aws_lambda_powertools/utilities/idempotency/persistence/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py new file mode 100644 index 00000000000..c9751b0ca12 --- /dev/null +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -0,0 +1,432 @@ +""" +Persistence layers supporting idempotency +""" + +import datetime +import hashlib +import json +import logging +from abc import ABC, abstractmethod +from typing import Any, Dict + +import jmespath + +from aws_lambda_powertools.shared.cache_dict import LRUDict +from aws_lambda_powertools.shared.json_encoder import Encoder +from aws_lambda_powertools.utilities.idempotency.exceptions import ( + IdempotencyInvalidStatusError, + IdempotencyItemAlreadyExistsError, + IdempotencyValidationError, +) + +logger = logging.getLogger(__name__) + +STATUS_CONSTANTS = {"INPROGRESS": "INPROGRESS", "COMPLETED": "COMPLETED", "EXPIRED": "EXPIRED"} + + +class DataRecord: + """ + Data Class for idempotency records. + """ + + def __init__( + self, + idempotency_key, + status: str = "", + expiry_timestamp: int = None, + response_data: str = "", + payload_hash: str = None, + ) -> None: + """ + + Parameters + ---------- + idempotency_key: str + hashed representation of the idempotent data + status: str, optional + status of the idempotent record + expiry_timestamp: int, optional + time before the record should expire, in seconds + payload_hash: str, optional + hashed representation of payload + response_data: str, optional + response data from previous executions using the record + """ + self.idempotency_key = idempotency_key + self.payload_hash = payload_hash + self.expiry_timestamp = expiry_timestamp + self._status = status + self.response_data = response_data + + @property + def is_expired(self) -> bool: + """ + Check if data record is expired + + Returns + ------- + bool + Whether the record is currently expired or not + """ + return bool(self.expiry_timestamp and int(datetime.datetime.now().timestamp()) > self.expiry_timestamp) + + @property + def status(self) -> str: + """ + Get status of data record + + Returns + ------- + str + """ + if self.is_expired: + return STATUS_CONSTANTS["EXPIRED"] + + if self._status in STATUS_CONSTANTS.values(): + return self._status + else: + raise IdempotencyInvalidStatusError(self._status) + + def response_json_as_dict(self) -> dict: + """ + Get response data deserialized to python dict + + Returns + ------- + dict + previous response data deserialized + """ + return json.loads(self.response_data) + + +class BasePersistenceLayer(ABC): + """ + Abstract Base Class for Idempotency persistence layer. + """ + + def __init__( + self, + event_key_jmespath: str = "", + payload_validation_jmespath: str = "", + expires_after_seconds: int = 60 * 60, # 1 hour default + use_local_cache: bool = False, + local_cache_max_items: int = 256, + hash_function: str = "md5", + ) -> None: + """ + Initialize the base persistence layer + + Parameters + ---------- + event_key_jmespath: str + A jmespath expression to extract the idempotency key from the event record + payload_validation_jmespath: str + A jmespath expression to extract the payload to be validated from the event record + expires_after_seconds: int + The number of seconds to wait before a record is expired + use_local_cache: bool, optional + Whether to locally cache idempotency results, by default False + local_cache_max_items: int, optional + Max number of items to store in local cache, by default 1024 + hash_function: str, optional + Function to use for calculating hashes, by default md5. + """ + self.event_key_jmespath = event_key_jmespath + if self.event_key_jmespath: + self.event_key_compiled_jmespath = jmespath.compile(event_key_jmespath) + self.expires_after_seconds = expires_after_seconds + self.use_local_cache = use_local_cache + if self.use_local_cache: + self._cache = LRUDict(max_items=local_cache_max_items) + self.payload_validation_enabled = False + if payload_validation_jmespath: + self.validation_key_jmespath = jmespath.compile(payload_validation_jmespath) + self.payload_validation_enabled = True + self.hash_function = getattr(hashlib, hash_function) + + def _get_hashed_idempotency_key(self, lambda_event: Dict[str, Any]) -> str: + """ + Extract data from lambda event using event key jmespath, and return a hashed representation + + Parameters + ---------- + lambda_event: Dict[str, Any] + Lambda event + + Returns + ------- + str + Hashed representation of the data extracted by the jmespath expression + + """ + data = lambda_event + if self.event_key_jmespath: + data = self.event_key_compiled_jmespath.search(lambda_event) + return self._generate_hash(data) + + def _get_hashed_payload(self, lambda_event: Dict[str, Any]) -> str: + """ + Extract data from lambda event using validation key jmespath, and return a hashed representation + + Parameters + ---------- + lambda_event: Dict[str, Any] + Lambda event + + Returns + ------- + str + Hashed representation of the data extracted by the jmespath expression + + """ + if not self.payload_validation_enabled: + return "" + data = self.validation_key_jmespath.search(lambda_event) + return self._generate_hash(data) + + def _generate_hash(self, data: Any) -> str: + """ + Generate a hash value from the provided data + + Parameters + ---------- + data: Any + The data to hash + + Returns + ------- + str + Hashed representation of the provided data + + """ + hashed_data = self.hash_function(json.dumps(data, cls=Encoder).encode()) + return hashed_data.hexdigest() + + def _validate_payload(self, lambda_event: Dict[str, Any], data_record: DataRecord) -> None: + """ + Validate that the hashed payload matches in the lambda event and stored data record + + Parameters + ---------- + lambda_event: Dict[str, Any] + Lambda event + data_record: DataRecord + DataRecord instance + + Raises + ______ + IdempotencyValidationError + Event payload doesn't match the stored record for the given idempotency key + + """ + if self.payload_validation_enabled: + lambda_payload_hash = self._get_hashed_payload(lambda_event) + if not data_record.payload_hash == lambda_payload_hash: + raise IdempotencyValidationError("Payload does not match stored record for this event key") + + def _get_expiry_timestamp(self) -> int: + """ + + Returns + ------- + int + unix timestamp of expiry date for idempotency record + + """ + now = datetime.datetime.now() + period = datetime.timedelta(seconds=self.expires_after_seconds) + return int((now + period).timestamp()) + + def _save_to_cache(self, data_record: DataRecord): + self._cache[data_record.idempotency_key] = data_record + + def _retrieve_from_cache(self, idempotency_key: str): + cached_record = self._cache.get(idempotency_key) + if cached_record: + if not cached_record.is_expired: + return cached_record + logger.debug(f"Removing expired local cache record for idempotency key: {idempotency_key}") + self._delete_from_cache(idempotency_key) + + def _delete_from_cache(self, idempotency_key: str): + del self._cache[idempotency_key] + + def save_success(self, event: Dict[str, Any], result: dict) -> None: + """ + Save record of function's execution completing succesfully + + Parameters + ---------- + event: Dict[str, Any] + Lambda event + result: dict + The response from lambda handler + """ + response_data = json.dumps(result, cls=Encoder) + + data_record = DataRecord( + idempotency_key=self._get_hashed_idempotency_key(event), + status=STATUS_CONSTANTS["COMPLETED"], + expiry_timestamp=self._get_expiry_timestamp(), + response_data=response_data, + payload_hash=self._get_hashed_payload(event), + ) + logger.debug( + f"Lambda successfully executed. Saving record to persistence store with " + f"idempotency key: {data_record.idempotency_key}" + ) + self._update_record(data_record=data_record) + + if self.use_local_cache: + self._save_to_cache(data_record) + + def save_inprogress(self, event: Dict[str, Any]) -> None: + """ + Save record of function's execution being in progress + + Parameters + ---------- + event: Dict[str, Any] + Lambda event + """ + data_record = DataRecord( + idempotency_key=self._get_hashed_idempotency_key(event), + status=STATUS_CONSTANTS["INPROGRESS"], + expiry_timestamp=self._get_expiry_timestamp(), + payload_hash=self._get_hashed_payload(event), + ) + + logger.debug(f"Saving in progress record for idempotency key: {data_record.idempotency_key}") + + if self.use_local_cache: + cached_record = self._retrieve_from_cache(idempotency_key=data_record.idempotency_key) + if cached_record: + raise IdempotencyItemAlreadyExistsError + + self._put_record(data_record) + + # This has to come after _put_record. If _put_record call raises ItemAlreadyExists we shouldn't populate the + # cache with an "INPROGRESS" record as we don't know the status in the data store at this point. + if self.use_local_cache: + self._save_to_cache(data_record) + + def delete_record(self, event: Dict[str, Any], exception: Exception): + """ + Delete record from the persistence store + + Parameters + ---------- + event: Dict[str, Any] + Lambda event + exception + The exception raised by the lambda handler + """ + data_record = DataRecord(idempotency_key=self._get_hashed_idempotency_key(event)) + + logger.debug( + f"Lambda raised an exception ({type(exception).__name__}). Clearing in progress record in persistence " + f"store for idempotency key: {data_record.idempotency_key}" + ) + self._delete_record(data_record) + + if self.use_local_cache: + self._delete_from_cache(data_record.idempotency_key) + + def get_record(self, event: Dict[str, Any]) -> DataRecord: + """ + Calculate idempotency key for lambda_event, then retrieve item from persistence store using idempotency key + and return it as a DataRecord instance.and return it as a DataRecord instance. + + Parameters + ---------- + event: Dict[str, Any] + + Returns + ------- + DataRecord + DataRecord representation of existing record found in persistence store + + Raises + ------ + IdempotencyItemNotFoundError + Exception raised if no record exists in persistence store with the idempotency key + IdempotencyValidationError + Event payload doesn't match the stored record for the given idempotency key + """ + + idempotency_key = self._get_hashed_idempotency_key(event) + + if self.use_local_cache: + cached_record = self._retrieve_from_cache(idempotency_key=idempotency_key) + if cached_record: + logger.debug(f"Idempotency record found in cache with idempotency key: {idempotency_key}") + self._validate_payload(event, cached_record) + return cached_record + + record = self._get_record(idempotency_key) + + if self.use_local_cache: + self._save_to_cache(data_record=record) + + self._validate_payload(event, record) + return record + + @abstractmethod + def _get_record(self, idempotency_key) -> DataRecord: + """ + Retrieve item from persistence store using idempotency key and return it as a DataRecord instance. + + Parameters + ---------- + idempotency_key + + Returns + ------- + DataRecord + DataRecord representation of existing record found in persistence store + + Raises + ------ + IdempotencyItemNotFoundError + Exception raised if no record exists in persistence store with the idempotency key + """ + raise NotImplementedError + + @abstractmethod + def _put_record(self, data_record: DataRecord) -> None: + """ + Add a DataRecord to persistence store if it does not already exist with that key. Raise ItemAlreadyExists + if a non-expired entry already exists. + + Parameters + ---------- + data_record: DataRecord + DataRecord instance + """ + + raise NotImplementedError + + @abstractmethod + def _update_record(self, data_record: DataRecord) -> None: + """ + Update item in persistence store + + Parameters + ---------- + data_record: DataRecord + DataRecord instance + """ + + raise NotImplementedError + + @abstractmethod + def _delete_record(self, data_record: DataRecord) -> None: + """ + Remove item from persistence store + Parameters + ---------- + data_record: DataRecord + DataRecord instance + """ + + raise NotImplementedError diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py new file mode 100644 index 00000000000..4d66448755d --- /dev/null +++ b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py @@ -0,0 +1,163 @@ +import datetime +import logging +from typing import Any, Dict, Optional + +import boto3 +from botocore.config import Config + +from aws_lambda_powertools.utilities.idempotency import BasePersistenceLayer +from aws_lambda_powertools.utilities.idempotency.exceptions import ( + IdempotencyItemAlreadyExistsError, + IdempotencyItemNotFoundError, +) +from aws_lambda_powertools.utilities.idempotency.persistence.base import DataRecord + +logger = logging.getLogger(__name__) + + +class DynamoDBPersistenceLayer(BasePersistenceLayer): + def __init__( + self, + table_name: str, + key_attr: str = "id", + expiry_attr: str = "expiration", + status_attr: str = "status", + data_attr: str = "data", + validation_key_attr: str = "validation", + boto_config: Optional[Config] = None, + boto3_session: Optional[boto3.session.Session] = None, + *args, + **kwargs, + ): + """ + Initialize the DynamoDB client + + Parameters + ---------- + table_name: str + Name of the table to use for storing execution records + key_attr: str, optional + DynamoDB attribute name for key, by default "id" + expiry_attr: str, optional + DynamoDB attribute name for expiry timestamp, by default "expiration" + status_attr: str, optional + DynamoDB attribute name for status, by default "status" + data_attr: str, optional + DynamoDB attribute name for response data, by default "data" + boto_config: botocore.config.Config, optional + Botocore configuration to pass during client initialization + boto3_session : boto3.session.Session, optional + Boto3 session to use for AWS API communication + + args + kwargs + + Examples + -------- + **Create a DynamoDB persistence layer with custom settings** + >>> from aws_lambda_powertools.utilities.idempotency import idempotent, DynamoDBPersistenceLayer + >>> + >>> persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name="idempotency_store") + >>> + >>> @idempotent(persistence_store=persistence_store) + >>> def handler(event, context): + >>> return {"StatusCode": 200} + """ + + boto_config = boto_config or Config() + session = boto3_session or boto3.session.Session() + self._ddb_resource = session.resource("dynamodb", config=boto_config) + self.table_name = table_name + self.table = self._ddb_resource.Table(self.table_name) + self.key_attr = key_attr + self.expiry_attr = expiry_attr + self.status_attr = status_attr + self.data_attr = data_attr + self.validation_key_attr = validation_key_attr + super(DynamoDBPersistenceLayer, self).__init__(*args, **kwargs) + + def _item_to_data_record(self, item: Dict[str, Any]) -> DataRecord: + """ + Translate raw item records from DynamoDB to DataRecord + + Parameters + ---------- + item: Dict[str, Union[str, int]] + Item format from dynamodb response + + Returns + ------- + DataRecord + representation of item + + """ + return DataRecord( + idempotency_key=item[self.key_attr], + status=item[self.status_attr], + expiry_timestamp=item[self.expiry_attr], + response_data=item.get(self.data_attr), + payload_hash=item.get(self.validation_key_attr), + ) + + def _get_record(self, idempotency_key) -> DataRecord: + response = self.table.get_item(Key={self.key_attr: idempotency_key}, ConsistentRead=True) + + try: + item = response["Item"] + except KeyError: + raise IdempotencyItemNotFoundError + return self._item_to_data_record(item) + + def _put_record(self, data_record: DataRecord) -> None: + item = { + self.key_attr: data_record.idempotency_key, + self.expiry_attr: data_record.expiry_timestamp, + self.status_attr: data_record.status, + } + + if self.payload_validation_enabled: + item[self.validation_key_attr] = data_record.payload_hash + + now = datetime.datetime.now() + try: + logger.debug(f"Putting record for idempotency key: {data_record.idempotency_key}") + self.table.put_item( + Item=item, + ConditionExpression=f"attribute_not_exists({self.key_attr}) OR {self.expiry_attr} < :now", + ExpressionAttributeValues={":now": int(now.timestamp())}, + ) + except self._ddb_resource.meta.client.exceptions.ConditionalCheckFailedException: + logger.debug(f"Failed to put record for already existing idempotency key: {data_record.idempotency_key}") + raise IdempotencyItemAlreadyExistsError + + def _update_record(self, data_record: DataRecord): + logger.debug(f"Updating record for idempotency key: {data_record.idempotency_key}") + update_expression = "SET #response_data = :response_data, #expiry = :expiry, #status = :status" + expression_attr_values = { + ":expiry": data_record.expiry_timestamp, + ":response_data": data_record.response_data, + ":status": data_record.status, + } + expression_attr_names = { + "#response_data": self.data_attr, + "#expiry": self.expiry_attr, + "#status": self.status_attr, + } + + if self.payload_validation_enabled: + update_expression += ", #validation_key = :validation_key" + expression_attr_values[":validation_key"] = data_record.payload_hash + expression_attr_names["#validation_key"] = self.validation_key_attr + + kwargs = { + "Key": {self.key_attr: data_record.idempotency_key}, + "UpdateExpression": update_expression, + "ExpressionAttributeValues": expression_attr_values, + "ExpressionAttributeNames": expression_attr_names, + } + + self.table.update_item(**kwargs) + + def _delete_record(self, data_record: DataRecord) -> None: + logger.debug(f"Deleting record for idempotency key: {data_record.idempotency_key}") + self.table.delete_item(Key={self.key_attr: data_record.idempotency_key},) diff --git a/docs/diagram_src/idempotent_sequence.puml b/docs/diagram_src/idempotent_sequence.puml new file mode 100644 index 00000000000..76c85942796 --- /dev/null +++ b/docs/diagram_src/idempotent_sequence.puml @@ -0,0 +1,29 @@ +@startuml +'https://plantuml.com/sequence-diagram + +participant Client +participant Lambda +participant "Persistence layer" + + +group initial request +Client->Lambda:Invoke (event) +Lambda->"Persistence layer":Get or set (id=event.search(payload)) +activate "Persistence layer" +note right of "Persistence layer":Locked during this time. Prevents \nmultiple Lambda invocations with the \nsame payload running concurrently. +Lambda-->Lambda:Run Lambda handler (event) +Lambda->"Persistence layer":Update record with Lambda handler result¹ +deactivate "Persistence layer" +"Persistence layer"-->"Persistence layer": Update record with result¹ +Client x<--Lambda:Response not received by client +end + +group retried request + +Client->Lambda: Invoke (event) +Lambda->"Persistence layer":Get or set (id=event.search(payload)) +Lambda<--"Persistence layer":Already exists in persistence layer. Return result¹ +Client<--Lambda:Response sent to client +end + +@enduml diff --git a/docs/diagram_src/idempotent_sequence_exception.puml b/docs/diagram_src/idempotent_sequence_exception.puml new file mode 100644 index 00000000000..7470cdd1c4e --- /dev/null +++ b/docs/diagram_src/idempotent_sequence_exception.puml @@ -0,0 +1,18 @@ +@startuml +'https://plantuml.com/sequence-diagram + +participant Client +participant Lambda +participant "Persistence layer" + + +Client->Lambda:Invoke (event) +Lambda->"Persistence layer":Get or set (id=event.search(payload)) +activate "Persistence layer" +note right of "Persistence layer":Locked during this time. Prevents \nmultiple Lambda invocations with the \nsame payload running concurrently. +Lambda-->x Lambda:Run Lambda handler (event). Raises Exception. +Lambda->"Persistence layer":Delete record (id=event.search(payload)) +deactivate "Persistence layer" +Client<--Lambda:Return error response + +@enduml diff --git a/docs/index.md b/docs/index.md index 2415a668ec1..cddf3182695 100644 --- a/docs/index.md +++ b/docs/index.md @@ -152,6 +152,7 @@ aws serverlessrepo list-application-versions \ | [Validation](./utilities/validation) | JSON Schema validator for inbound events and responses | [Event source data classes](./utilities/data_classes) | Data classes describing the schema of common Lambda event triggers | [Parser](./utilities/parser) | Data parsing and deep validation using Pydantic +| [Idempotency](./utilities/idempotency) | Idempotent Lambda handler ## Environment variables diff --git a/docs/media/idempotent_sequence.png b/docs/media/idempotent_sequence.png new file mode 100644 index 0000000000000000000000000000000000000000..92593184abbfb79cc673af07bfbb019bf6c06812 GIT binary patch literal 74622 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�W*NJiVcf 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 literal 0 HcmV?d00001 diff --git a/docs/media/idempotent_sequence_exception.png b/docs/media/idempotent_sequence_exception.png new file mode 100644 index 0000000000000000000000000000000000000000..4cf065993dd0ca3818941e1e80af17f4f7ed56f7 GIT binary patch literal 46647 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 literal 0 HcmV?d00001 diff --git a/docs/utilities/idempotency.md b/docs/utilities/idempotency.md new file mode 100644 index 00000000000..1c6555088d9 --- /dev/null +++ b/docs/utilities/idempotency.md @@ -0,0 +1,362 @@ +--- +title: Idempotency +description: Utility +--- + +This utility provides a simple solution to convert your Lambda functions into idempotent operations which are safe to +retry. + +## Terminology + +The property of idempotency means that an operation does not cause additional side effects if it is called more than +once with the same input parameters. Idempotent operations will return the same result when they are called multiple +times with the same parameters. This makes idempotent operations safe to retry. + + +## Key features + +* Prevent Lambda handler code executing more than once on the same event payload during a time window +* Ensure Lambda handler returns the same result when called with the same payload +* Select a subset of the event as the idempotency key using JMESpath expressions +* Set a time window in which records with the same payload should be considered duplicates + +## Getting started + +### Required resources + +Before getting started, you need to create a persistent storage layer where the idempotency utility can store its +state. Your lambda functions will need read and write access to it. DynamoDB is currently the only supported persistent +storage layer, so you'll need to create a table first. + +> Example using AWS Serverless Application Model (SAM) + +=== "template.yml" +```yaml +Resources: + HelloWorldFunction: + Type: AWS::Serverless::Function + Properties: + Runtime: python3.8 + ... + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref IdempotencyTable + IdempotencyTable: + Type: AWS::DynamoDB::Table + Properties: + AttributeDefinitions: + - AttributeName: id + AttributeType: S + BillingMode: PAY_PER_REQUEST + KeySchema: + - AttributeName: id + KeyType: HASH + TableName: "IdempotencyTable" + TimeToLiveSpecification: + AttributeName: expiration + Enabled: true +``` + +!!! warning + When using this utility with DynamoDB, your lambda responses must always be smaller than 400kb. Larger items cannot + be written to DynamoDB and will cause exceptions. + +!!! info + Each function invocation will generally make 2 requests to DynamoDB. If the + result returned by your Lambda is less than 1kb, you can expect 2 WCUs per invocation. For retried invocations, you will + see 1WCU and 1RCU. Review the [DynamoDB pricing documentation](https://aws.amazon.com/dynamodb/pricing/) to + estimate the cost. + +### Lambda handler + +You can quickly start by initializing the `DynamoDBPersistenceLayer` class outside the Lambda handler, and using it +with the `idempotent` decorator on your lambda handler. The only required parameter is `table_name`, but you likely +want to specify `event_key_jmespath` as well. + +`event_key_jmespath`: A JMESpath expression which will be used to extract the payload from the event your Lambda hander +is called with. This payload will be used as the key to decide if future invocations are duplicates. If you don't pass +this parameter, the entire event will be used as the key. + +=== "app.py" + + ```python hl_lines="2 6-9 11" + import json + from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent + + # Treat everything under the "body" key in + # the event json object as our payload + persistence_layer = DynamoDBPersistenceLayer( + event_key_jmespath="body", + table_name="IdempotencyTable" + ) + + @idempotent(persistence_store=persistence_layer) + def handler(event, context): + body = json.loads(event['body']) + payment = create_subscription_payment( + user=body['user'], + product=body['product_id'] + ) + ... + return {"message": "success", "statusCode": 200, "payment_id": payment.id} + ``` +=== "Example event" + + ```json + { + "version":"2.0", + "routeKey":"ANY /createpayment", + "rawPath":"/createpayment", + "rawQueryString":"", + "headers": { + "Header1": "value1", + "Header2": "value2" + }, + "requestContext":{ + "accountId":"123456789012", + "apiId":"api-id", + "domainName":"id.execute-api.us-east-1.amazonaws.com", + "domainPrefix":"id", + "http":{ + "method":"POST", + "path":"/createpayment", + "protocol":"HTTP/1.1", + "sourceIp":"ip", + "userAgent":"agent" + }, + "requestId":"id", + "routeKey":"ANY /createpayment", + "stage":"$default", + "time":"10/Feb/2021:13:40:43 +0000", + "timeEpoch":1612964443723 + }, + "body":"{\"username\":\"xyz\",\"product_id\":\"123456789\"}", + "isBase64Encoded":false + } + ``` + +In this example, we have a Lambda handler that creates a payment for a user subscribing to a product. We want to ensure +that we don't accidentally charge our customer by subscribing them more than once. Imagine the function executes +successfully, but the client never receives the response. When we're using the idempotent decorator, we can safely +retry. This sequence diagram shows an example flow of what happens in this case: + +![Idempotent sequence](../media/idempotent_sequence.png) + + +The client was successful in receiving the result after the retry. Since the Lambda handler was only executed once, our +customer hasn't been charged twice. + +!!! note + Bear in mind that the entire Lambda handler is treated as a single idempotent operation. If your Lambda handler can + cause multiple side effects, consider splitting it into separate functions. + +### Handling exceptions + +If your Lambda handler raises an unhandled exception, the record in the persistence layer will be deleted. This means +that if the client retries, your Lambda handler will be free to execute again. If you don't want the record to be +deleted, you need to catch Exceptions within the handler and return a successful response. + + +![Idempotent sequence exception](../media/idempotent_sequence_exception.png) + +!!! warning + If any of the calls to the persistence layer unexpectedly fail, `IdempotencyPersistenceLayerError` will be raised. + As this happens outside the scope of your Lambda handler, you are not able to catch it. + +### Setting a time window +In most cases, it is not desirable to store the idempotency records forever. Rather, you want to guarantee that the +same payload won't be executed within a period of time. By default, the period is set to 1 hour (3600 seconds). You can +change this window with the `expires_after_seconds` parameter: + +```python hl_lines="4" +DynamoDBPersistenceLayer( + event_key_jmespath="body", + table_name="IdempotencyTable", + expires_after_seconds=5*60 # 5 minutes + ) + +``` +This will mark any records older than 5 minutes as expired, and the lambda handler will be executed as normal if it is +invoked with a matching payload. If you have set the TTL field in DynamoDB like in the SAM example above, the record +will be automatically deleted from the table after a period of itme. + + +### Handling concurrent executions +If you invoke a Lambda function with a given payload, then try to invoke it again with the same payload before the +first invocation has finished, we'll raise an `IdempotencyAlreadyInProgressError` exception. This is the utility's +locking mechanism at work. Since we don't know the result from the first invocation yet, we can't safely allow another +concurrent execution. If you receive this error, you can safely retry the operation. + + +### Using local cache +To reduce the number of lookups to the persistence storage layer, you can enable in memory caching with the +`use_local_cache` parameter, which is disabled by default. This cache is local to each Lambda execution environment. +This means it will be effective in cases where your function's concurrency is low in comparison to the number of +"retry" invocations with the same payload. When enabled, the default is to cache a maxmum of 256 records in each Lambda +execution environment. You can change this with the `local_cache_max_items` parameter. + +```python hl_lines="4 5" +DynamoDBPersistenceLayer( + event_key_jmespath="body", + table_name="IdempotencyTable", + use_local_cache=True, + local_cache_max_items=1000 + ) +``` + + +## Advanced + +### Payload validation +What happens if lambda is invoked with a payload that it has seen before, but some parameters which are not part of the +payload have changed? By default, lambda will return the same result as it returned before, which may be misleading. +Payload validation provides a solution to that. You can provide another JMESpath expression to the persistence store +with the `payload_validation_jmespath` to specify which part of the event body should be validated against previous +idempotent invocations. + +=== "app.py" + ```python hl_lines="6" + from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent + + persistence_layer = DynamoDBPersistenceLayer( + event_key_jmespath="[userDetail, productId]", + table_name="IdempotencyTable",) + payload_validation_jmespath="amount" + ) + + @idempotent(persistence_store=persistence_layer) + def handler(event, context): + # Creating a subscription payment is a side + # effect of calling this function! + payment = create_subscription_payment( + user=event['userDetail']['username'], + product=event['product_id'], + amount=event['amount'] + ) + ... + return {"message": "success", "statusCode": 200, + "payment_id": payment.id, "amount": payment.amount} + ``` +=== "Event" + ```json + { + "userDetail": { + "username": "User1", + "user_email": "user@example.com" + }, + "productId": 1500, + "charge_type": "subscription", + "amount": 500 + } + ``` + +In this example, the "userDetail" and "productId" keys are used as the payload to generate the idempotency key. If +we try to send the same request but with a different amount, Lambda will raise `IdempotencyValidationError`. Without +payload validation, we would have returned the same result as we did for the initial request. Since we're also +returning an amount in the response, this could be quite confusing for the client. By using payload validation on the +amount field, we prevent this potentially confusing behaviour and instead raise an Exception. + +### Changing dynamoDB attribute names +If you want to use an existing DynamoDB table, or wish to change the name of the attributes used to store items in the +table, you can do so when you construct the `DynamoDBPersistenceLayer` instance. + + +Parameter | Default value | Description +------------------- |--------------- | ------------ +key_attr | "id" | Primary key of the table. Hashed representation of the payload +expiry_attr | "expiration" | Unix timestamp of when record expires +status_attr | "status" | Stores status of the lambda execution during and after invocation +data_attr | "data" | Stores results of successfully executed Lambda handlers +validation_key_attr | "validation" | Hashed representation of the parts of the event used for validation + +This example demonstrates changing the attribute names to custom values: + +=== "app.py" + ```python hl_lines="5-10" + persistence_layer = DynamoDBPersistenceLayer( + event_key_jmespath="[userDetail, productId]", + table_name="IdempotencyTable",) + key_attr="idempotency_key", + expiry_attr="expires_at", + status_attr="current_status", + data_attr="result_data", + validation_key_attr="validation_key" + ) + ``` + +### Customizing boto configuration +You can provide custom boto configuration or event bring your own boto3 session if required by using the `boto_config` +or `boto3_session` parameters when constructing the persistence store. + +=== "Custom session" + ```python hl_lines="1 4 8" + import boto3 + from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent + + boto3_session = boto3.session.Session() + persistence_layer = DynamoDBPersistenceLayer( + event_key_jmespath="body", + table_name="IdempotencyTable", + boto3_session=boto3_session + ) + + @idempotent(persistence_store=persistence_layer) + def handler(event, context): + ... + ``` +=== "Custom config" + ```python hl_lines="1 4 8" + from botocore.config import Config + from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent + + boto_config = Config() + persistence_layer = DynamoDBPersistenceLayer( + event_key_jmespath="body", + table_name="IdempotencyTable", + boto_config=boto_config + ) + + @idempotent(persistence_store=persistence_layer) + def handler(event, context): + ... + ``` + +### Bring your own persistent store + +The utility provides an abstract base class which can be used to implement your choice of persistent storage layers. +You can inherit from the `BasePersistenceLayer` class and implement the abstract methods `_get_record`, `_put_record`, +`_update_record` and `_delete_record`. Pay attention to the documentation for each - you may need to perform additional +checks inside these methods to ensure the idempotency guarantees remain intact. For example, the `_put_record` method +needs to raise an exception if a non-expired record already exists in the data store with a matching key. + +## Compatibility with other utilities + +### Validation utility + +The idempotency utility can be used with the `validator` decorator. Ensure that idempotency is the innermost decorator. + +!!! warning + If you use an envelope with the validator, the event received by the idempotency utility will be the unwrapped + event - not the "raw" event Lambda was invoked with. You will need to account for this if you set the + `event_key_jmespath`. + +=== "app.py" + ```python hl_lines="9 10" + from aws_lambda_powertools.utilities.validation import validator, envelopes + from aws_lambda_powertools.utilities.idempotency.idempotency import idempotent + + persistence_layer = DynamoDBPersistenceLayer( + event_key_jmespath="[message, username]", + table_name="IdempotencyTable", + ) + + @validator(envelope=envelopes.API_GATEWAY_HTTP) + @idempotent(persistence_store=persistence_layer) + def lambda_handler(event, context): + cause_some_side_effects(event['username') + return {"message": event['message'], "statusCode": 200} + ``` + +## Extra resources +If you're interested in a deep dive on how Amazon uses idempotency when building our APIs, check out +[this article](https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/). diff --git a/mkdocs.yml b/mkdocs.yml index 0298a4864a6..e4c91ca7917 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -17,6 +17,7 @@ nav: - utilities/validation.md - utilities/data_classes.md - utilities/parser.md + - utilities/idempotency.md theme: name: material diff --git a/tests/events/apiGatewayProxyV2Event.json b/tests/events/apiGatewayProxyV2Event.json index 9c310e6d52f..4d0cfdf5703 100644 --- a/tests/events/apiGatewayProxyV2Event.json +++ b/tests/events/apiGatewayProxyV2Event.json @@ -45,7 +45,7 @@ "time": "12/Mar/2020:19:03:58 +0000", "timeEpoch": 1583348638390 }, - "body": "Hello from Lambda", + "body": "{\"message\": \"hello world\", \"username\": \"tom\"}", "pathParameters": { "parameter1": "value1" }, diff --git a/tests/functional/idempotency/__init__.py b/tests/functional/idempotency/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/functional/idempotency/conftest.py b/tests/functional/idempotency/conftest.py new file mode 100644 index 00000000000..918eac9a507 --- /dev/null +++ b/tests/functional/idempotency/conftest.py @@ -0,0 +1,185 @@ +import datetime +import hashlib +import json +import os +from decimal import Decimal +from unittest import mock + +import jmespath +import pytest +from botocore import stub +from botocore.config import Config + +from aws_lambda_powertools.shared.json_encoder import Encoder +from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer +from aws_lambda_powertools.utilities.validation import envelopes +from aws_lambda_powertools.utilities.validation.base import unwrap_event_from_envelope + +TABLE_NAME = "TEST_TABLE" + + +@pytest.fixture(scope="module") +def config() -> Config: + return Config(region_name="us-east-1") + + +@pytest.fixture(scope="module") +def lambda_apigw_event(): + full_file_name = os.path.dirname(os.path.realpath(__file__)) + "/../../events/" + "apiGatewayProxyV2Event.json" + with open(full_file_name) as fp: + event = json.load(fp) + + return event + + +@pytest.fixture +def timestamp_future(): + return str(int((datetime.datetime.now() + datetime.timedelta(seconds=3600)).timestamp())) + + +@pytest.fixture +def timestamp_expired(): + now = datetime.datetime.now() + period = datetime.timedelta(seconds=6400) + return str(int((now - period).timestamp())) + + +@pytest.fixture(scope="module") +def lambda_response(): + return {"message": "test", "statusCode": 200, "decimal_val": Decimal("2.5"), "decimal_NaN": Decimal("NaN")} + + +@pytest.fixture(scope="module") +def serialized_lambda_response(lambda_response): + return json.dumps(lambda_response, cls=Encoder) + + +@pytest.fixture(scope="module") +def deserialized_lambda_response(lambda_response): + return json.loads(json.dumps(lambda_response, cls=Encoder)) + + +@pytest.fixture +def default_jmespath(): + return "[body, queryStringParameters]" + + +@pytest.fixture +def expected_params_update_item(serialized_lambda_response, hashed_idempotency_key): + return { + "ExpressionAttributeNames": {"#expiry": "expiration", "#response_data": "data", "#status": "status"}, + "ExpressionAttributeValues": { + ":expiry": stub.ANY, + ":response_data": serialized_lambda_response, + ":status": "COMPLETED", + }, + "Key": {"id": hashed_idempotency_key}, + "TableName": "TEST_TABLE", + "UpdateExpression": "SET #response_data = :response_data, " "#expiry = :expiry, #status = :status", + } + + +@pytest.fixture +def expected_params_update_item_with_validation( + serialized_lambda_response, hashed_idempotency_key, hashed_validation_key +): + return { + "ExpressionAttributeNames": { + "#expiry": "expiration", + "#response_data": "data", + "#status": "status", + "#validation_key": "validation", + }, + "ExpressionAttributeValues": { + ":expiry": stub.ANY, + ":response_data": serialized_lambda_response, + ":status": "COMPLETED", + ":validation_key": hashed_validation_key, + }, + "Key": {"id": hashed_idempotency_key}, + "TableName": "TEST_TABLE", + "UpdateExpression": "SET #response_data = :response_data, " + "#expiry = :expiry, #status = :status, " + "#validation_key = :validation_key", + } + + +@pytest.fixture +def expected_params_put_item(hashed_idempotency_key): + return { + "ConditionExpression": "attribute_not_exists(id) OR expiration < :now", + "ExpressionAttributeValues": {":now": stub.ANY}, + "Item": {"expiration": stub.ANY, "id": hashed_idempotency_key, "status": "INPROGRESS"}, + "TableName": "TEST_TABLE", + } + + +@pytest.fixture +def expected_params_put_item_with_validation(hashed_idempotency_key, hashed_validation_key): + return { + "ConditionExpression": "attribute_not_exists(id) OR expiration < :now", + "ExpressionAttributeValues": {":now": stub.ANY}, + "Item": { + "expiration": stub.ANY, + "id": hashed_idempotency_key, + "status": "INPROGRESS", + "validation": hashed_validation_key, + }, + "TableName": "TEST_TABLE", + } + + +@pytest.fixture +def hashed_idempotency_key(lambda_apigw_event, default_jmespath): + compiled_jmespath = jmespath.compile(default_jmespath) + data = compiled_jmespath.search(lambda_apigw_event) + return hashlib.md5(json.dumps(data).encode()).hexdigest() + + +@pytest.fixture +def hashed_idempotency_key_with_envelope(lambda_apigw_event): + event = unwrap_event_from_envelope( + data=lambda_apigw_event, envelope=envelopes.API_GATEWAY_HTTP, jmespath_options={} + ) + return hashlib.md5(json.dumps(event).encode()).hexdigest() + + +@pytest.fixture +def hashed_validation_key(lambda_apigw_event): + return hashlib.md5(json.dumps(lambda_apigw_event["requestContext"]).encode()).hexdigest() + + +@pytest.fixture +def persistence_store(config, request, default_jmespath): + persistence_store = DynamoDBPersistenceLayer( + event_key_jmespath=default_jmespath, + table_name=TABLE_NAME, + boto_config=config, + use_local_cache=request.param["use_local_cache"], + ) + return persistence_store + + +@pytest.fixture +def persistence_store_without_jmespath(config, request): + persistence_store = DynamoDBPersistenceLayer( + table_name=TABLE_NAME, boto_config=config, use_local_cache=request.param["use_local_cache"], + ) + return persistence_store + + +@pytest.fixture +def persistence_store_with_validation(config, request, default_jmespath): + persistence_store = DynamoDBPersistenceLayer( + event_key_jmespath=default_jmespath, + table_name=TABLE_NAME, + boto_config=config, + use_local_cache=request.param, + payload_validation_jmespath="requestContext", + ) + return persistence_store + + +@pytest.fixture +def mock_function(): + return mock.MagicMock() diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py new file mode 100644 index 00000000000..e6e64e3b38b --- /dev/null +++ b/tests/functional/idempotency/test_idempotency.py @@ -0,0 +1,596 @@ +import copy +import sys + +import pytest +from botocore import stub + +from aws_lambda_powertools.utilities.idempotency.exceptions import ( + IdempotencyAlreadyInProgressError, + IdempotencyInconsistentStateError, + IdempotencyInvalidStatusError, + IdempotencyPersistenceLayerError, + IdempotencyValidationError, +) +from aws_lambda_powertools.utilities.idempotency.idempotency import idempotent +from aws_lambda_powertools.utilities.idempotency.persistence.base import DataRecord +from aws_lambda_powertools.utilities.validation import envelopes, validator + +TABLE_NAME = "TEST_TABLE" + + +# Using parametrize to run test twice, with two separate instances of persistence store. One instance with caching +# enabled, and one without. +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) +def test_idempotent_lambda_already_completed( + persistence_store, + lambda_apigw_event, + timestamp_future, + hashed_idempotency_key, + serialized_lambda_response, + deserialized_lambda_response, +): + """ + Test idempotent decorator where event with matching event key has already been succesfully processed + """ + + stubber = stub.Stubber(persistence_store.table.meta.client) + ddb_response = { + "Item": { + "id": {"S": hashed_idempotency_key}, + "expiration": {"N": timestamp_future}, + "data": {"S": serialized_lambda_response}, + "status": {"S": "COMPLETED"}, + } + } + + expected_params = { + "TableName": TABLE_NAME, + "Key": {"id": hashed_idempotency_key}, + "ConsistentRead": True, + } + stubber.add_client_error("put_item", "ConditionalCheckFailedException") + stubber.add_response("get_item", ddb_response, expected_params) + stubber.activate() + + @idempotent(persistence_store=persistence_store) + def lambda_handler(event, context): + raise Exception + + lambda_resp = lambda_handler(lambda_apigw_event, {}) + assert lambda_resp == deserialized_lambda_response + + stubber.assert_no_pending_responses() + stubber.deactivate() + + +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) +def test_idempotent_lambda_in_progress( + persistence_store, lambda_apigw_event, lambda_response, timestamp_future, hashed_idempotency_key +): + """ + Test idempotent decorator where lambda_handler is already processing an event with matching event key + """ + + stubber = stub.Stubber(persistence_store.table.meta.client) + + expected_params = { + "TableName": TABLE_NAME, + "Key": {"id": hashed_idempotency_key}, + "ConsistentRead": True, + } + ddb_response = { + "Item": { + "id": {"S": hashed_idempotency_key}, + "expiration": {"N": timestamp_future}, + "status": {"S": "INPROGRESS"}, + } + } + + stubber.add_client_error("put_item", "ConditionalCheckFailedException") + stubber.add_response("get_item", ddb_response, expected_params) + stubber.activate() + + @idempotent(persistence_store=persistence_store) + def lambda_handler(event, context): + return lambda_response + + with pytest.raises(IdempotencyAlreadyInProgressError) as ex: + lambda_handler(lambda_apigw_event, {}) + assert ( + ex.value.args[0] == "Execution already in progress with idempotency key: " + "body=a3edd699125517bb49d562501179ecbd" + ) + + stubber.assert_no_pending_responses() + stubber.deactivate() + + +@pytest.mark.skipif(sys.version_info < (3, 8), reason="issue with pytest mock lib for < 3.8") +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": True}], indirect=True) +def test_idempotent_lambda_in_progress_with_cache( + persistence_store, lambda_apigw_event, lambda_response, timestamp_future, hashed_idempotency_key, mocker +): + """ + Test idempotent decorator where lambda_handler is already processing an event with matching event key, cache + enabled. + """ + save_to_cache_spy = mocker.spy(persistence_store, "_save_to_cache") + retrieve_from_cache_spy = mocker.spy(persistence_store, "_retrieve_from_cache") + stubber = stub.Stubber(persistence_store.table.meta.client) + + expected_params = { + "TableName": TABLE_NAME, + "Key": {"id": hashed_idempotency_key}, + "ConsistentRead": True, + } + ddb_response = { + "Item": { + "id": {"S": hashed_idempotency_key}, + "expiration": {"N": timestamp_future}, + "status": {"S": "INPROGRESS"}, + } + } + + stubber.add_client_error("put_item", "ConditionalCheckFailedException") + stubber.add_response("get_item", ddb_response, expected_params) + stubber.activate() + + @idempotent(persistence_store=persistence_store) + def lambda_handler(event, context): + return lambda_response + + loops = 3 + for _ in range(loops): + with pytest.raises(IdempotencyAlreadyInProgressError) as ex: + lambda_handler(lambda_apigw_event, {}) + assert ( + ex.value.args[0] == "Execution already in progress with idempotency key: " + "body=a3edd699125517bb49d562501179ecbd" + ) + + assert retrieve_from_cache_spy.call_count == 2 * loops + retrieve_from_cache_spy.assert_called_with(idempotency_key=hashed_idempotency_key) + + assert save_to_cache_spy.call_count == 1 + first_call_args_data_record = save_to_cache_spy.call_args_list[0].kwargs["data_record"] + assert first_call_args_data_record.idempotency_key == hashed_idempotency_key + assert first_call_args_data_record.status == "INPROGRESS" + assert persistence_store._cache.get(hashed_idempotency_key) + + stubber.assert_no_pending_responses() + stubber.deactivate() + + +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) +def test_idempotent_lambda_first_execution( + persistence_store, + lambda_apigw_event, + expected_params_update_item, + expected_params_put_item, + lambda_response, + serialized_lambda_response, + deserialized_lambda_response, + hashed_idempotency_key, +): + """ + Test idempotent decorator when lambda is executed with an event with a previously unknown event key + """ + + stubber = stub.Stubber(persistence_store.table.meta.client) + ddb_response = {} + + stubber.add_response("put_item", ddb_response, expected_params_put_item) + stubber.add_response("update_item", ddb_response, expected_params_update_item) + stubber.activate() + + @idempotent(persistence_store=persistence_store) + def lambda_handler(event, context): + return lambda_response + + lambda_handler(lambda_apigw_event, {}) + + stubber.assert_no_pending_responses() + stubber.deactivate() + + +@pytest.mark.skipif(sys.version_info < (3, 8), reason="issue with pytest mock lib for < 3.8") +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": True}], indirect=True) +def test_idempotent_lambda_first_execution_cached( + persistence_store, + lambda_apigw_event, + expected_params_update_item, + expected_params_put_item, + lambda_response, + hashed_idempotency_key, + mocker, +): + """ + Test idempotent decorator when lambda is executed with an event with a previously unknown event key. Ensure + result is cached locally on the persistence store instance. + """ + save_to_cache_spy = mocker.spy(persistence_store, "_save_to_cache") + retrieve_from_cache_spy = mocker.spy(persistence_store, "_retrieve_from_cache") + stubber = stub.Stubber(persistence_store.table.meta.client) + ddb_response = {} + + stubber.add_response("put_item", ddb_response, expected_params_put_item) + stubber.add_response("update_item", ddb_response, expected_params_update_item) + stubber.activate() + + @idempotent(persistence_store=persistence_store) + def lambda_handler(event, context): + return lambda_response + + lambda_handler(lambda_apigw_event, {}) + + assert retrieve_from_cache_spy.call_count == 1 + assert save_to_cache_spy.call_count == 2 + first_call_args, second_call_args = save_to_cache_spy.call_args_list + assert first_call_args.args[0].status == "INPROGRESS" + assert second_call_args.args[0].status == "COMPLETED" + assert persistence_store._cache.get(hashed_idempotency_key) + + # This lambda call should not call AWS API + lambda_handler(lambda_apigw_event, {}) + assert retrieve_from_cache_spy.call_count == 3 + retrieve_from_cache_spy.assert_called_with(idempotency_key=hashed_idempotency_key) + + # This assertion fails if an AWS API operation was called more than once + stubber.assert_no_pending_responses() + stubber.deactivate() + + +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) +def test_idempotent_lambda_expired( + persistence_store, + lambda_apigw_event, + timestamp_expired, + lambda_response, + expected_params_update_item, + expected_params_put_item, + hashed_idempotency_key, +): + """ + Test idempotent decorator when lambda is called with an event it succesfully handled already, but outside of the + expiry window + """ + + stubber = stub.Stubber(persistence_store.table.meta.client) + + ddb_response = {} + + stubber.add_response("put_item", ddb_response, expected_params_put_item) + stubber.add_response("update_item", ddb_response, expected_params_update_item) + stubber.activate() + + @idempotent(persistence_store=persistence_store) + def lambda_handler(event, context): + return lambda_response + + lambda_handler(lambda_apigw_event, {}) + + stubber.assert_no_pending_responses() + stubber.deactivate() + + +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) +def test_idempotent_lambda_exception( + persistence_store, + lambda_apigw_event, + timestamp_future, + lambda_response, + hashed_idempotency_key, + expected_params_put_item, +): + """ + Test idempotent decorator when lambda is executed with an event with a previously unknown event key, but + lambda_handler raises an exception which is retryable. + """ + + # Create a new provider + + # Stub the boto3 client + stubber = stub.Stubber(persistence_store.table.meta.client) + + ddb_response = {} + expected_params_delete_item = {"TableName": TABLE_NAME, "Key": {"id": hashed_idempotency_key}} + + stubber.add_response("put_item", ddb_response, expected_params_put_item) + stubber.add_response("delete_item", ddb_response, expected_params_delete_item) + stubber.activate() + + @idempotent(persistence_store=persistence_store) + def lambda_handler(event, context): + raise Exception("Something went wrong!") + + with pytest.raises(Exception): + lambda_handler(lambda_apigw_event, {}) + + stubber.assert_no_pending_responses() + stubber.deactivate() + + +@pytest.mark.parametrize( + "persistence_store_with_validation", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True +) +def test_idempotent_lambda_already_completed_with_validation_bad_payload( + persistence_store_with_validation, + lambda_apigw_event, + timestamp_future, + lambda_response, + hashed_idempotency_key, + hashed_validation_key, +): + """ + Test idempotent decorator where event with matching event key has already been succesfully processed + """ + + stubber = stub.Stubber(persistence_store_with_validation.table.meta.client) + ddb_response = { + "Item": { + "id": {"S": hashed_idempotency_key}, + "expiration": {"N": timestamp_future}, + "data": {"S": '{"message": "test", "statusCode": 200}'}, + "status": {"S": "COMPLETED"}, + "validation": {"S": hashed_validation_key}, + } + } + + expected_params = {"TableName": TABLE_NAME, "Key": {"id": hashed_idempotency_key}, "ConsistentRead": True} + + stubber.add_client_error("put_item", "ConditionalCheckFailedException") + stubber.add_response("get_item", ddb_response, expected_params) + stubber.activate() + + @idempotent(persistence_store=persistence_store_with_validation) + def lambda_handler(event, context): + return lambda_response + + with pytest.raises(IdempotencyValidationError): + lambda_apigw_event["requestContext"]["accountId"] += "1" # Alter the request payload + lambda_handler(lambda_apigw_event, {}) + + stubber.assert_no_pending_responses() + stubber.deactivate() + + +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) +def test_idempotent_lambda_expired_during_request( + persistence_store, + lambda_apigw_event, + timestamp_expired, + lambda_response, + expected_params_update_item, + hashed_idempotency_key, +): + """ + Test idempotent decorator when lambda is called with an event it succesfully handled already. Persistence store + returns inconsistent/rapidly changing result between put_item and get_item calls. + """ + + stubber = stub.Stubber(persistence_store.table.meta.client) + + ddb_response_get_item = { + "Item": { + "id": {"S": hashed_idempotency_key}, + "expiration": {"N": timestamp_expired}, + "data": {"S": '{"message": "test", "statusCode": 200}'}, + "status": {"S": "INPROGRESS"}, + } + } + ddb_response_get_item_missing = {} + expected_params_get_item = { + "TableName": TABLE_NAME, + "Key": {"id": hashed_idempotency_key}, + "ConsistentRead": True, + } + + # Simulate record repeatedly changing state between put_item and get_item + stubber.add_client_error("put_item", "ConditionalCheckFailedException") + stubber.add_response("get_item", ddb_response_get_item, expected_params_get_item) + + stubber.add_client_error("put_item", "ConditionalCheckFailedException") + stubber.add_response("get_item", ddb_response_get_item_missing) + + stubber.add_client_error("put_item", "ConditionalCheckFailedException") + stubber.add_response("get_item", copy.deepcopy(ddb_response_get_item), copy.deepcopy(expected_params_get_item)) + + stubber.activate() + + @idempotent(persistence_store=persistence_store) + def lambda_handler(event, context): + return lambda_response + + # max retries exceeded before get_item and put_item agree on item state, so exception gets raised + with pytest.raises(IdempotencyInconsistentStateError): + lambda_handler(lambda_apigw_event, {}) + + stubber.assert_no_pending_responses() + stubber.deactivate() + + +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) +def test_idempotent_persistence_exception_deleting( + persistence_store, + lambda_apigw_event, + timestamp_future, + lambda_response, + hashed_idempotency_key, + expected_params_put_item, +): + """ + Test idempotent decorator when lambda is executed with an event with a previously unknown event key, but + lambda_handler raises an exception which is retryable. + """ + stubber = stub.Stubber(persistence_store.table.meta.client) + + ddb_response = {} + + stubber.add_response("put_item", ddb_response, expected_params_put_item) + stubber.add_client_error("delete_item", "UnrecoverableError") + stubber.activate() + + @idempotent(persistence_store=persistence_store) + def lambda_handler(event, context): + raise Exception("Something went wrong!") + + with pytest.raises(IdempotencyPersistenceLayerError) as exc: + lambda_handler(lambda_apigw_event, {}) + + assert exc.value.args[0] == "Failed to delete record from idempotency store" + stubber.assert_no_pending_responses() + stubber.deactivate() + + +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) +def test_idempotent_persistence_exception_updating( + persistence_store, + lambda_apigw_event, + timestamp_future, + lambda_response, + hashed_idempotency_key, + expected_params_put_item, +): + """ + Test idempotent decorator when lambda is executed with an event with a previously unknown event key, but + lambda_handler raises an exception which is retryable. + """ + stubber = stub.Stubber(persistence_store.table.meta.client) + + ddb_response = {} + + stubber.add_response("put_item", ddb_response, expected_params_put_item) + stubber.add_client_error("update_item", "UnrecoverableError") + stubber.activate() + + @idempotent(persistence_store=persistence_store) + def lambda_handler(event, context): + return {"message": "success!"} + + with pytest.raises(IdempotencyPersistenceLayerError) as exc: + lambda_handler(lambda_apigw_event, {}) + + assert exc.value.args[0] == "Failed to update record state to success in idempotency store" + stubber.assert_no_pending_responses() + stubber.deactivate() + + +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) +def test_idempotent_persistence_exception_getting( + persistence_store, + lambda_apigw_event, + timestamp_future, + lambda_response, + hashed_idempotency_key, + expected_params_put_item, +): + """ + Test idempotent decorator when lambda is executed with an event with a previously unknown event key, but + lambda_handler raises an exception which is retryable. + """ + stubber = stub.Stubber(persistence_store.table.meta.client) + + stubber.add_client_error("put_item", "ConditionalCheckFailedException") + stubber.add_client_error("get_item", "UnexpectedException") + stubber.activate() + + @idempotent(persistence_store=persistence_store) + def lambda_handler(event, context): + return {"message": "success!"} + + with pytest.raises(IdempotencyPersistenceLayerError) as exc: + lambda_handler(lambda_apigw_event, {}) + + assert exc.value.args[0] == "Failed to get record from idempotency store" + stubber.assert_no_pending_responses() + stubber.deactivate() + + +@pytest.mark.parametrize( + "persistence_store_with_validation", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True +) +def test_idempotent_lambda_first_execution_with_validation( + persistence_store_with_validation, + lambda_apigw_event, + expected_params_update_item_with_validation, + expected_params_put_item_with_validation, + lambda_response, + hashed_idempotency_key, + hashed_validation_key, +): + """ + Test idempotent decorator when lambda is executed with an event with a previously unknown event key + """ + stubber = stub.Stubber(persistence_store_with_validation.table.meta.client) + ddb_response = {} + + stubber.add_response("put_item", ddb_response, expected_params_put_item_with_validation) + stubber.add_response("update_item", ddb_response, expected_params_update_item_with_validation) + stubber.activate() + + @idempotent(persistence_store=persistence_store_with_validation) + def lambda_handler(lambda_apigw_event, context): + return lambda_response + + lambda_handler(lambda_apigw_event, {}) + + stubber.assert_no_pending_responses() + stubber.deactivate() + + +@pytest.mark.parametrize( + "persistence_store_without_jmespath", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True +) +def test_idempotent_lambda_with_validator_util( + persistence_store_without_jmespath, + lambda_apigw_event, + timestamp_future, + serialized_lambda_response, + deserialized_lambda_response, + hashed_idempotency_key_with_envelope, + mock_function, +): + """ + Test idempotent decorator where event with matching event key has already been succesfully processed, using the + validator utility to unwrap the event + """ + + stubber = stub.Stubber(persistence_store_without_jmespath.table.meta.client) + ddb_response = { + "Item": { + "id": {"S": hashed_idempotency_key_with_envelope}, + "expiration": {"N": timestamp_future}, + "data": {"S": serialized_lambda_response}, + "status": {"S": "COMPLETED"}, + } + } + + expected_params = { + "TableName": TABLE_NAME, + "Key": {"id": hashed_idempotency_key_with_envelope}, + "ConsistentRead": True, + } + stubber.add_client_error("put_item", "ConditionalCheckFailedException") + stubber.add_response("get_item", ddb_response, expected_params) + stubber.activate() + + @validator(envelope=envelopes.API_GATEWAY_HTTP) + @idempotent(persistence_store=persistence_store_without_jmespath) + def lambda_handler(event, context): + mock_function() + return "shouldn't get here!" + + mock_function.assert_not_called() + lambda_resp = lambda_handler(lambda_apigw_event, {}) + assert lambda_resp == deserialized_lambda_response + + stubber.assert_no_pending_responses() + stubber.deactivate() + + +def test_data_record_invalid_status_value(): + data_record = DataRecord("key", status="UNSUPPORTED_STATUS") + with pytest.raises(IdempotencyInvalidStatusError) as e: + _ = data_record.status + + assert e.value.args[0] == "UNSUPPORTED_STATUS" diff --git a/tests/unit/test_json_encoder.py b/tests/unit/test_json_encoder.py new file mode 100644 index 00000000000..8d6a9f3944c --- /dev/null +++ b/tests/unit/test_json_encoder.py @@ -0,0 +1,14 @@ +import decimal +import json + +from aws_lambda_powertools.shared.json_encoder import Encoder + + +def test_jsonencode_decimal(): + result = json.dumps({"val": decimal.Decimal("8.5")}, cls=Encoder) + assert result == '{"val": "8.5"}' + + +def test_jsonencode_decimal_nan(): + result = json.dumps({"val": decimal.Decimal("NaN")}, cls=Encoder) + assert result == '{"val": NaN}' diff --git a/tests/unit/test_lru_cache.py b/tests/unit/test_lru_cache.py new file mode 100644 index 00000000000..170972432ce --- /dev/null +++ b/tests/unit/test_lru_cache.py @@ -0,0 +1,58 @@ +import random + +import pytest + +from aws_lambda_powertools.shared.cache_dict import LRUDict + +MAX_CACHE_ITEMS = 50 +PREFILL_CACHE_ITEMS = 50 + + +@pytest.fixture +def populated_cache(): + cache_dict = LRUDict(max_items=MAX_CACHE_ITEMS, **{f"key_{i}": f"val_{i}" for i in range(0, PREFILL_CACHE_ITEMS)}) + return cache_dict + + +def test_cache_order_init(populated_cache): + first_item = list(populated_cache)[0] + last_item = list(populated_cache)[-1] + + assert first_item == "key_0" + assert last_item == f"key_{MAX_CACHE_ITEMS - 1}" + + +def test_cache_order_getitem(populated_cache): + random_value = random.randrange(0, MAX_CACHE_ITEMS) + _ = populated_cache[f"key_{random_value}"] + + last_item = list(populated_cache)[-1] + + assert last_item == f"key_{random_value}" + + +def test_cache_order_get(populated_cache): + random_value = random.randrange(0, MAX_CACHE_ITEMS) + _ = populated_cache.get(f"key_{random_value}") + + last_item = list(populated_cache)[-1] + + assert last_item == f"key_{random_value}" + + +def test_cache_evict_over_max_items(populated_cache): + assert "key_0" in populated_cache + assert len(populated_cache) == MAX_CACHE_ITEMS + populated_cache["new_item"] = "new_value" + assert len(populated_cache) == MAX_CACHE_ITEMS + assert "key_0" not in populated_cache + assert "key_1" in populated_cache + + +def test_setitem_moves_to_end(populated_cache): + random_value = random.randrange(0, MAX_CACHE_ITEMS) + populated_cache[f"key_{random_value}"] = f"new_val_{random_value}" + last_item = list(populated_cache)[-1] + + assert last_item == f"key_{random_value}" + assert populated_cache[f"key_{random_value}"] == f"new_val_{random_value}" From f1a88326a96a23d77e6247c1a0370b5ad048a69c Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Sat, 20 Feb 2021 21:34:22 +0100 Subject: [PATCH 02/21] fix: correct behaviour to avoid caching "INPROGRESS" records (#295) * fix: correct behaviour to avoid caching "INPROGRESS" records * docs: add beta flag to utility for initial release(s) * chore: Change STATUS_CONSTANTS to MappingProxyType * chore: Fix docstrings * chore: readability improvements * chore: move cache conditionals inside of cache methods * chore: add test for unhandled types Co-authored-by: Michael Brewer --- .../utilities/idempotency/persistence/base.py | 66 +++++++++++-------- docs/utilities/idempotency.md | 57 ++++++++-------- .../idempotency/test_idempotency.py | 55 ++++++++++++---- tests/unit/test_json_encoder.py | 10 +++ 4 files changed, 123 insertions(+), 65 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index c9751b0ca12..c3183e0df84 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -7,6 +7,7 @@ import json import logging from abc import ABC, abstractmethod +from types import MappingProxyType from typing import Any, Dict import jmespath @@ -21,7 +22,7 @@ logger = logging.getLogger(__name__) -STATUS_CONSTANTS = {"INPROGRESS": "INPROGRESS", "COMPLETED": "COMPLETED", "EXPIRED": "EXPIRED"} +STATUS_CONSTANTS = MappingProxyType({"INPROGRESS": "INPROGRESS", "COMPLETED": "COMPLETED", "EXPIRED": "EXPIRED"}) class DataRecord: @@ -81,8 +82,7 @@ def status(self) -> str: """ if self.is_expired: return STATUS_CONSTANTS["EXPIRED"] - - if self._status in STATUS_CONSTANTS.values(): + elif self._status in STATUS_CONSTANTS.values(): return self._status else: raise IdempotencyInvalidStatusError(self._status) @@ -214,14 +214,14 @@ def _validate_payload(self, lambda_event: Dict[str, Any], data_record: DataRecor DataRecord instance Raises - ______ + ---------- IdempotencyValidationError Event payload doesn't match the stored record for the given idempotency key """ if self.payload_validation_enabled: lambda_payload_hash = self._get_hashed_payload(lambda_event) - if not data_record.payload_hash == lambda_payload_hash: + if data_record.payload_hash != lambda_payload_hash: raise IdempotencyValidationError("Payload does not match stored record for this event key") def _get_expiry_timestamp(self) -> int: @@ -238,9 +238,30 @@ def _get_expiry_timestamp(self) -> int: return int((now + period).timestamp()) def _save_to_cache(self, data_record: DataRecord): + """ + Save data_record to local cache except when status is "INPROGRESS" + + NOTE: We can't cache "INPROGRESS" records as we have no way to reflect updates that can happen outside of the + execution environment + + Parameters + ---------- + data_record: DataRecord + DataRecord instance + + Returns + ------- + + """ + if not self.use_local_cache: + return + if data_record.status == STATUS_CONSTANTS["INPROGRESS"]: + return self._cache[data_record.idempotency_key] = data_record def _retrieve_from_cache(self, idempotency_key: str): + if not self.use_local_cache: + return cached_record = self._cache.get(idempotency_key) if cached_record: if not cached_record.is_expired: @@ -249,11 +270,13 @@ def _retrieve_from_cache(self, idempotency_key: str): self._delete_from_cache(idempotency_key) def _delete_from_cache(self, idempotency_key: str): + if not self.use_local_cache: + return del self._cache[idempotency_key] def save_success(self, event: Dict[str, Any], result: dict) -> None: """ - Save record of function's execution completing succesfully + Save record of function's execution completing successfully Parameters ---------- @@ -277,8 +300,7 @@ def save_success(self, event: Dict[str, Any], result: dict) -> None: ) self._update_record(data_record=data_record) - if self.use_local_cache: - self._save_to_cache(data_record) + self._save_to_cache(data_record) def save_inprogress(self, event: Dict[str, Any]) -> None: """ @@ -298,18 +320,11 @@ def save_inprogress(self, event: Dict[str, Any]) -> None: logger.debug(f"Saving in progress record for idempotency key: {data_record.idempotency_key}") - if self.use_local_cache: - cached_record = self._retrieve_from_cache(idempotency_key=data_record.idempotency_key) - if cached_record: - raise IdempotencyItemAlreadyExistsError + if self._retrieve_from_cache(idempotency_key=data_record.idempotency_key): + raise IdempotencyItemAlreadyExistsError self._put_record(data_record) - # This has to come after _put_record. If _put_record call raises ItemAlreadyExists we shouldn't populate the - # cache with an "INPROGRESS" record as we don't know the status in the data store at this point. - if self.use_local_cache: - self._save_to_cache(data_record) - def delete_record(self, event: Dict[str, Any], exception: Exception): """ Delete record from the persistence store @@ -329,8 +344,7 @@ def delete_record(self, event: Dict[str, Any], exception: Exception): ) self._delete_record(data_record) - if self.use_local_cache: - self._delete_from_cache(data_record.idempotency_key) + self._delete_from_cache(data_record.idempotency_key) def get_record(self, event: Dict[str, Any]) -> DataRecord: """ @@ -356,17 +370,15 @@ def get_record(self, event: Dict[str, Any]) -> DataRecord: idempotency_key = self._get_hashed_idempotency_key(event) - if self.use_local_cache: - cached_record = self._retrieve_from_cache(idempotency_key=idempotency_key) - if cached_record: - logger.debug(f"Idempotency record found in cache with idempotency key: {idempotency_key}") - self._validate_payload(event, cached_record) - return cached_record + cached_record = self._retrieve_from_cache(idempotency_key=idempotency_key) + if cached_record: + logger.debug(f"Idempotency record found in cache with idempotency key: {idempotency_key}") + self._validate_payload(event, cached_record) + return cached_record record = self._get_record(idempotency_key) - if self.use_local_cache: - self._save_to_cache(data_record=record) + self._save_to_cache(data_record=record) self._validate_payload(event, record) return record diff --git a/docs/utilities/idempotency.md b/docs/utilities/idempotency.md index 1c6555088d9..6bc7457d603 100644 --- a/docs/utilities/idempotency.md +++ b/docs/utilities/idempotency.md @@ -3,8 +3,11 @@ title: Idempotency description: Utility --- -This utility provides a simple solution to convert your Lambda functions into idempotent operations which are safe to -retry. +!!! attention + **This utility is currently in beta**. Please open an [issue in GitHub](https://github.com/awslabs/aws-lambda-powertools-python/issues/new/choose) for any bugs or feature requests. + +The idempotency utility provides a simple solution to convert your Lambda functions into idempotent operations which +are safe to retry. ## Terminology @@ -31,31 +34,31 @@ storage layer, so you'll need to create a table first. > Example using AWS Serverless Application Model (SAM) === "template.yml" -```yaml -Resources: - HelloWorldFunction: - Type: AWS::Serverless::Function - Properties: - Runtime: python3.8 - ... - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref IdempotencyTable - IdempotencyTable: - Type: AWS::DynamoDB::Table - Properties: - AttributeDefinitions: - - AttributeName: id - AttributeType: S - BillingMode: PAY_PER_REQUEST - KeySchema: - - AttributeName: id - KeyType: HASH - TableName: "IdempotencyTable" - TimeToLiveSpecification: - AttributeName: expiration - Enabled: true -``` + ```yaml + Resources: + HelloWorldFunction: + Type: AWS::Serverless::Function + Properties: + Runtime: python3.8 + ... + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref IdempotencyTable + IdempotencyTable: + Type: AWS::DynamoDB::Table + Properties: + AttributeDefinitions: + - AttributeName: id + AttributeType: S + BillingMode: PAY_PER_REQUEST + KeySchema: + - AttributeName: id + KeyType: HASH + TableName: "IdempotencyTable" + TimeToLiveSpecification: + AttributeName: expiration + Enabled: true + ``` !!! warning When using this utility with DynamoDB, your lambda responses must always be smaller than 400kb. Larger items cannot diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index e6e64e3b38b..269ab6f9b33 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -133,6 +133,12 @@ def test_idempotent_lambda_in_progress_with_cache( stubber.add_client_error("put_item", "ConditionalCheckFailedException") stubber.add_response("get_item", ddb_response, expected_params) + + stubber.add_client_error("put_item", "ConditionalCheckFailedException") + stubber.add_response("get_item", copy.deepcopy(ddb_response), copy.deepcopy(expected_params)) + + stubber.add_client_error("put_item", "ConditionalCheckFailedException") + stubber.add_response("get_item", copy.deepcopy(ddb_response), copy.deepcopy(expected_params)) stubber.activate() @idempotent(persistence_store=persistence_store) @@ -151,11 +157,8 @@ def lambda_handler(event, context): assert retrieve_from_cache_spy.call_count == 2 * loops retrieve_from_cache_spy.assert_called_with(idempotency_key=hashed_idempotency_key) - assert save_to_cache_spy.call_count == 1 - first_call_args_data_record = save_to_cache_spy.call_args_list[0].kwargs["data_record"] - assert first_call_args_data_record.idempotency_key == hashed_idempotency_key - assert first_call_args_data_record.status == "INPROGRESS" - assert persistence_store._cache.get(hashed_idempotency_key) + save_to_cache_spy.assert_called() + assert persistence_store._cache.get(hashed_idempotency_key) is None stubber.assert_no_pending_responses() stubber.deactivate() @@ -223,12 +226,10 @@ def lambda_handler(event, context): lambda_handler(lambda_apigw_event, {}) - assert retrieve_from_cache_spy.call_count == 1 - assert save_to_cache_spy.call_count == 2 - first_call_args, second_call_args = save_to_cache_spy.call_args_list - assert first_call_args.args[0].status == "INPROGRESS" - assert second_call_args.args[0].status == "COMPLETED" - assert persistence_store._cache.get(hashed_idempotency_key) + retrieve_from_cache_spy.assert_called_once() + save_to_cache_spy.assert_called_once() + assert save_to_cache_spy.call_args[0][0].status == "COMPLETED" + assert persistence_store._cache.get(hashed_idempotency_key).status == "COMPLETED" # This lambda call should not call AWS API lambda_handler(lambda_apigw_event, {}) @@ -594,3 +595,35 @@ def test_data_record_invalid_status_value(): _ = data_record.status assert e.value.args[0] == "UNSUPPORTED_STATUS" + + +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": True}], indirect=True) +def test_in_progress_never_saved_to_cache(persistence_store): + # GIVEN a data record with status "INPROGRESS" + # and persistence_store has use_local_cache = True + data_record = DataRecord("key", status="INPROGRESS") + + # WHEN saving to local cache + persistence_store._save_to_cache(data_record) + + # THEN don't save to local cache + assert persistence_store._cache.get("key") is None + + +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}], indirect=True) +def test_user_local_disabled(persistence_store): + # GIVEN a persistence_store with use_local_cache = False + + # WHEN calling any local cache options + data_record = DataRecord("key", status="COMPLETED") + try: + persistence_store._save_to_cache(data_record) + cache_value = persistence_store._retrieve_from_cache("key") + assert cache_value is None + persistence_store._delete_from_cache("key") + except AttributeError as e: + pytest.fail(f"AttributeError should not be raised: {e}") + + # THEN raise AttributeError + # AND don't have a _cache attribute + assert not hasattr("persistence_store", "_cache") diff --git a/tests/unit/test_json_encoder.py b/tests/unit/test_json_encoder.py index 8d6a9f3944c..af8de4257a8 100644 --- a/tests/unit/test_json_encoder.py +++ b/tests/unit/test_json_encoder.py @@ -1,6 +1,8 @@ import decimal import json +import pytest + from aws_lambda_powertools.shared.json_encoder import Encoder @@ -12,3 +14,11 @@ def test_jsonencode_decimal(): def test_jsonencode_decimal_nan(): result = json.dumps({"val": decimal.Decimal("NaN")}, cls=Encoder) assert result == '{"val": NaN}' + + +def test_jsonencode_calls_default(): + class CustomClass: + pass + + with pytest.raises(TypeError): + json.dumps({"val": CustomClass()}, cls=Encoder) From fe53a2e392aad0792f4e1b6ea4af1603b9696fab Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Fri, 26 Feb 2021 12:28:25 -0800 Subject: [PATCH 03/21] docs(data-classes): Correct import for DynamoDBRecordEventName (#299) --- docs/utilities/data_classes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/utilities/data_classes.md b/docs/utilities/data_classes.md index 464942995fb..231cdf545f1 100644 --- a/docs/utilities/data_classes.md +++ b/docs/utilities/data_classes.md @@ -142,7 +142,7 @@ attributes values (`AttributeValue`), as well as enums for stream view type (`St === "lambda_app.py" ```python - from aws_lambda_powertools.utilities.data_classes import DynamoDBStreamEvent, DynamoDBRecordEventName + from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import DynamoDBStreamEvent, DynamoDBRecordEventName def lambda_handler(event, context): event: DynamoDBStreamEvent = DynamoDBStreamEvent(event) From d7b4afed4aed3ae9ef245c2277d6271637985d1a Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Sun, 28 Feb 2021 10:55:29 -0800 Subject: [PATCH 04/21] feat(idempotency): Fix KeyError when local_cache is True and an error is raised in the lambda handler (#300) --- .../utilities/idempotency/persistence/base.py | 3 ++- .../idempotency/test_idempotency.py | 11 ++++++++++ tests/unit/test_lru_cache.py | 22 +++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index c3183e0df84..7e31c7d394b 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -272,7 +272,8 @@ def _retrieve_from_cache(self, idempotency_key: str): def _delete_from_cache(self, idempotency_key: str): if not self.use_local_cache: return - del self._cache[idempotency_key] + if idempotency_key in self._cache: + del self._cache[idempotency_key] def save_success(self, event: Dict[str, Any], result: dict) -> None: """ diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index 269ab6f9b33..6a85d69d957 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -627,3 +627,14 @@ def test_user_local_disabled(persistence_store): # THEN raise AttributeError # AND don't have a _cache attribute assert not hasattr("persistence_store", "_cache") + + +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": True}], indirect=True) +def test_delete_from_cache_when_empty(persistence_store): + # GIVEN use_local_cache is True AND the local cache is empty + try: + # WHEN we _delete_from_cache + persistence_store._delete_from_cache("key_does_not_exist") + except KeyError: + # THEN we should not get a KeyError + pytest.fail("KeyError should not happen") diff --git a/tests/unit/test_lru_cache.py b/tests/unit/test_lru_cache.py index 170972432ce..887e20d6270 100644 --- a/tests/unit/test_lru_cache.py +++ b/tests/unit/test_lru_cache.py @@ -56,3 +56,25 @@ def test_setitem_moves_to_end(populated_cache): assert last_item == f"key_{random_value}" assert populated_cache[f"key_{random_value}"] == f"new_val_{random_value}" + + +def test_lru_pop_failing(): + cache = LRUDict() + key = "test" + cache[key] = "value" + try: + cache.pop(key, None) + pytest.fail("GitHub #300: LRUDict pop bug has been fixed :)") + except KeyError as e: + assert e.args[0] == key + + +def test_lru_del(): + cache = LRUDict() + key = "test" + cache[key] = "value" + assert len(cache) == 1 + if key in cache: + del cache[key] + assert key not in cache + assert len(cache) == 0 From a0348f1334331825fa03b2f0eab27df5c3b18fa7 Mon Sep 17 00:00:00 2001 From: Heitor Lessa Date: Wed, 3 Mar 2021 09:56:17 +0100 Subject: [PATCH 05/21] chore: attempt 1 to fix PR labeler --- .github/labeler.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/labeler.yml b/.github/labeler.yml index f1c2928a7c7..df3748b34dd 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,18 +1,23 @@ # Label general purpose utilities area/utilities: + - aws_lambda_powertools_python/utilities/* - aws_lambda_powertools_python/utilities/**/* - aws_lambda_powertools_python/middleware_factory/* + - aws_lambda_powertools_python/middleware_factory/**/* # Label core utilities area/logger: - aws_lambda_powertools_python/logging/* + - aws_lambda_powertools_python/logging/**/* area/tracer: - aws_lambda_powertools_python/tracing/* + - aws_lambda_powertools_python/tracing/**/* area/metrics: - aws_lambda_powertools_python/metrics/* + - aws_lambda_powertools_python/metrics/**/* documentation: - docs/* From 3da394dcf5734294eb00910a707d5f7024c85bf8 Mon Sep 17 00:00:00 2001 From: Heitor Lessa Date: Wed, 3 Mar 2021 09:57:45 +0100 Subject: [PATCH 06/21] chore: update labeler bot to sync upon PR changes --- .github/workflows/labeler.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 5ec42cce815..e5ce6979d73 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -9,3 +9,4 @@ jobs: - uses: actions/labeler@main with: repo-token: "${{ secrets.GITHUB_TOKEN }}" + sync-labels: true From 11ebcf9f216e00a2b2d842154121a569bc430a23 Mon Sep 17 00:00:00 2001 From: Heitor Lessa Date: Wed, 3 Mar 2021 10:22:50 +0100 Subject: [PATCH 07/21] fix: import time latency by lazily loading high level modules (#301) * feat: initial working skeleton Signed-off-by: heitorlessa * feat: use global lazy import for intellisense * fix: default lazy provider * chore: trigger CI #1 * chore: trigger CI #2 * chore: uncaught linting * feat: add minimum generic interface for Tracing Provider and Segment * fix: type hints * refactor: use JSON Schema as dict to reduce I/O latency * docs: changelog * test: add perf tests for import * test: adjust perf bar to flaky/CI machines * fix(pytest): enforce coverage upon request only Signed-off-by: heitorlessa * chore: address PR's review * chore: correctly redistribute apache 2.0 unmodified code * chore: test labeler * refactor: lazy load fastjsonschema to prevent unnecessary http.client sessions --- CHANGELOG.md | 5 + LICENSE | 179 ++++++++++++++++++ Makefile | 3 +- aws_lambda_powertools/metrics/base.py | 10 +- .../metrics/{schema.json => schema.py} | 144 ++++++-------- aws_lambda_powertools/shared/constants.py | 4 + aws_lambda_powertools/shared/lazy_import.py | 55 ++++++ aws_lambda_powertools/tracing/base.py | 145 ++++++++++++++ aws_lambda_powertools/tracing/tracer.py | 66 ++++--- .../utilities/parser/envelopes/base.py | 2 +- pytest.ini | 4 +- tests/functional/test_metrics.py | 6 +- tests/performance/__init__.py | 0 tests/performance/test_high_level_imports.py | 95 ++++++++++ 14 files changed, 592 insertions(+), 126 deletions(-) rename aws_lambda_powertools/metrics/{schema.json => schema.py} (68%) create mode 100644 aws_lambda_powertools/shared/lazy_import.py create mode 100644 aws_lambda_powertools/tracing/base.py create mode 100644 tests/performance/__init__.py create mode 100644 tests/performance/test_high_level_imports.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 75aa5a18c45..3e77d594c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) fo ## [Unreleased] +### Fixed + +* **Tracer**: Lazy loads X-Ray SDK to improve import perf for those not instantiating Tracer +* **Metrics**: Convert EMF JSON Schema as Dictionary to reduce I/O and improve import perf + ## [1.10.5] - 2021-02-17 No changes. Bumped version to trigger new pipeline build for layer publishing. diff --git a/LICENSE b/LICENSE index 9e30e05ab6d..17c63bac7fb 100644 --- a/LICENSE +++ b/LICENSE @@ -12,3 +12,182 @@ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +---------------- + +** Tensorflow - https://github.com/tensorflow/tensorflow/ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. diff --git a/Makefile b/Makefile index 27a2896d812..e56eb4bb266 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,8 @@ lint: format poetry run flake8 aws_lambda_powertools/* tests/* test: - poetry run pytest -vvv --cov=./ --cov-report=xml + poetry run pytest -m "not perf" --cov=./ --cov-report=xml + poetry run pytest --cache-clear tests/performance coverage-html: poetry run pytest --cov-report html diff --git a/aws_lambda_powertools/metrics/base.py b/aws_lambda_powertools/metrics/base.py index ecc44edf0fa..281070323d7 100644 --- a/aws_lambda_powertools/metrics/base.py +++ b/aws_lambda_powertools/metrics/base.py @@ -3,23 +3,19 @@ import logging import numbers import os -import pathlib from collections import defaultdict from enum import Enum from typing import Any, Dict, List, Union -import fastjsonschema - from ..shared import constants from ..shared.functions import resolve_env_var_choice +from ..shared.lazy_import import LazyLoader from .exceptions import MetricUnitError, MetricValueError, SchemaValidationError +from .schema import CLOUDWATCH_EMF_SCHEMA +fastjsonschema = LazyLoader("fastjsonschema", globals(), "fastjsonschema") logger = logging.getLogger(__name__) -_schema_path = pathlib.Path(__file__).parent / "./schema.json" -with _schema_path.open() as f: - CLOUDWATCH_EMF_SCHEMA = json.load(f) - MAX_METRICS = 100 diff --git a/aws_lambda_powertools/metrics/schema.json b/aws_lambda_powertools/metrics/schema.py similarity index 68% rename from aws_lambda_powertools/metrics/schema.json rename to aws_lambda_powertools/metrics/schema.py index f948ed979fa..9d6a033c618 100644 --- a/aws_lambda_powertools/metrics/schema.json +++ b/aws_lambda_powertools/metrics/schema.py @@ -1,114 +1,94 @@ -{ - "type": "object", - "title": "Root Node", - "required": [ - "_aws" - ], +# flake8: noqa +CLOUDWATCH_EMF_SCHEMA = { "properties": { "_aws": { "$id": "#/properties/_aws", - "type": "object", - "title": "Metadata", - "required": [ - "Timestamp", - "CloudWatchMetrics" - ], "properties": { - "Timestamp": { - "$id": "#/properties/_aws/properties/Timestamp", - "type": "integer", - "title": "The Timestamp Schema", - "examples": [ - 1565375354953 - ] - }, "CloudWatchMetrics": { "$id": "#/properties/_aws/properties/CloudWatchMetrics", - "type": "array", - "title": "MetricDirectives", "items": { "$id": "#/properties/_aws/properties/CloudWatchMetrics/items", - "type": "object", - "title": "MetricDirective", - "required": [ - "Namespace", - "Dimensions", - "Metrics" - ], "properties": { - "Namespace": { - "$id": "#/properties/_aws/properties/CloudWatchMetrics/items/properties/Namespace", - "type": "string", - "title": "CloudWatch Metrics Namespace", - "examples": [ - "MyApp" - ], - "pattern": "^(.*)$", - "minLength": 1 - }, "Dimensions": { "$id": "#/properties/_aws/properties/CloudWatchMetrics/items/properties/Dimensions", - "type": "array", - "title": "The Dimensions Schema", - "minItems": 1, "items": { "$id": "#/properties/_aws/properties/CloudWatchMetrics/items/properties/Dimensions/items", - "type": "array", - "title": "DimensionSet", - "minItems": 1, - "maxItems": 9, "items": { "$id": "#/properties/_aws/properties/CloudWatchMetrics/items/properties/Dimensions/items/items", - "type": "string", - "title": "DimensionReference", - "examples": [ - "Operation" - ], + "examples": ["Operation"], + "minItems": 1, "pattern": "^(.*)$", - "minItems": 1 - } - } + "title": "DimensionReference", + "type": "string", + }, + "maxItems": 9, + "minItems": 1, + "title": "DimensionSet", + "type": "array", + }, + "minItems": 1, + "title": "The " "Dimensions " "Schema", + "type": "array", }, "Metrics": { "$id": "#/properties/_aws/properties/CloudWatchMetrics/items/properties/Metrics", - "type": "array", - "title": "MetricDefinitions", - "minItems": 1, "items": { "$id": "#/properties/_aws/properties/CloudWatchMetrics/items/properties/Metrics/items", - "type": "object", - "title": "MetricDefinition", - "required": [ - "Name" - ], "minItems": 1, "properties": { "Name": { "$id": "#/properties/_aws/properties/CloudWatchMetrics/items/properties/Metrics/items/properties/Name", - "type": "string", - "title": "MetricName", - "examples": [ - "ProcessingLatency" - ], + "examples": ["ProcessingLatency"], + "minLength": 1, "pattern": "^(.*)$", - "minLength": 1 + "title": "MetricName", + "type": "string", }, "Unit": { "$id": "#/properties/_aws/properties/CloudWatchMetrics/items/properties/Metrics/items/properties/Unit", - "type": "string", + "examples": ["Milliseconds"], + "pattern": "^(Seconds|Microseconds|Milliseconds|Bytes|Kilobytes|Megabytes|Gigabytes|Terabytes|Bits|Kilobits|Megabits|Gigabits|Terabits|Percent|Count|Bytes\\/Second|Kilobytes\\/Second|Megabytes\\/Second|Gigabytes\\/Second|Terabytes\\/Second|Bits\\/Second|Kilobits\\/Second|Megabits\\/Second|Gigabits\\/Second|Terabits\\/Second|Count\\/Second|None)$", "title": "MetricUnit", - "examples": [ - "Milliseconds" - ], - "pattern": "^(Seconds|Microseconds|Milliseconds|Bytes|Kilobytes|Megabytes|Gigabytes|Terabytes|Bits|Kilobits|Megabits|Gigabits|Terabits|Percent|Count|Bytes\\/Second|Kilobytes\\/Second|Megabytes\\/Second|Gigabytes\\/Second|Terabytes\\/Second|Bits\\/Second|Kilobits\\/Second|Megabits\\/Second|Gigabits\\/Second|Terabits\\/Second|Count\\/Second|None)$" - } - } - } - } - } - } - } - } + "type": "string", + }, + }, + "required": ["Name"], + "title": "MetricDefinition", + "type": "object", + }, + "minItems": 1, + "title": "MetricDefinitions", + "type": "array", + }, + "Namespace": { + "$id": "#/properties/_aws/properties/CloudWatchMetrics/items/properties/Namespace", + "examples": ["MyApp"], + "minLength": 1, + "pattern": "^(.*)$", + "title": "CloudWatch " "Metrics " "Namespace", + "type": "string", + }, + }, + "required": ["Namespace", "Dimensions", "Metrics"], + "title": "MetricDirective", + "type": "object", + }, + "title": "MetricDirectives", + "type": "array", + }, + "Timestamp": { + "$id": "#/properties/_aws/properties/Timestamp", + "examples": [1565375354953], + "title": "The Timestamp " "Schema", + "type": "integer", + }, + }, + "required": ["Timestamp", "CloudWatchMetrics"], + "title": "Metadata", + "type": "object", } - } + }, + "required": ["_aws"], + "title": "Root Node", + "type": "object", } diff --git a/aws_lambda_powertools/shared/constants.py b/aws_lambda_powertools/shared/constants.py index c69d6b5ea49..eaad5640dfd 100644 --- a/aws_lambda_powertools/shared/constants.py +++ b/aws_lambda_powertools/shared/constants.py @@ -14,3 +14,7 @@ CHALICE_LOCAL_ENV: str = "AWS_CHALICE_CLI_MODE" SERVICE_NAME_ENV: str = "POWERTOOLS_SERVICE_NAME" XRAY_TRACE_ID_ENV: str = "_X_AMZN_TRACE_ID" + + +XRAY_SDK_MODULE = "aws_xray_sdk" +XRAY_SDK_CORE_MODULE = "aws_xray_sdk.core" diff --git a/aws_lambda_powertools/shared/lazy_import.py b/aws_lambda_powertools/shared/lazy_import.py new file mode 100644 index 00000000000..e860a650f31 --- /dev/null +++ b/aws_lambda_powertools/shared/lazy_import.py @@ -0,0 +1,55 @@ +# Copyright 2015 The TensorFlow Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== + +"""A LazyLoader class.""" + +import importlib +import types + + +class LazyLoader(types.ModuleType): + """Lazily import a module, mainly to avoid pulling in large dependencies. + + `contrib`, and `ffmpeg` are examples of modules that are large and not always + needed, and this allows them to only be loaded when they are used. + + Note: Subclassing types.ModuleType allow us to correctly adhere with sys.modules, import system + """ + + def __init__(self, local_name, parent_module_globals, name): # pylint: disable=super-on-old-class + self._local_name = local_name + self._parent_module_globals = parent_module_globals + + super(LazyLoader, self).__init__(name) + + def _load(self): + # Import the target module and insert it into the parent's namespace + module = importlib.import_module(self.__name__) + self._parent_module_globals[self._local_name] = module + + # Update this object's dict so that if someone keeps a reference to the + # LazyLoader, lookups are efficient (__getattr__ is only called on lookups + # that fail). + self.__dict__.update(module.__dict__) + + return module + + def __getattr__(self, item): + module = self._load() + return getattr(module, item) + + def __dir__(self): + module = self._load() + return dir(module) diff --git a/aws_lambda_powertools/tracing/base.py b/aws_lambda_powertools/tracing/base.py new file mode 100644 index 00000000000..1857ed52a73 --- /dev/null +++ b/aws_lambda_powertools/tracing/base.py @@ -0,0 +1,145 @@ +import abc +import numbers +import traceback +from contextlib import contextmanager +from typing import Any, AsyncContextManager, ContextManager, List, NoReturn, Set, Union + + +class BaseProvider(abc.ABC): + @abc.abstractmethod + @contextmanager + def in_subsegment(self, name=None, **kwargs) -> ContextManager: + """Return a subsegment context manger. + + Parameters + ---------- + name: str + Subsegment name + kwargs: Optional[dict] + Optional parameters to be propagated to segment + """ + + @abc.abstractmethod + @contextmanager + def in_subsegment_async(self, name=None, **kwargs) -> AsyncContextManager: + """Return a subsegment async context manger. + + Parameters + ---------- + name: str + Subsegment name + kwargs: Optional[dict] + Optional parameters to be propagated to segment + """ + + @abc.abstractmethod + def put_annotation(self, key: str, value: Union[str, numbers.Number, bool]) -> NoReturn: + """Annotate current active trace entity with a key-value pair. + + Note: Annotations will be indexed for later search query. + + Parameters + ---------- + key: str + Metadata key + value: Union[str, numbers.Number, bool] + Annotation value + """ + + @abc.abstractmethod + def put_metadata(self, key: str, value: Any, namespace: str = "default") -> NoReturn: + """Add metadata to the current active trace entity. + + Note: Metadata is not indexed but can be later retrieved by BatchGetTraces API. + + Parameters + ---------- + key: str + Metadata key + value: Any + Any object that can be serialized into a JSON string + namespace: Set[str] + Metadata namespace, by default 'default' + """ + + @abc.abstractmethod + def patch(self, modules: Set[str]) -> NoReturn: + """Instrument a set of supported libraries + + Parameters + ---------- + modules: Set[str] + Set of modules to be patched + """ + + @abc.abstractmethod + def patch_all(self) -> NoReturn: + """Instrument all supported libraries""" + + +class BaseSegment(abc.ABC): + """Holds common properties and methods on segment and subsegment.""" + + @abc.abstractmethod + def close(self, end_time: int = None): + """Close the trace entity by setting `end_time` + and flip the in progress flag to False. + + Parameters + ---------- + end_time: int + Time in epoch seconds, by default current time will be used. + """ + + @abc.abstractmethod + def add_subsegment(self, subsegment: Any): + """Add input subsegment as a child subsegment.""" + + @abc.abstractmethod + def remove_subsegment(self, subsegment: Any): + """Remove input subsegment from child subsegments.""" + + @abc.abstractmethod + def put_annotation(self, key: str, value: Union[str, numbers.Number, bool]) -> NoReturn: + """Annotate segment or subsegment with a key-value pair. + + Note: Annotations will be indexed for later search query. + + Parameters + ---------- + key: str + Metadata key + value: Union[str, numbers.Number, bool] + Annotation value + """ + + @abc.abstractmethod + def put_metadata(self, key: str, value: Any, namespace: str = "default") -> NoReturn: + """Add metadata to segment or subsegment. Metadata is not indexed + but can be later retrieved by BatchGetTraces API. + + Parameters + ---------- + key: str + Metadata key + value: Any + Any object that can be serialized into a JSON string + namespace: Set[str] + Metadata namespace, by default 'default' + """ + + @abc.abstractmethod + def add_exception(self, exception: BaseException, stack: List[traceback.StackSummary], remote: bool = False): + """Add an exception to trace entities. + + Parameters + ---------- + exception: Exception + Caught exception + stack: List[traceback.StackSummary] + List of traceback summaries + + Output from `traceback.extract_stack()`. + remote: bool + Whether it's a client error (False) or downstream service error (True), by default False + """ diff --git a/aws_lambda_powertools/tracing/tracer.py b/aws_lambda_powertools/tracing/tracer.py index bfd18be245a..f5b9ac92728 100644 --- a/aws_lambda_powertools/tracing/tracer.py +++ b/aws_lambda_powertools/tracing/tracer.py @@ -3,21 +3,20 @@ import functools import inspect import logging +import numbers import os -from typing import Any, Callable, Dict, List, Optional, Tuple - -import aws_xray_sdk -import aws_xray_sdk.core +from typing import Any, Callable, Dict, List, Optional, Tuple, Union from ..shared import constants from ..shared.functions import resolve_truthy_env_var_choice +from ..shared.lazy_import import LazyLoader +from .base import BaseProvider, BaseSegment is_cold_start = True logger = logging.getLogger(__name__) -# Set the streaming threshold to 0 on the default recorder to force sending -# subsegments individually, rather than batching them. -# See https://github.com/awslabs/aws-lambda-powertools-python/issues/283 -aws_xray_sdk.core.xray_recorder.configure(streaming_threshold=0) + +aws_xray_sdk = LazyLoader(constants.XRAY_SDK_MODULE, globals(), constants.XRAY_SDK_MODULE) +aws_xray_sdk.core = LazyLoader(constants.XRAY_SDK_CORE_MODULE, globals(), constants.XRAY_SDK_CORE_MODULE) class Tracer: @@ -139,7 +138,7 @@ def handler(event: dict, context: Any) -> Dict: "disabled": False, "auto_patch": True, "patch_modules": None, - "provider": aws_xray_sdk.core.xray_recorder, + "provider": None, } _config = copy.copy(_default_config) @@ -148,8 +147,8 @@ def __init__( service: str = None, disabled: bool = None, auto_patch: bool = None, - patch_modules: List = None, - provider: aws_xray_sdk.core.xray_recorder = None, + patch_modules: Optional[Tuple[str]] = None, + provider: BaseProvider = None, ): self.__build_config( service=service, disabled=disabled, auto_patch=auto_patch, patch_modules=patch_modules, provider=provider @@ -165,14 +164,19 @@ def __init__( if self.auto_patch: self.patch(modules=patch_modules) - def put_annotation(self, key: str, value: Any): + # Set the streaming threshold to 0 on the default recorder to force sending + # subsegments individually, rather than batching them. + # See https://github.com/awslabs/aws-lambda-powertools-python/issues/283 + aws_xray_sdk.core.xray_recorder.configure(streaming_threshold=0) # noqa: E800 + + def put_annotation(self, key: str, value: Union[str, numbers.Number, bool]): """Adds annotation to existing segment or subsegment Parameters ---------- key : str Annotation key - value : any + value : Union[str, numbers.Number, bool] Value for annotation Example @@ -238,7 +242,7 @@ def patch(self, modules: Tuple[str] = None): def capture_lambda_handler( self, - lambda_handler: Callable[[Dict, Any], Any] = None, + lambda_handler: Callable[[Dict, Any, Optional[Dict]], Any] = None, capture_response: Optional[bool] = None, capture_error: Optional[bool] = None, ): @@ -512,8 +516,8 @@ async def async_tasks(): def _decorate_async_function( self, method: Callable = None, - capture_response: Optional[bool] = None, - capture_error: Optional[bool] = None, + capture_response: Optional[Union[bool, str]] = None, + capture_error: Optional[Union[bool, str]] = None, method_name: str = None, ): @functools.wraps(method) @@ -539,8 +543,8 @@ async def decorate(*args, **kwargs): def _decorate_generator_function( self, method: Callable = None, - capture_response: Optional[bool] = None, - capture_error: Optional[bool] = None, + capture_response: Optional[Union[bool, str]] = None, + capture_error: Optional[Union[bool, str]] = None, method_name: str = None, ): @functools.wraps(method) @@ -566,8 +570,8 @@ def decorate(*args, **kwargs): def _decorate_generator_function_with_context_manager( self, method: Callable = None, - capture_response: Optional[bool] = None, - capture_error: Optional[bool] = None, + capture_response: Optional[Union[bool, str]] = None, + capture_error: Optional[Union[bool, str]] = None, method_name: str = None, ): @functools.wraps(method) @@ -594,8 +598,8 @@ def decorate(*args, **kwargs): def _decorate_sync_function( self, method: Callable = None, - capture_response: Optional[bool] = None, - capture_error: Optional[bool] = None, + capture_response: Optional[Union[bool, str]] = None, + capture_error: Optional[Union[bool, str]] = None, method_name: str = None, ): @functools.wraps(method) @@ -625,8 +629,8 @@ def _add_response_as_metadata( self, method_name: str = None, data: Any = None, - subsegment: aws_xray_sdk.core.models.subsegment = None, - capture_response: Optional[bool] = None, + subsegment: BaseSegment = None, + capture_response: Optional[Union[bool, str]] = None, ): """Add response as metadata for given subsegment @@ -636,7 +640,7 @@ def _add_response_as_metadata( method name to add as metadata key, by default None data : Any, optional data to add as subsegment metadata, by default None - subsegment : aws_xray_sdk.core.models.subsegment, optional + subsegment : BaseSegment, optional existing subsegment to add metadata on, by default None capture_response : bool, optional Do not include response as metadata @@ -650,7 +654,7 @@ def _add_full_exception_as_metadata( self, method_name: str = None, error: Exception = None, - subsegment: aws_xray_sdk.core.models.subsegment = None, + subsegment: BaseSegment = None, capture_error: Optional[bool] = None, ): """Add full exception object as metadata for given subsegment @@ -661,7 +665,7 @@ def _add_full_exception_as_metadata( method name to add as metadata key, by default None error : Exception, optional error to add as subsegment metadata, by default None - subsegment : aws_xray_sdk.core.models.subsegment, optional + subsegment : BaseSegment, optional existing subsegment to add metadata on, by default None capture_error : bool, optional Do not include error as metadata, by default True @@ -678,7 +682,7 @@ def _disable_tracer_provider(): aws_xray_sdk.global_sdk_config.set_sdk_enabled(False) @staticmethod - def _is_tracer_disabled() -> bool: + def _is_tracer_disabled() -> Union[bool, str]: """Detects whether trace has been disabled Tracing is automatically disabled in the following conditions: @@ -689,7 +693,7 @@ def _is_tracer_disabled() -> bool: Returns ------- - bool + Union[bool, str] """ logger.debug("Verifying whether Tracing has been disabled") is_lambda_sam_cli = os.getenv(constants.SAM_LOCAL_ENV) @@ -712,13 +716,13 @@ def __build_config( disabled: bool = None, auto_patch: bool = None, patch_modules: List = None, - provider: aws_xray_sdk.core.xray_recorder = None, + provider: BaseProvider = None, ): """ Populates Tracer config for new and existing initializations """ is_disabled = disabled if disabled is not None else self._is_tracer_disabled() is_service = service if service is not None else os.getenv(constants.SERVICE_NAME_ENV) - self._config["provider"] = provider if provider is not None else self._config["provider"] + self._config["provider"] = provider or self._config["provider"] or aws_xray_sdk.core.xray_recorder self._config["auto_patch"] = auto_patch if auto_patch is not None else self._config["auto_patch"] self._config["service"] = is_service or self._config["service"] self._config["disabled"] = is_disabled or self._config["disabled"] diff --git a/aws_lambda_powertools/utilities/parser/envelopes/base.py b/aws_lambda_powertools/utilities/parser/envelopes/base.py index 484d589fcc8..06e78160d87 100644 --- a/aws_lambda_powertools/utilities/parser/envelopes/base.py +++ b/aws_lambda_powertools/utilities/parser/envelopes/base.py @@ -59,5 +59,5 @@ def parse(...): # Generic to support type annotations throughout parser -# Note: Can't be defined under types.py due to circular dependency +# Note: Can't be defined under base.py due to circular dependency Envelope = TypeVar("Envelope", bound=BaseEnvelope) diff --git a/pytest.ini b/pytest.ini index 45345cbd365..4f01361ce5e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,5 @@ [pytest] -addopts = -ra --cov --cov-config=.coveragerc +addopts = -ra -vvv testpaths = ./tests +markers = + perf: marks perf tests to be deselected (deselect with '-m "not perf"') diff --git a/tests/functional/test_metrics.py b/tests/functional/test_metrics.py index a3d471ab305..a8dc4e61656 100644 --- a/tests/functional/test_metrics.py +++ b/tests/functional/test_metrics.py @@ -258,8 +258,8 @@ def test_schema_validation_no_namespace(metric, dimension): # WHEN we attempt to serialize a valid EMF object # THEN it should fail namespace validation with pytest.raises(SchemaValidationError, match=".*Namespace must be string"): - with single_metric(**metric): - pass + with single_metric(**metric) as my_metric: + my_metric.add_dimension(**dimension) def test_schema_validation_incorrect_metric_value(metric, dimension, namespace): @@ -268,7 +268,7 @@ def test_schema_validation_incorrect_metric_value(metric, dimension, namespace): # WHEN we attempt to serialize a valid EMF object # THEN it should fail validation and raise SchemaValidationError - with pytest.raises(MetricValueError): + with pytest.raises(MetricValueError, match=".*is not a valid number"): with single_metric(**metric): pass diff --git a/tests/performance/__init__.py b/tests/performance/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/performance/test_high_level_imports.py b/tests/performance/test_high_level_imports.py new file mode 100644 index 00000000000..70a8d993bdf --- /dev/null +++ b/tests/performance/test_high_level_imports.py @@ -0,0 +1,95 @@ +import importlib +import time +from contextlib import contextmanager +from types import ModuleType +from typing import Generator, Tuple + +import pytest + +LOGGER_INIT_SLA: float = 0.001 +METRICS_INIT_SLA: float = 0.005 +TRACER_INIT_SLA: float = 0.5 +IMPORT_INIT_SLA: float = 0.035 + + +@contextmanager +def timing() -> Generator: + """ "Generator to quickly time operations. It can add 5ms so take that into account in elapsed time + + Examples + -------- + + with timing() as t: + print("something") + elapsed = t() + """ + start = time.perf_counter() + yield lambda: time.perf_counter() - start # gen as lambda to calculate elapsed time + + +def core_utilities() -> Tuple[ModuleType, ModuleType, ModuleType]: + """Return Tracing, Logging, and Metrics module""" + tracing = importlib.import_module("aws_lambda_powertools.tracing") + logging = importlib.import_module("aws_lambda_powertools.logging") + metrics = importlib.import_module("aws_lambda_powertools.metrics") + + return tracing, logging, metrics + + +@pytest.mark.perf +def test_import_times_ceiling(): + # GIVEN Core utilities are imported + # WHEN none are used + # THEN import and any global initialization perf should be below 30ms + # though we adjust to 35ms to take into account different CI machines, etc. + # instead of re-running tests which can lead to false positives + with timing() as t: + core_utilities() + + elapsed = t() + if elapsed > IMPORT_INIT_SLA: + pytest.fail(f"High level imports should be below 35ms: {elapsed}") + + +@pytest.mark.perf +def test_tracer_init(): + # GIVEN Tracer is initialized + # WHEN default options are used + # THEN initialization X-Ray SDK perf should be below 450ms + # though we adjust to 500ms to take into account different CI machines, etc. + # instead of re-running tests which can lead to false positives + with timing() as t: + tracing, _, _ = core_utilities() + tracing.Tracer(disabled=True) # boto3 takes ~200ms, and remaining is X-Ray SDK init + + elapsed = t() + if elapsed > TRACER_INIT_SLA: + pytest.fail(f"High level imports should be below 50ms: {elapsed}") + + +@pytest.mark.perf +def test_metrics_init(): + # GIVEN Metrics is initialized + # WHEN default options are used + # THEN initialization perf should be below 5ms + with timing() as t: + _, _, metrics = core_utilities() + metrics.Metrics() + + elapsed = t() + if elapsed > METRICS_INIT_SLA: + pytest.fail(f"High level imports should be below 40ms: {elapsed}") + + +@pytest.mark.perf +def test_logger_init(): + # GIVEN Logger is initialized + # WHEN default options are used + # THEN initialization perf should be below 5ms + with timing() as t: + _, logging, _ = core_utilities() + logging.Logger() + + elapsed = t() + if elapsed > LOGGER_INIT_SLA: + pytest.fail(f"High level imports should be below 40ms: {elapsed}") From 6e293b104c159c31674d1168a6e2a29227d6ad4e Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Wed, 3 Mar 2021 05:50:11 -0800 Subject: [PATCH 08/21] feat(idempotency): Add raise_on_no_idempotency_key flag (#297) * feat(idempotencty): Raise error when event data is None GIVEN a persistence_store with event_key_jmespath = `body` WHEN getting the hashed idempotency key with an event without a `body` key THEN raise IdempotencyValidationError * feat(idempotency): Add raise_on_no_idempotency_key Add `raise_on_no_idempotency_key` to enforce a idempotency key value is passed into the lambda. * fix: Include empty data structures Take in account for empty data structures, including non-lists iterables like tuples, dictionaries Co-authored-by: Heitor Lessa * tests: Include tests for empty data structures Co-authored-by: Heitor Lessa * fix: tests * docs: Make some doc changes Co-authored-by: Heitor Lessa --- .../utilities/idempotency/exceptions.py | 6 +++ .../utilities/idempotency/persistence/base.py | 16 ++++++ docs/utilities/idempotency.md | 17 +++++- tests/functional/idempotency/conftest.py | 2 +- .../idempotency/test_idempotency.py | 54 ++++++++++++++++++- 5 files changed, 91 insertions(+), 4 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/exceptions.py b/aws_lambda_powertools/utilities/idempotency/exceptions.py index 1d7a8acab1f..6c7318ebca0 100644 --- a/aws_lambda_powertools/utilities/idempotency/exceptions.py +++ b/aws_lambda_powertools/utilities/idempotency/exceptions.py @@ -43,3 +43,9 @@ class IdempotencyPersistenceLayerError(Exception): """ Unrecoverable error from the data store """ + + +class IdempotencyKeyError(Exception): + """ + Payload does not contain a idempotent key + """ diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index 7e31c7d394b..c866d75d98d 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -6,6 +6,7 @@ import hashlib import json import logging +import warnings from abc import ABC, abstractmethod from types import MappingProxyType from typing import Any, Dict @@ -17,6 +18,7 @@ from aws_lambda_powertools.utilities.idempotency.exceptions import ( IdempotencyInvalidStatusError, IdempotencyItemAlreadyExistsError, + IdempotencyKeyError, IdempotencyValidationError, ) @@ -112,6 +114,7 @@ def __init__( use_local_cache: bool = False, local_cache_max_items: int = 256, hash_function: str = "md5", + raise_on_no_idempotency_key: bool = False, ) -> None: """ Initialize the base persistence layer @@ -130,6 +133,8 @@ def __init__( Max number of items to store in local cache, by default 1024 hash_function: str, optional Function to use for calculating hashes, by default md5. + raise_on_no_idempotency_key: bool, optional + Raise exception if no idempotency key was found in the request, by default False """ self.event_key_jmespath = event_key_jmespath if self.event_key_jmespath: @@ -143,6 +148,7 @@ def __init__( self.validation_key_jmespath = jmespath.compile(payload_validation_jmespath) self.payload_validation_enabled = True self.hash_function = getattr(hashlib, hash_function) + self.raise_on_no_idempotency_key = raise_on_no_idempotency_key def _get_hashed_idempotency_key(self, lambda_event: Dict[str, Any]) -> str: """ @@ -162,8 +168,18 @@ def _get_hashed_idempotency_key(self, lambda_event: Dict[str, Any]) -> str: data = lambda_event if self.event_key_jmespath: data = self.event_key_compiled_jmespath.search(lambda_event) + + if self.is_missing_idempotency_key(data): + warnings.warn(f"No value found for idempotency_key. jmespath: {self.event_key_jmespath}") + if self.raise_on_no_idempotency_key: + raise IdempotencyKeyError("No data found to create a hashed idempotency_key") + return self._generate_hash(data) + @staticmethod + def is_missing_idempotency_key(data) -> bool: + return data is None or not data or all(x is None for x in data) + def _get_hashed_payload(self, lambda_event: Dict[str, Any]) -> str: """ Extract data from lambda event using validation key jmespath, and return a hashed representation diff --git a/docs/utilities/idempotency.md b/docs/utilities/idempotency.md index 6bc7457d603..1193663b2e8 100644 --- a/docs/utilities/idempotency.md +++ b/docs/utilities/idempotency.md @@ -254,11 +254,24 @@ idempotent invocations. ``` In this example, the "userDetail" and "productId" keys are used as the payload to generate the idempotency key. If -we try to send the same request but with a different amount, Lambda will raise `IdempotencyValidationError`. Without +we try to send the same request but with a different amount, we will raise `IdempotencyValidationError`. Without payload validation, we would have returned the same result as we did for the initial request. Since we're also returning an amount in the response, this could be quite confusing for the client. By using payload validation on the amount field, we prevent this potentially confusing behaviour and instead raise an Exception. +### Making idempotency key required + +If you want to enforce that an idempotency key is required, you can set `raise_on_no_idempotency_key` to `True`, +and we will raise `IdempotencyKeyError` if none was found. + +```python hl_lines="4" +DynamoDBPersistenceLayer( + event_key_jmespath="body", + table_name="IdempotencyTable", + raise_on_no_idempotency_key=True + ) +``` + ### Changing dynamoDB attribute names If you want to use an existing DynamoDB table, or wish to change the name of the attributes used to store items in the table, you can do so when you construct the `DynamoDBPersistenceLayer` instance. @@ -278,7 +291,7 @@ This example demonstrates changing the attribute names to custom values: ```python hl_lines="5-10" persistence_layer = DynamoDBPersistenceLayer( event_key_jmespath="[userDetail, productId]", - table_name="IdempotencyTable",) + table_name="IdempotencyTable", key_attr="idempotency_key", expiry_attr="expires_at", status_attr="current_status", diff --git a/tests/functional/idempotency/conftest.py b/tests/functional/idempotency/conftest.py index 918eac9a507..68492648337 100644 --- a/tests/functional/idempotency/conftest.py +++ b/tests/functional/idempotency/conftest.py @@ -152,7 +152,7 @@ def hashed_validation_key(lambda_apigw_event): @pytest.fixture def persistence_store(config, request, default_jmespath): persistence_store = DynamoDBPersistenceLayer( - event_key_jmespath=default_jmespath, + event_key_jmespath=request.param.get("event_key_jmespath") or default_jmespath, table_name=TABLE_NAME, boto_config=config, use_local_cache=request.param["use_local_cache"], diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index 6a85d69d957..675d8d1c5c8 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -1,5 +1,7 @@ import copy +import json import sys +from hashlib import md5 import pytest from botocore import stub @@ -8,11 +10,12 @@ IdempotencyAlreadyInProgressError, IdempotencyInconsistentStateError, IdempotencyInvalidStatusError, + IdempotencyKeyError, IdempotencyPersistenceLayerError, IdempotencyValidationError, ) from aws_lambda_powertools.utilities.idempotency.idempotency import idempotent -from aws_lambda_powertools.utilities.idempotency.persistence.base import DataRecord +from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer, DataRecord from aws_lambda_powertools.utilities.validation import envelopes, validator TABLE_NAME = "TEST_TABLE" @@ -638,3 +641,52 @@ def test_delete_from_cache_when_empty(persistence_store): except KeyError: # THEN we should not get a KeyError pytest.fail("KeyError should not happen") + + +def test_is_missing_idempotency_key(): + # GIVEN None THEN is_missing_idempotency_key is True + assert BasePersistenceLayer.is_missing_idempotency_key(None) + # GIVEN a list of Nones THEN is_missing_idempotency_key is True + assert BasePersistenceLayer.is_missing_idempotency_key([None, None]) + # GIVEN a list of all not None THEN is_missing_idempotency_key is false + assert BasePersistenceLayer.is_missing_idempotency_key([None, "Value"]) is False + # GIVEN a str THEN is_missing_idempotency_key is false + assert BasePersistenceLayer.is_missing_idempotency_key("Value") is False + # GIVEN an empty tuple THEN is_missing_idempotency_key is false + assert BasePersistenceLayer.is_missing_idempotency_key(()) + # GIVEN an empty list THEN is_missing_idempotency_key is false + assert BasePersistenceLayer.is_missing_idempotency_key([]) + # GIVEN an empty dictionary THEN is_missing_idempotency_key is false + assert BasePersistenceLayer.is_missing_idempotency_key({}) + # GIVEN an empty str THEN is_missing_idempotency_key is false + assert BasePersistenceLayer.is_missing_idempotency_key("") + + +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False, "event_key_jmespath": "body"}], indirect=True) +def test_default_no_raise_on_missing_idempotency_key(persistence_store): + # GIVEN a persistence_store with use_local_cache = False and event_key_jmespath = "body" + assert persistence_store.use_local_cache is False + assert "body" in persistence_store.event_key_jmespath + + # WHEN getting the hashed idempotency key for an event with no `body` key + hashed_key = persistence_store._get_hashed_idempotency_key({}) + + # THEN return the hash of None + assert md5(json.dumps(None).encode()).hexdigest() == hashed_key + + +@pytest.mark.parametrize( + "persistence_store", [{"use_local_cache": False, "event_key_jmespath": "[body, x]"}], indirect=True +) +def test_raise_on_no_idempotency_key(persistence_store): + # GIVEN a persistence_store with raise_on_no_idempotency_key and no idempotency key in the request + persistence_store.raise_on_no_idempotency_key = True + assert persistence_store.use_local_cache is False + assert "body" in persistence_store.event_key_jmespath + + # WHEN getting the hashed idempotency key for an event with no `body` key + with pytest.raises(IdempotencyKeyError) as excinfo: + persistence_store._get_hashed_idempotency_key({}) + + # THEN raise IdempotencyKeyError error + assert "No data found to create a hashed idempotency_key" in str(excinfo.value) From 4ef565aa4de77159de438747074b77d0908b50e4 Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Wed, 3 Mar 2021 06:29:41 -0800 Subject: [PATCH 09/21] test(general): Add some missing code coverage (#296) * tests: Add some missing code coverage * test(DictWrapper): Add check for not isinstance * test(tracing): Add mock to test aiohttp_trace_config * test(appconfig): Add test for get_app_config() without a default provider * chore: Bump CI * doc: correct docs Co-authored-by: Heitor Lessa * chore: formatting * tests: Assert that the TraceConfig has been configured * tests: removed Co-authored-by: Heitor Lessa --- .../functional/test_lambda_trigger_events.py | 3 +++ tests/functional/test_utilities_parameters.py | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/tests/functional/test_lambda_trigger_events.py b/tests/functional/test_lambda_trigger_events.py index d6d225bf530..20f4eb5a827 100644 --- a/tests/functional/test_lambda_trigger_events.py +++ b/tests/functional/test_lambda_trigger_events.py @@ -54,6 +54,9 @@ def message(self) -> str: assert DataClassSample(data1) == DataClassSample(data1) assert DataClassSample(data1) != DataClassSample(data2) + # Comparing against a dict should not be equals + assert DataClassSample(data1) != data1 + assert data1 != DataClassSample(data1) assert DataClassSample(data1) is not data1 assert data1 is not DataClassSample(data1) diff --git a/tests/functional/test_utilities_parameters.py b/tests/functional/test_utilities_parameters.py index 045b7fbbe18..5a915f574ae 100644 --- a/tests/functional/test_utilities_parameters.py +++ b/tests/functional/test_utilities_parameters.py @@ -1534,6 +1534,32 @@ def _get_multiple(self, path: str, **kwargs) -> Dict[str, str]: assert str_value == json.dumps(mock_body_json) +def test_appconf_get_app_config_new(monkeypatch, mock_name, mock_value): + # GIVEN + class TestProvider(BaseProvider): + def __init__(self, environment: str, application: str): + super().__init__() + + def get(self, name: str, **kwargs) -> str: + return mock_value + + def _get(self, name: str, **kwargs) -> str: + raise NotImplementedError() + + def _get_multiple(self, path: str, **kwargs) -> Dict[str, str]: + raise NotImplementedError() + + monkeypatch.setattr(parameters.appconfig, "DEFAULT_PROVIDERS", {}) + monkeypatch.setattr(parameters.appconfig, "AppConfigProvider", TestProvider) + + # WHEN + value = parameters.get_app_config(mock_name, environment="dev", application="myapp") + + # THEN + assert parameters.appconfig.DEFAULT_PROVIDERS["appconfig"] is not None + assert value == mock_value + + def test_transform_value_json(mock_value): """ Test transform_value() with a json transform From 12c512b78b8830bf2ed09e35d640df14afacfc1a Mon Sep 17 00:00:00 2001 From: Heitor Lessa Date: Wed, 3 Mar 2021 15:50:48 +0100 Subject: [PATCH 10/21] chore: remove unsuccessful labeler bot --- .github/workflows/labeler.yml | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 .github/workflows/labeler.yml diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml deleted file mode 100644 index e5ce6979d73..00000000000 --- a/.github/workflows/labeler.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: "PR Labeler" -on: -- pull_request_target - -jobs: - triage: - runs-on: ubuntu-latest - steps: - - uses: actions/labeler@main - with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" - sync-labels: true From 5367f6120200dd47605734a06f2959de57bb0f87 Mon Sep 17 00:00:00 2001 From: Sordie <35031535+Sordie@users.noreply.github.com> Date: Thu, 4 Mar 2021 08:59:48 +0100 Subject: [PATCH 11/21] feat(data-classes): Add connect contact flow event (#304) --- .../utilities/data_classes/__init__.py | 2 + .../connect_contact_flow_event.py | 166 ++++++++++++++++++ tests/events/connectContactFlowEventAll.json | 41 +++++ tests/events/connectContactFlowEventMin.json | 27 +++ .../functional/test_lambda_trigger_events.py | 67 +++++++ 5 files changed, 303 insertions(+) create mode 100644 aws_lambda_powertools/utilities/data_classes/connect_contact_flow_event.py create mode 100644 tests/events/connectContactFlowEventAll.json create mode 100644 tests/events/connectContactFlowEventMin.json diff --git a/aws_lambda_powertools/utilities/data_classes/__init__.py b/aws_lambda_powertools/utilities/data_classes/__init__.py index 47ca29c2148..9c74983f3a9 100644 --- a/aws_lambda_powertools/utilities/data_classes/__init__.py +++ b/aws_lambda_powertools/utilities/data_classes/__init__.py @@ -1,6 +1,7 @@ from .alb_event import ALBEvent from .api_gateway_proxy_event import APIGatewayProxyEvent, APIGatewayProxyEventV2 from .cloud_watch_logs_event import CloudWatchLogsEvent +from .connect_contact_flow_event import ConnectContactFlowEvent from .dynamo_db_stream_event import DynamoDBStreamEvent from .event_bridge_event import EventBridgeEvent from .kinesis_stream_event import KinesisStreamEvent @@ -14,6 +15,7 @@ "APIGatewayProxyEventV2", "ALBEvent", "CloudWatchLogsEvent", + "ConnectContactFlowEvent", "DynamoDBStreamEvent", "EventBridgeEvent", "KinesisStreamEvent", diff --git a/aws_lambda_powertools/utilities/data_classes/connect_contact_flow_event.py b/aws_lambda_powertools/utilities/data_classes/connect_contact_flow_event.py new file mode 100644 index 00000000000..79f086ac1e2 --- /dev/null +++ b/aws_lambda_powertools/utilities/data_classes/connect_contact_flow_event.py @@ -0,0 +1,166 @@ +from enum import Enum, auto +from typing import Dict, Optional + +from aws_lambda_powertools.utilities.data_classes.common import DictWrapper + + +class ConnectContactFlowChannel(Enum): + VOICE = auto() + CHAT = auto() + + +class ConnectContactFlowEndpointType(Enum): + TELEPHONE_NUMBER = auto() + + +class ConnectContactFlowInitiationMethod(Enum): + INBOUND = auto() + OUTBOUND = auto() + TRANSFER = auto() + CALLBACK = auto() + API = auto() + + +class ConnectContactFlowEndpoint(DictWrapper): + @property + def address(self) -> str: + """The phone number.""" + return self["Address"] + + @property + def endpoint_type(self) -> ConnectContactFlowEndpointType: + """The enpoint type.""" + return ConnectContactFlowEndpointType[self["Type"]] + + +class ConnectContactFlowQueue(DictWrapper): + @property + def arn(self) -> str: + """The unique queue ARN.""" + return self["ARN"] + + @property + def name(self) -> str: + """The queue name.""" + return self["Name"] + + +class ConnectContactFlowMediaStreamAudio(DictWrapper): + @property + def start_fragment_number(self) -> Optional[str]: + """The number that identifies the Kinesis Video Streams fragment, in the stream used for Live media streaming, + in which the customer audio stream started. + """ + return self["StartFragmentNumber"] + + @property + def start_timestamp(self) -> Optional[str]: + """When the customer audio stream started.""" + return self["StartTimestamp"] + + @property + def stream_arn(self) -> Optional[str]: + """The ARN of the Kinesis Video stream used for Live media streaming that includes the customer data to + reference. + """ + return self["StreamARN"] + + +class ConnectContactFlowMediaStreamCustomer(DictWrapper): + @property + def audio(self) -> ConnectContactFlowMediaStreamAudio: + return ConnectContactFlowMediaStreamAudio(self["Audio"]) + + +class ConnectContactFlowMediaStreams(DictWrapper): + @property + def customer(self) -> ConnectContactFlowMediaStreamCustomer: + return ConnectContactFlowMediaStreamCustomer(self["Customer"]) + + +class ConnectContactFlowData(DictWrapper): + @property + def attributes(self) -> Dict[str, str]: + """These are attributes that have been previously associated with a contact, + such as when using a Set contact attributes block in a contact flow. + This map may be empty if there aren't any saved attributes. + """ + return self["Attributes"] + + @property + def channel(self) -> ConnectContactFlowChannel: + """The method used to contact your contact center.""" + return ConnectContactFlowChannel[self["Channel"]] + + @property + def contact_id(self) -> str: + """The unique identifier of the contact.""" + return self["ContactId"] + + @property + def customer_endpoint(self) -> Optional[ConnectContactFlowEndpoint]: + """Contains the customer’s address (number) and type of address.""" + if self["CustomerEndpoint"] is not None: + return ConnectContactFlowEndpoint(self["CustomerEndpoint"]) + return None + + @property + def initial_contact_id(self) -> str: + """The unique identifier for the contact associated with the first interaction between the customer and your + contact center. Use the initial contact ID to track contacts between contact flows. + """ + return self["InitialContactId"] + + @property + def initiation_method(self) -> ConnectContactFlowInitiationMethod: + """How the contact was initiated.""" + return ConnectContactFlowInitiationMethod[self["InitiationMethod"]] + + @property + def instance_arn(self) -> str: + """The ARN for your Amazon Connect instance.""" + return self["InstanceARN"] + + @property + def previous_contact_id(self) -> str: + """The unique identifier for the contact before it was transferred. + Use the previous contact ID to trace contacts between contact flows. + """ + return self["PreviousContactId"] + + @property + def queue(self) -> Optional[ConnectContactFlowQueue]: + """The current queue.""" + if self["Queue"] is not None: + return ConnectContactFlowQueue(self["Queue"]) + return None + + @property + def system_endpoint(self) -> Optional[ConnectContactFlowEndpoint]: + """Contains the address (number) the customer dialed to call your contact center and type of address.""" + if self["SystemEndpoint"] is not None: + return ConnectContactFlowEndpoint(self["SystemEndpoint"]) + return None + + @property + def media_streams(self) -> ConnectContactFlowMediaStreams: + return ConnectContactFlowMediaStreams(self["MediaStreams"]) + + +class ConnectContactFlowEvent(DictWrapper): + """Amazon Connect contact flow event + + Documentation: + ------------- + - https://docs.aws.amazon.com/connect/latest/adminguide/connect-lambda-functions.html + """ + + @property + def contact_data(self) -> ConnectContactFlowData: + """This is always passed by Amazon Connect for every contact. Some parameters are optional.""" + return ConnectContactFlowData(self["Details"]["ContactData"]) + + @property + def parameters(self) -> Dict[str, str]: + """These are parameters specific to this call that were defined when you created the Lambda function.""" + return self["Details"]["Parameters"] diff --git a/tests/events/connectContactFlowEventAll.json b/tests/events/connectContactFlowEventAll.json new file mode 100644 index 00000000000..5850649b6eb --- /dev/null +++ b/tests/events/connectContactFlowEventAll.json @@ -0,0 +1,41 @@ +{ + "Name": "ContactFlowEvent", + "Details": { + "ContactData": { + "Attributes": { + "Language": "en-US" + }, + "Channel": "VOICE", + "ContactId": "5ca32fbd-8f92-46af-92a5-6b0f970f0efe", + "CustomerEndpoint": { + "Address": "+11234567890", + "Type": "TELEPHONE_NUMBER" + }, + "InitialContactId": "5ca32fbd-8f92-46af-92a5-6b0f970f0efe", + "InitiationMethod": "API", + "InstanceARN": "arn:aws:connect:eu-central-1:123456789012:instance/9308c2a1-9bc6-4cea-8290-6c0b4a6d38fa", + "MediaStreams": { + "Customer": { + "Audio": { + "StartFragmentNumber": "91343852333181432392682062622220590765191907586", + "StartTimestamp": "1565781909613", + "StreamARN": "arn:aws:kinesisvideo:eu-central-1:123456789012:stream/connect-contact-a3d73b84-ce0e-479a-a9dc-5637c9d30ac9/1565272947806" + } + } + }, + "PreviousContactId": "5ca32fbd-8f92-46af-92a5-6b0f970f0efe", + "Queue": { + "ARN": "arn:aws:connect:eu-central-1:123456789012:instance/9308c2a1-9bc6-4cea-8290-6c0b4a6d38fa/queue/5cba7cbf-1ecb-4b6d-b8bd-fe91079b3fc8", + "Name": "QueueOne" + }, + "SystemEndpoint": { + "Address": "+11234567890", + "Type": "TELEPHONE_NUMBER" + } + }, + "Parameters": { + "ParameterOne": "One", + "ParameterTwo": "Two" + } + } +} \ No newline at end of file diff --git a/tests/events/connectContactFlowEventMin.json b/tests/events/connectContactFlowEventMin.json new file mode 100644 index 00000000000..9cc22d59c3f --- /dev/null +++ b/tests/events/connectContactFlowEventMin.json @@ -0,0 +1,27 @@ +{ + "Name": "ContactFlowEvent", + "Details": { + "ContactData": { + "Attributes": {}, + "Channel": "VOICE", + "ContactId": "5ca32fbd-8f92-46af-92a5-6b0f970f0efe", + "CustomerEndpoint": null, + "InitialContactId": "5ca32fbd-8f92-46af-92a5-6b0f970f0efe", + "InitiationMethod": "API", + "InstanceARN": "arn:aws:connect:eu-central-1:123456789012:instance/9308c2a1-9bc6-4cea-8290-6c0b4a6d38fa", + "MediaStreams": { + "Customer": { + "Audio": { + "StartFragmentNumber": null, + "StartTimestamp": null, + "StreamARN": null + } + } + }, + "PreviousContactId": "5ca32fbd-8f92-46af-92a5-6b0f970f0efe", + "Queue": null, + "SystemEndpoint": null + }, + "Parameters": {} + } +} \ No newline at end of file diff --git a/tests/functional/test_lambda_trigger_events.py b/tests/functional/test_lambda_trigger_events.py index 20f4eb5a827..a6fb82970fc 100644 --- a/tests/functional/test_lambda_trigger_events.py +++ b/tests/functional/test_lambda_trigger_events.py @@ -29,6 +29,12 @@ VerifyAuthChallengeResponseTriggerEvent, ) from aws_lambda_powertools.utilities.data_classes.common import BaseProxyEvent, DictWrapper +from aws_lambda_powertools.utilities.data_classes.connect_contact_flow_event import ( + ConnectContactFlowChannel, + ConnectContactFlowEndpointType, + ConnectContactFlowEvent, + ConnectContactFlowInitiationMethod, +) from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import ( AttributeValue, DynamoDBRecordEventName, @@ -318,6 +324,67 @@ def test_verify_auth_challenge_response_trigger_event(): assert event.response.answer_correct is True +def test_connect_contact_flow_event_min(): + event = ConnectContactFlowEvent(load_event("connectContactFlowEventMin.json")) + + assert event.contact_data.attributes == {} + assert event.contact_data.channel == ConnectContactFlowChannel.VOICE + assert event.contact_data.contact_id == "5ca32fbd-8f92-46af-92a5-6b0f970f0efe" + assert event.contact_data.customer_endpoint is None + assert event.contact_data.initial_contact_id == "5ca32fbd-8f92-46af-92a5-6b0f970f0efe" + assert event.contact_data.initiation_method == ConnectContactFlowInitiationMethod.API + assert ( + event.contact_data.instance_arn + == "arn:aws:connect:eu-central-1:123456789012:instance/9308c2a1-9bc6-4cea-8290-6c0b4a6d38fa" + ) + assert event.contact_data.media_streams.customer.audio.start_fragment_number is None + assert event.contact_data.media_streams.customer.audio.start_timestamp is None + assert event.contact_data.media_streams.customer.audio.stream_arn is None + assert event.contact_data.previous_contact_id == "5ca32fbd-8f92-46af-92a5-6b0f970f0efe" + assert event.contact_data.queue is None + assert event.contact_data.system_endpoint is None + assert event.parameters == {} + + +def test_connect_contact_flow_event_all(): + event = ConnectContactFlowEvent(load_event("connectContactFlowEventAll.json")) + + assert event.contact_data.attributes == {"Language": "en-US"} + assert event.contact_data.channel == ConnectContactFlowChannel.VOICE + assert event.contact_data.contact_id == "5ca32fbd-8f92-46af-92a5-6b0f970f0efe" + assert event.contact_data.customer_endpoint is not None + assert event.contact_data.customer_endpoint.address == "+11234567890" + assert event.contact_data.customer_endpoint.endpoint_type == ConnectContactFlowEndpointType.TELEPHONE_NUMBER + assert event.contact_data.initial_contact_id == "5ca32fbd-8f92-46af-92a5-6b0f970f0efe" + assert event.contact_data.initiation_method == ConnectContactFlowInitiationMethod.API + assert ( + event.contact_data.instance_arn + == "arn:aws:connect:eu-central-1:123456789012:instance/9308c2a1-9bc6-4cea-8290-6c0b4a6d38fa" + ) + assert ( + event.contact_data.media_streams.customer.audio.start_fragment_number + == "91343852333181432392682062622220590765191907586" + ) + assert event.contact_data.media_streams.customer.audio.start_timestamp == "1565781909613" + assert ( + event.contact_data.media_streams.customer.audio.stream_arn + == "arn:aws:kinesisvideo:eu-central-1:123456789012:stream/" + + "connect-contact-a3d73b84-ce0e-479a-a9dc-5637c9d30ac9/1565272947806" + ) + assert event.contact_data.previous_contact_id == "5ca32fbd-8f92-46af-92a5-6b0f970f0efe" + assert event.contact_data.queue is not None + assert ( + event.contact_data.queue.arn + == "arn:aws:connect:eu-central-1:123456789012:instance/9308c2a1-9bc6-4cea-8290-6c0b4a6d38fa/" + + "queue/5cba7cbf-1ecb-4b6d-b8bd-fe91079b3fc8" + ) + assert event.contact_data.queue.name == "QueueOne" + assert event.contact_data.system_endpoint is not None + assert event.contact_data.system_endpoint.address == "+11234567890" + assert event.contact_data.system_endpoint.endpoint_type == ConnectContactFlowEndpointType.TELEPHONE_NUMBER + assert event.parameters == {"ParameterOne": "One", "ParameterTwo": "Two"} + + def test_dynamo_db_stream_trigger_event(): event = DynamoDBStreamEvent(load_event("dynamoStreamEvent.json")) From dfc928f3b8094b31a86687abd7435e50945f6303 Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Thu, 4 Mar 2021 00:03:42 -0800 Subject: [PATCH 12/21] docs(idempotent): Fix typos and code formatting (#305) Changes: * Used the same formatting and indentation for all of the code examples * Fix various typos and spelling * Highlight the correct lines on some of the examples --- docs/utilities/idempotency.md | 191 ++++++++++++++++++---------------- 1 file changed, 102 insertions(+), 89 deletions(-) diff --git a/docs/utilities/idempotency.md b/docs/utilities/idempotency.md index 1193663b2e8..8ea23bde5ae 100644 --- a/docs/utilities/idempotency.md +++ b/docs/utilities/idempotency.md @@ -34,6 +34,7 @@ storage layer, so you'll need to create a table first. > Example using AWS Serverless Application Model (SAM) === "template.yml" + ```yaml Resources: HelloWorldFunction: @@ -76,32 +77,32 @@ You can quickly start by initializing the `DynamoDBPersistenceLayer` class outsi with the `idempotent` decorator on your lambda handler. The only required parameter is `table_name`, but you likely want to specify `event_key_jmespath` as well. -`event_key_jmespath`: A JMESpath expression which will be used to extract the payload from the event your Lambda hander +`event_key_jmespath`: A JMESpath expression which will be used to extract the payload from the event your Lambda handler is called with. This payload will be used as the key to decide if future invocations are duplicates. If you don't pass this parameter, the entire event will be used as the key. === "app.py" ```python hl_lines="2 6-9 11" - import json - from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent - - # Treat everything under the "body" key in - # the event json object as our payload - persistence_layer = DynamoDBPersistenceLayer( - event_key_jmespath="body", - table_name="IdempotencyTable" - ) + import json + from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent + + # Treat everything under the "body" key in + # the event json object as our payload + persistence_layer = DynamoDBPersistenceLayer( + table_name="IdempotencyTable", + event_key_jmespath="body", + ) - @idempotent(persistence_store=persistence_layer) - def handler(event, context): - body = json.loads(event['body']) - payment = create_subscription_payment( - user=body['user'], - product=body['product_id'] - ) - ... - return {"message": "success", "statusCode": 200, "payment_id": payment.id} + @idempotent(persistence_store=persistence_layer) + def handler(event, context): + body = json.loads(event['body']) + payment = create_subscription_payment( + user=body['user'], + product=body['product_id'] + ) + ... + return {"message": "success", "statusCode": 200, "payment_id": payment.id} ``` === "Example event" @@ -171,17 +172,19 @@ In most cases, it is not desirable to store the idempotency records forever. Rat same payload won't be executed within a period of time. By default, the period is set to 1 hour (3600 seconds). You can change this window with the `expires_after_seconds` parameter: -```python hl_lines="4" -DynamoDBPersistenceLayer( - event_key_jmespath="body", - table_name="IdempotencyTable", - expires_after_seconds=5*60 # 5 minutes +=== "app.py" + + ```python hl_lines="4" + DynamoDBPersistenceLayer( + table_name="IdempotencyTable", + event_key_jmespath="body", + expires_after_seconds=5*60, # 5 minutes ) + ``` -``` This will mark any records older than 5 minutes as expired, and the lambda handler will be executed as normal if it is invoked with a matching payload. If you have set the TTL field in DynamoDB like in the SAM example above, the record -will be automatically deleted from the table after a period of itme. +will be automatically deleted from the table after a period of time. ### Handling concurrent executions @@ -195,17 +198,19 @@ concurrent execution. If you receive this error, you can safely retry the operat To reduce the number of lookups to the persistence storage layer, you can enable in memory caching with the `use_local_cache` parameter, which is disabled by default. This cache is local to each Lambda execution environment. This means it will be effective in cases where your function's concurrency is low in comparison to the number of -"retry" invocations with the same payload. When enabled, the default is to cache a maxmum of 256 records in each Lambda +"retry" invocations with the same payload. When enabled, the default is to cache a maximum of 256 records in each Lambda execution environment. You can change this with the `local_cache_max_items` parameter. -```python hl_lines="4 5" -DynamoDBPersistenceLayer( - event_key_jmespath="body", - table_name="IdempotencyTable", - use_local_cache=True, - local_cache_max_items=1000 +=== "app.py" + + ```python hl_lines="4 5" + DynamoDBPersistenceLayer( + table_name="IdempotencyTable", + event_key_jmespath="body", + use_local_cache=True, + local_cache_max_items=1000 ) -``` + ``` ## Advanced @@ -218,35 +223,37 @@ with the `payload_validation_jmespath` to specify which part of the event body s idempotent invocations. === "app.py" + ```python hl_lines="6" from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent - persistence_layer = DynamoDBPersistenceLayer( - event_key_jmespath="[userDetail, productId]", - table_name="IdempotencyTable",) - payload_validation_jmespath="amount" - ) + persistence_layer = DynamoDBPersistenceLayer( + table_name="IdempotencyTable", + event_key_jmespath="[userDetail, productId]", + payload_validation_jmespath="amount" + ) - @idempotent(persistence_store=persistence_layer) - def handler(event, context): - # Creating a subscription payment is a side - # effect of calling this function! - payment = create_subscription_payment( - user=event['userDetail']['username'], - product=event['product_id'], - amount=event['amount'] - ) - ... - return {"message": "success", "statusCode": 200, - "payment_id": payment.id, "amount": payment.amount} + @idempotent(persistence_store=persistence_layer) + def handler(event, context): + # Creating a subscription payment is a side + # effect of calling this function! + payment = create_subscription_payment( + user=event['userDetail']['username'], + product=event['product_id'], + amount=event['amount'] + ) + ... + return {"message": "success", "statusCode": 200, + "payment_id": payment.id, "amount": payment.amount} ``` -=== "Event" +=== "Example Event" + ```json { "userDetail": { "username": "User1", "user_email": "user@example.com" - }, + }, "productId": 1500, "charge_type": "subscription", "amount": 500 @@ -264,13 +271,15 @@ amount field, we prevent this potentially confusing behaviour and instead raise If you want to enforce that an idempotency key is required, you can set `raise_on_no_idempotency_key` to `True`, and we will raise `IdempotencyKeyError` if none was found. -```python hl_lines="4" -DynamoDBPersistenceLayer( - event_key_jmespath="body", - table_name="IdempotencyTable", - raise_on_no_idempotency_key=True +=== "app.py" + + ```python hl_lines="4" + DynamoDBPersistenceLayer( + table_name="IdempotencyTable", + event_key_jmespath="body", + raise_on_no_idempotency_key=True, ) -``` + ``` ### Changing dynamoDB attribute names If you want to use an existing DynamoDB table, or wish to change the name of the attributes used to store items in the @@ -288,16 +297,17 @@ validation_key_attr | "validation" | Hashed representation of the parts of the This example demonstrates changing the attribute names to custom values: === "app.py" - ```python hl_lines="5-10" + + ```python hl_lines="4-8" persistence_layer = DynamoDBPersistenceLayer( - event_key_jmespath="[userDetail, productId]", table_name="IdempotencyTable", + event_key_jmespath="[userDetail, productId]", key_attr="idempotency_key", expiry_attr="expires_at", status_attr="current_status", data_attr="result_data", validation_key_attr="validation_key" - ) + ) ``` ### Customizing boto configuration @@ -305,36 +315,38 @@ You can provide custom boto configuration or event bring your own boto3 session or `boto3_session` parameters when constructing the persistence store. === "Custom session" + ```python hl_lines="1 4 8" - import boto3 - from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent - - boto3_session = boto3.session.Session() - persistence_layer = DynamoDBPersistenceLayer( - event_key_jmespath="body", - table_name="IdempotencyTable", - boto3_session=boto3_session - ) + import boto3 + from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent + + boto3_session = boto3.session.Session() + persistence_layer = DynamoDBPersistenceLayer( + table_name="IdempotencyTable", + event_key_jmespath="body", + boto3_session=boto3_session + ) - @idempotent(persistence_store=persistence_layer) - def handler(event, context): - ... + @idempotent(persistence_store=persistence_layer) + def handler(event, context): + ... ``` === "Custom config" + ```python hl_lines="1 4 8" - from botocore.config import Config - from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent - - boto_config = Config() - persistence_layer = DynamoDBPersistenceLayer( - event_key_jmespath="body", - table_name="IdempotencyTable", - boto_config=boto_config - ) - - @idempotent(persistence_store=persistence_layer) - def handler(event, context): - ... + from botocore.config import Config + from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent + + boto_config = Config() + persistence_layer = DynamoDBPersistenceLayer( + table_name="IdempotencyTable", + event_key_jmespath="body", + boto_config=boto_config + ) + + @idempotent(persistence_store=persistence_layer) + def handler(event, context): + ... ``` ### Bring your own persistent store @@ -357,14 +369,15 @@ The idempotency utility can be used with the `validator` decorator. Ensure that `event_key_jmespath`. === "app.py" + ```python hl_lines="9 10" from aws_lambda_powertools.utilities.validation import validator, envelopes from aws_lambda_powertools.utilities.idempotency.idempotency import idempotent persistence_layer = DynamoDBPersistenceLayer( - event_key_jmespath="[message, username]", table_name="IdempotencyTable", - ) + event_key_jmespath="[message, username]", + ) @validator(envelope=envelopes.API_GATEWAY_HTTP) @idempotent(persistence_store=persistence_layer) From b4d0baad54074f14fac9d018764ceba04e9df044 Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Thu, 4 Mar 2021 01:40:06 -0800 Subject: [PATCH 13/21] feat(idempotent): Add support for jmespath_options (#302) * feat(idempotent): Add support for jmespath_options Like for the validator, idempotent utility should allow for extracting an idempotent key using custom functions * test(idempotent): Add test for support jmepath custom function * chore: Fix test by using fixtures * refactor(jmespath_functions): Move into shared --- .../jmespath_functions.py | 0 .../utilities/idempotency/persistence/base.py | 12 ++++++- .../utilities/validation/base.py | 3 +- tests/functional/idempotency/conftest.py | 18 +++++++++++ .../idempotency/test_idempotency.py | 31 +++++++++++++++++++ 5 files changed, 62 insertions(+), 2 deletions(-) rename aws_lambda_powertools/{utilities/validation => shared}/jmespath_functions.py (100%) diff --git a/aws_lambda_powertools/utilities/validation/jmespath_functions.py b/aws_lambda_powertools/shared/jmespath_functions.py similarity index 100% rename from aws_lambda_powertools/utilities/validation/jmespath_functions.py rename to aws_lambda_powertools/shared/jmespath_functions.py diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index c866d75d98d..352ba40b5f6 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -14,6 +14,7 @@ import jmespath from aws_lambda_powertools.shared.cache_dict import LRUDict +from aws_lambda_powertools.shared.jmespath_functions import PowertoolsFunctions from aws_lambda_powertools.shared.json_encoder import Encoder from aws_lambda_powertools.utilities.idempotency.exceptions import ( IdempotencyInvalidStatusError, @@ -115,6 +116,7 @@ def __init__( local_cache_max_items: int = 256, hash_function: str = "md5", raise_on_no_idempotency_key: bool = False, + jmespath_options: Dict = None, ) -> None: """ Initialize the base persistence layer @@ -135,6 +137,8 @@ def __init__( Function to use for calculating hashes, by default md5. raise_on_no_idempotency_key: bool, optional Raise exception if no idempotency key was found in the request, by default False + jmespath_options : Dict + Alternative JMESPath options to be included when filtering expr """ self.event_key_jmespath = event_key_jmespath if self.event_key_jmespath: @@ -149,6 +153,9 @@ def __init__( self.payload_validation_enabled = True self.hash_function = getattr(hashlib, hash_function) self.raise_on_no_idempotency_key = raise_on_no_idempotency_key + if not jmespath_options: + jmespath_options = {"custom_functions": PowertoolsFunctions()} + self.jmespath_options = jmespath_options def _get_hashed_idempotency_key(self, lambda_event: Dict[str, Any]) -> str: """ @@ -166,8 +173,11 @@ def _get_hashed_idempotency_key(self, lambda_event: Dict[str, Any]) -> str: """ data = lambda_event + if self.event_key_jmespath: - data = self.event_key_compiled_jmespath.search(lambda_event) + data = self.event_key_compiled_jmespath.search( + lambda_event, options=jmespath.Options(**self.jmespath_options) + ) if self.is_missing_idempotency_key(data): warnings.warn(f"No value found for idempotency_key. jmespath: {self.event_key_jmespath}") diff --git a/aws_lambda_powertools/utilities/validation/base.py b/aws_lambda_powertools/utilities/validation/base.py index bacd25a4efa..a5c82503735 100644 --- a/aws_lambda_powertools/utilities/validation/base.py +++ b/aws_lambda_powertools/utilities/validation/base.py @@ -5,8 +5,9 @@ import jmespath from jmespath.exceptions import LexerError +from aws_lambda_powertools.shared.jmespath_functions import PowertoolsFunctions + from .exceptions import InvalidEnvelopeExpressionError, InvalidSchemaFormatError, SchemaValidationError -from .jmespath_functions import PowertoolsFunctions logger = logging.getLogger(__name__) diff --git a/tests/functional/idempotency/conftest.py b/tests/functional/idempotency/conftest.py index 68492648337..9ae030f02d1 100644 --- a/tests/functional/idempotency/conftest.py +++ b/tests/functional/idempotency/conftest.py @@ -9,6 +9,7 @@ import pytest from botocore import stub from botocore.config import Config +from jmespath import functions from aws_lambda_powertools.shared.json_encoder import Encoder from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer @@ -180,6 +181,23 @@ def persistence_store_with_validation(config, request, default_jmespath): return persistence_store +@pytest.fixture +def persistence_store_with_jmespath_options(config, request): + class CustomFunctions(functions.Functions): + @functions.signature({"types": ["string"]}) + def _func_echo_decoder(self, value): + return value + + persistence_store = DynamoDBPersistenceLayer( + table_name=TABLE_NAME, + boto_config=config, + use_local_cache=False, + event_key_jmespath=request.param, + jmespath_options={"custom_functions": CustomFunctions()}, + ) + return persistence_store + + @pytest.fixture def mock_function(): return mock.MagicMock() diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index 675d8d1c5c8..872d9f39365 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -3,6 +3,7 @@ import sys from hashlib import md5 +import jmespath import pytest from botocore import stub @@ -690,3 +691,33 @@ def test_raise_on_no_idempotency_key(persistence_store): # THEN raise IdempotencyKeyError error assert "No data found to create a hashed idempotency_key" in str(excinfo.value) + + +@pytest.mark.parametrize("persistence_store", [{"use_local_cache": True}], indirect=True) +def test_jmespath_with_powertools_json(persistence_store): + # GIVEN an event_key_jmespath with powertools_json custom function + persistence_store.event_key_jmespath = "[requestContext.authorizer.claims.sub, powertools_json(body).id]" + persistence_store.event_key_compiled_jmespath = jmespath.compile(persistence_store.event_key_jmespath) + sub_attr_value = "cognito_user" + key_attr_value = "some_key" + expected_value = [sub_attr_value, key_attr_value] + api_gateway_proxy_event = { + "requestContext": {"authorizer": {"claims": {"sub": sub_attr_value}}}, + "body": json.dumps({"id": key_attr_value}), + } + + # WHEN calling _get_hashed_idempotency_key + result = persistence_store._get_hashed_idempotency_key(api_gateway_proxy_event) + + # THEN the hashed idempotency key should match the extracted values generated hash + assert result == persistence_store._generate_hash(expected_value) + + +@pytest.mark.parametrize("persistence_store_with_jmespath_options", ["powertools_json(data).payload"], indirect=True) +def test_custom_jmespath_function_overrides_builtin_functions(persistence_store_with_jmespath_options): + # GIVEN an persistence store with a custom jmespath_options + # AND use a builtin powertools custom function + with pytest.raises(jmespath.exceptions.UnknownFunctionError, match="Unknown function: powertools_json()"): + # WHEN calling _get_hashed_idempotency_key + # THEN raise unknown function + persistence_store_with_jmespath_options._get_hashed_idempotency_key({}) From 271f560ea973d8f18bbad616fd2dd5b8cd4ed514 Mon Sep 17 00:00:00 2001 From: Heitor Lessa Date: Thu, 4 Mar 2021 17:29:51 +0100 Subject: [PATCH 14/21] refactor(metrics): optimize validation and serialization (#307) * feat: allow metric validation to be disabled * refactor: use native python validation over JSON Schema * test: adjust perf bar to flaky/CI machines * chore: flaky CI * chore: update lock to test flaky CI build * chore: ignore 3.6 to test flaky CI * chore: re-add 3.6 to test flaky CI --- aws_lambda_powertools/metrics/base.py | 23 +- aws_lambda_powertools/metrics/metric.py | 10 +- aws_lambda_powertools/metrics/metrics.py | 16 +- aws_lambda_powertools/metrics/schema.py | 94 ----- poetry.lock | 468 +++++++++++------------ tests/functional/test_metrics.py | 10 +- tests/performance/conftest.py | 18 + tests/performance/test_metrics.py | 87 +++++ 8 files changed, 373 insertions(+), 353 deletions(-) delete mode 100644 aws_lambda_powertools/metrics/schema.py create mode 100644 tests/performance/conftest.py create mode 100644 tests/performance/test_metrics.py diff --git a/aws_lambda_powertools/metrics/base.py b/aws_lambda_powertools/metrics/base.py index 281070323d7..bfe88bf899b 100644 --- a/aws_lambda_powertools/metrics/base.py +++ b/aws_lambda_powertools/metrics/base.py @@ -9,14 +9,12 @@ from ..shared import constants from ..shared.functions import resolve_env_var_choice -from ..shared.lazy_import import LazyLoader from .exceptions import MetricUnitError, MetricValueError, SchemaValidationError -from .schema import CLOUDWATCH_EMF_SCHEMA -fastjsonschema = LazyLoader("fastjsonschema", globals(), "fastjsonschema") logger = logging.getLogger(__name__) MAX_METRICS = 100 +MAX_DIMENSIONS = 9 class MetricUnit(Enum): @@ -180,6 +178,12 @@ def serialize_metric_set(self, metrics: Dict = None, dimensions: Dict = None, me if self.service and not self.dimension_set.get("service"): self.dimension_set["service"] = self.service + if len(metrics) == 0: + raise SchemaValidationError("Must contain at least one metric.") + + if self.namespace is None: + raise SchemaValidationError("Must contain a metric namespace.") + logger.debug({"details": "Serializing metrics", "metrics": metrics, "dimensions": dimensions}) metric_names_and_units: List[Dict[str, str]] = [] # [ { "Name": "metric_name", "Unit": "Count" } ] @@ -209,12 +213,6 @@ def serialize_metric_set(self, metrics: Dict = None, dimensions: Dict = None, me **metric_names_and_values, # "single_metric": 1.0 } - try: - logger.debug("Validating serialized metrics against CloudWatch EMF schema") - fastjsonschema.validate(definition=CLOUDWATCH_EMF_SCHEMA, data=embedded_metrics_object) - except fastjsonschema.JsonSchemaException as e: - message = f"Invalid format. Error: {e.message}, Invalid item: {e.name}" # noqa: B306, E501 - raise SchemaValidationError(message) return embedded_metrics_object def add_dimension(self, name: str, value: str): @@ -234,7 +232,10 @@ def add_dimension(self, name: str, value: str): Dimension value """ logger.debug(f"Adding dimension: {name}:{value}") - + if len(self.dimension_set) == 9: + raise SchemaValidationError( + f"Maximum number of dimensions exceeded ({MAX_DIMENSIONS}): Unable to add dimension {name}." + ) # Cast value to str according to EMF spec # Majority of values are expected to be string already, so # checking before casting improves performance in most cases @@ -295,7 +296,7 @@ def __extract_metric_unit_value(self, unit: Union[str, MetricUnit]) -> str: if unit in self._metric_unit_options: unit = MetricUnit[unit].value - if unit not in self._metric_units: # str correta + if unit not in self._metric_units: raise MetricUnitError( f"Invalid metric unit '{unit}', expected either option: {self._metric_unit_options}" ) diff --git a/aws_lambda_powertools/metrics/metric.py b/aws_lambda_powertools/metrics/metric.py index 4451eb2d1d0..8e7ace1e5bd 100644 --- a/aws_lambda_powertools/metrics/metric.py +++ b/aws_lambda_powertools/metrics/metric.py @@ -102,8 +102,12 @@ def single_metric(name: str, unit: MetricUnit, value: float, namespace: str = No Raises ------ - e - Propagate error received + MetricUnitError + When metric metric isn't supported by CloudWatch + MetricValueError + When metric value isn't a number + SchemaValidationError + When metric object fails EMF schema validation """ metric_set = None try: @@ -112,4 +116,4 @@ def single_metric(name: str, unit: MetricUnit, value: float, namespace: str = No yield metric metric_set: Dict = metric.serialize_metric_set() finally: - print(json.dumps(metric_set)) + print(json.dumps(metric_set, separators=(",", ":"))) diff --git a/aws_lambda_powertools/metrics/metrics.py b/aws_lambda_powertools/metrics/metrics.py index 2ab6cb35b4a..4f53231b84f 100644 --- a/aws_lambda_powertools/metrics/metrics.py +++ b/aws_lambda_powertools/metrics/metrics.py @@ -57,13 +57,19 @@ def do_something(): Parameters ---------- - MetricManager : MetricManager - Inherits from `aws_lambda_powertools.metrics.base.MetricManager` + service : str, optional + service name to be used as metric dimension, by default "service_undefined" + namespace : str + Namespace for metrics Raises ------ - e - Propagate error received + MetricUnitError + When metric metric isn't supported by CloudWatch + MetricValueError + When metric value isn't a number + SchemaValidationError + When metric object fails EMF schema validation """ _metrics = {} @@ -150,7 +156,7 @@ def decorate(event, context): else: metrics = self.serialize_metric_set() self.clear_metrics() - print(json.dumps(metrics)) + print(json.dumps(metrics, separators=(",", ":"))) return response diff --git a/aws_lambda_powertools/metrics/schema.py b/aws_lambda_powertools/metrics/schema.py deleted file mode 100644 index 9d6a033c618..00000000000 --- a/aws_lambda_powertools/metrics/schema.py +++ /dev/null @@ -1,94 +0,0 @@ -# flake8: noqa -CLOUDWATCH_EMF_SCHEMA = { - "properties": { - "_aws": { - "$id": "#/properties/_aws", - "properties": { - "CloudWatchMetrics": { - "$id": "#/properties/_aws/properties/CloudWatchMetrics", - "items": { - "$id": "#/properties/_aws/properties/CloudWatchMetrics/items", - "properties": { - "Dimensions": { - "$id": "#/properties/_aws/properties/CloudWatchMetrics/items/properties/Dimensions", - "items": { - "$id": "#/properties/_aws/properties/CloudWatchMetrics/items/properties/Dimensions/items", - "items": { - "$id": "#/properties/_aws/properties/CloudWatchMetrics/items/properties/Dimensions/items/items", - "examples": ["Operation"], - "minItems": 1, - "pattern": "^(.*)$", - "title": "DimensionReference", - "type": "string", - }, - "maxItems": 9, - "minItems": 1, - "title": "DimensionSet", - "type": "array", - }, - "minItems": 1, - "title": "The " "Dimensions " "Schema", - "type": "array", - }, - "Metrics": { - "$id": "#/properties/_aws/properties/CloudWatchMetrics/items/properties/Metrics", - "items": { - "$id": "#/properties/_aws/properties/CloudWatchMetrics/items/properties/Metrics/items", - "minItems": 1, - "properties": { - "Name": { - "$id": "#/properties/_aws/properties/CloudWatchMetrics/items/properties/Metrics/items/properties/Name", - "examples": ["ProcessingLatency"], - "minLength": 1, - "pattern": "^(.*)$", - "title": "MetricName", - "type": "string", - }, - "Unit": { - "$id": "#/properties/_aws/properties/CloudWatchMetrics/items/properties/Metrics/items/properties/Unit", - "examples": ["Milliseconds"], - "pattern": "^(Seconds|Microseconds|Milliseconds|Bytes|Kilobytes|Megabytes|Gigabytes|Terabytes|Bits|Kilobits|Megabits|Gigabits|Terabits|Percent|Count|Bytes\\/Second|Kilobytes\\/Second|Megabytes\\/Second|Gigabytes\\/Second|Terabytes\\/Second|Bits\\/Second|Kilobits\\/Second|Megabits\\/Second|Gigabits\\/Second|Terabits\\/Second|Count\\/Second|None)$", - "title": "MetricUnit", - "type": "string", - }, - }, - "required": ["Name"], - "title": "MetricDefinition", - "type": "object", - }, - "minItems": 1, - "title": "MetricDefinitions", - "type": "array", - }, - "Namespace": { - "$id": "#/properties/_aws/properties/CloudWatchMetrics/items/properties/Namespace", - "examples": ["MyApp"], - "minLength": 1, - "pattern": "^(.*)$", - "title": "CloudWatch " "Metrics " "Namespace", - "type": "string", - }, - }, - "required": ["Namespace", "Dimensions", "Metrics"], - "title": "MetricDirective", - "type": "object", - }, - "title": "MetricDirectives", - "type": "array", - }, - "Timestamp": { - "$id": "#/properties/_aws/properties/Timestamp", - "examples": [1565375354953], - "title": "The Timestamp " "Schema", - "type": "integer", - }, - }, - "required": ["Timestamp", "CloudWatchMetrics"], - "title": "Metadata", - "type": "object", - } - }, - "required": ["_aws"], - "title": "Root Node", - "type": "object", -} diff --git a/poetry.lock b/poetry.lock index dadec35e65d..da93eb54a0c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -44,16 +44,16 @@ wrapt = "*" [[package]] name = "bandit" -version = "1.6.2" +version = "1.7.0" description = "Security oriented static analyser for python code." category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.5" [package.dependencies] colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} GitPython = ">=1.0.1" -PyYAML = ">=3.13" +PyYAML = ">=5.3.1" six = ">=1.10.0" stevedore = ">=1.20.0" @@ -79,45 +79,29 @@ d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] [[package]] name = "boto3" -version = "1.16.23" +version = "1.17.20" description = "The AWS SDK for Python" category = "main" optional = false -python-versions = "*" +python-versions = ">= 2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [package.dependencies] -botocore = ">=1.19.23,<1.20.0" +botocore = ">=1.20.20,<1.21.0" jmespath = ">=0.7.1,<1.0.0" s3transfer = ">=0.3.0,<0.4.0" [[package]] name = "botocore" -version = "1.19.23" +version = "1.20.20" description = "Low-level, data-driven core of boto 3." category = "main" optional = false -python-versions = "*" +python-versions = ">= 2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [package.dependencies] jmespath = ">=0.7.1,<1.0.0" python-dateutil = ">=2.1,<3.0.0" -urllib3 = {version = ">=1.25.4,<1.27", markers = "python_version != \"3.4\""} - -[[package]] -name = "certifi" -version = "2020.11.8" -description = "Python package for providing Mozilla's CA Bundle." -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "chardet" -version = "3.0.4" -description = "Universal encoding detector for Python 2 and 3" -category = "dev" -optional = false -python-versions = "*" +urllib3 = ">=1.25.4,<1.27" [[package]] name = "click" @@ -137,7 +121,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "coverage" -version = "5.3" +version = "5.5" description = "Code coverage measurement for Python" category = "dev" optional = false @@ -159,7 +143,7 @@ python-versions = ">=3.6, <3.7" [[package]] name = "dnspython" -version = "2.0.0" +version = "2.1.0" description = "DNS toolkit" category = "main" optional = true @@ -194,7 +178,7 @@ python-versions = "*" [[package]] name = "fastjsonschema" -version = "2.14.5" +version = "2.15.0" description = "Fastest Python implementation of JSON schema" category = "main" optional = false @@ -231,7 +215,7 @@ flake8 = ">=3.0.0" [[package]] name = "flake8-bugbear" -version = "20.1.4" +version = "20.11.1" description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle." category = "dev" optional = false @@ -241,6 +225,9 @@ python-versions = ">=3.6" attrs = ">=19.2.0" flake8 = ">=3.0.0" +[package.extras] +dev = ["coverage", "black", "hypothesis", "hypothesmith"] + [[package]] name = "flake8-builtins" version = "1.5.3" @@ -257,11 +244,11 @@ test = ["coverage", "coveralls", "mock", "pytest", "pytest-cov"] [[package]] name = "flake8-comprehensions" -version = "3.3.0" +version = "3.3.1" description = "A flake8 plugin to help you write better list/set/dict comprehensions." category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" [package.dependencies] flake8 = ">=3.0,<3.2.0 || >3.2.0,<4" @@ -356,7 +343,7 @@ smmap = ">=3.0.1,<4" [[package]] name = "gitpython" -version = "3.1.11" +version = "3.1.14" description = "Python Git Library" category = "dev" optional = false @@ -367,26 +354,27 @@ gitdb = ">=4.0.1,<5" [[package]] name = "idna" -version = "2.10" +version = "3.1" description = "Internationalized Domain Names in Applications (IDNA)" category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +optional = true +python-versions = ">=3.4" [[package]] name = "importlib-metadata" -version = "2.0.0" +version = "3.7.0" description = "Read metadata from Python packages" category = "main" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +python-versions = ">=3.6" [package.dependencies] +typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] -docs = ["sphinx", "rst.linker"] -testing = ["packaging", "pep517", "importlib-resources (>=1.3)"] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] [[package]] name = "isort" @@ -429,7 +417,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "joblib" -version = "1.0.0" +version = "1.0.1" description = "Lightweight pipelining with Python functions" category = "dev" optional = false @@ -437,18 +425,18 @@ python-versions = ">=3.6" [[package]] name = "jsonpickle" -version = "1.4.1" +version = "2.0.0" description = "Python library for serializing any arbitrary object graph into JSON" category = "main" optional = false python-versions = ">=2.7" [package.dependencies] -importlib-metadata = "*" +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [package.extras] docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] -testing = ["coverage (<5)", "pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-black-multipy", "pytest-cov", "ecdsa", "feedparser", "numpy", "pandas", "pymongo", "sqlalchemy", "enum34", "jsonlib"] +testing = ["coverage (<5)", "pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-black-multipy", "pytest-cov", "ecdsa", "feedparser", "numpy", "pandas", "pymongo", "sklearn", "sqlalchemy", "enum34", "jsonlib"] "testing.libs" = ["demjson", "simplejson", "ujson", "yajl"] [[package]] @@ -481,7 +469,7 @@ languages = ["nltk (>=3.2.5,<3.5)", "nltk (>=3.2.5)"] [[package]] name = "mako" -version = "1.1.3" +version = "1.1.4" description = "A super-fast templating language that borrows the best ideas from the existing templating languages." category = "dev" optional = false @@ -510,7 +498,7 @@ restructuredText = ["rst2ansi"] [[package]] name = "markdown" -version = "3.3.3" +version = "3.3.4" description = "Python implementation of Markdown." category = "dev" optional = false @@ -570,7 +558,7 @@ mkdocs = ">=0.17" [[package]] name = "mkdocs-material" -version = "6.2.7" +version = "6.2.8" description = "A Material Design theme for MkDocs" category = "dev" optional = false @@ -596,7 +584,7 @@ mkdocs-material = ">=5.0.0" [[package]] name = "more-itertools" -version = "8.6.0" +version = "8.7.0" description = "More routines for operating on iterables, beyond itertools" category = "dev" optional = false @@ -626,7 +614,7 @@ twitter = ["twython"] [[package]] name = "packaging" -version = "20.4" +version = "20.9" description = "Core utilities for Python packages" category = "dev" optional = false @@ -634,7 +622,6 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.dependencies] pyparsing = ">=2.0.2" -six = "*" [[package]] name = "pathspec" @@ -680,7 +667,7 @@ dev = ["pre-commit", "tox"] [[package]] name = "py" -version = "1.9.0" +version = "1.10.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" category = "dev" optional = false @@ -696,7 +683,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pydantic" -version = "1.7.2" +version = "1.7.3" description = "Data validation and settings management using python 3.6 type hinting" category = "main" optional = true @@ -720,7 +707,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pygments" -version = "2.7.4" +version = "2.8.0" description = "Pygments is a syntax highlighting package written in Python." category = "dev" optional = false @@ -784,14 +771,14 @@ testing = ["async_generator (>=1.3)", "coverage", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-cov" -version = "2.10.1" +version = "2.11.1" description = "Pytest plugin for measuring coverage." category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.dependencies] -coverage = ">=4.4" +coverage = ">=5.2.1" pytest = ">=4.6" [package.extras] @@ -824,11 +811,11 @@ six = ">=1.5" [[package]] name = "pyyaml" -version = "5.3.1" +version = "5.4.1" description = "YAML parser and emitter for Python" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [[package]] name = "radon" @@ -857,25 +844,19 @@ python-versions = "*" [[package]] name = "requests" -version = "2.25.0" +version = "2.15.1" description = "Python HTTP for Humans." category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[package.dependencies] -certifi = ">=2017.4.17" -chardet = ">=3.0.2,<4" -idna = ">=2.5,<3" -urllib3 = ">=1.21.1,<1.27" +python-versions = "*" [package.extras] -security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] +security = ["cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)"] socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] [[package]] name = "s3transfer" -version = "0.3.3" +version = "0.3.4" description = "An Amazon S3 Transfer Manager" category = "main" optional = false @@ -894,7 +875,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "smmap" -version = "3.0.4" +version = "3.0.5" description = "A pure Python implementation of a sliding window memory map manager" category = "dev" optional = false @@ -902,7 +883,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "stevedore" -version = "3.2.2" +version = "3.3.0" description = "Manage dynamic plugins for Python applications" category = "dev" optional = false @@ -914,7 +895,7 @@ pbr = ">=2.0.0,<2.1.0 || >2.1.0" [[package]] name = "testfixtures" -version = "6.15.0" +version = "6.17.1" description = "A collection of helpers and mock objects for unit tests and doc tests." category = "dev" optional = false @@ -943,7 +924,7 @@ python-versions = ">= 3.5" [[package]] name = "tqdm" -version = "4.56.0" +version = "4.58.0" description = "Fast, Extensible Progress Meter" category = "dev" optional = false @@ -955,7 +936,7 @@ telegram = ["requests"] [[package]] name = "typed-ast" -version = "1.4.1" +version = "1.4.2" description = "a fork of Python 2 and 3 ast modules with type comment support" category = "dev" optional = false @@ -966,12 +947,12 @@ name = "typing-extensions" version = "3.7.4.3" description = "Backported and Experimental Type Hints for Python 3.5+" category = "main" -optional = true +optional = false python-versions = "*" [[package]] name = "urllib3" -version = "1.26.2" +version = "1.26.3" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false @@ -1029,7 +1010,7 @@ pydantic = ["pydantic", "typing_extensions", "email-validator"] [metadata] lock-version = "1.1" python-versions = "^3.6" -content-hash = "9c967dd4ada2788799ce868acf31c73849603495047390e4e94e88f8693e40ae" +content-hash = "c8b988d7cf8dda9209e5af324048ce0a56ae3ddbdd33457c9a4791ef89cb47d8" [metadata.files] appdirs = [ @@ -1049,28 +1030,20 @@ aws-xray-sdk = [ {file = "aws_xray_sdk-2.6.0-py2.py3-none-any.whl", hash = "sha256:076f7c610cd3564bbba3507d43e328fb6ff4a2e841d3590f39b2c3ce99d41e1d"}, ] bandit = [ - {file = "bandit-1.6.2-py2.py3-none-any.whl", hash = "sha256:336620e220cf2d3115877685e264477ff9d9abaeb0afe3dc7264f55fa17a3952"}, - {file = "bandit-1.6.2.tar.gz", hash = "sha256:41e75315853507aa145d62a78a2a6c5e3240fe14ee7c601459d0df9418196065"}, + {file = "bandit-1.7.0-py3-none-any.whl", hash = "sha256:216be4d044209fa06cf2a3e51b319769a51be8318140659719aa7a115c35ed07"}, + {file = "bandit-1.7.0.tar.gz", hash = "sha256:8a4c7415254d75df8ff3c3b15cfe9042ecee628a1e40b44c15a98890fbfc2608"}, ] black = [ {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"}, {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, ] boto3 = [ - {file = "boto3-1.16.23-py2.py3-none-any.whl", hash = "sha256:22a6f11383965d7ece9e391722b2989780960c62997b1aa464ffa1f886e1cfa8"}, - {file = "boto3-1.16.23.tar.gz", hash = "sha256:6e6bd178f930309c2ec79643436aae5cf6f26d51e35aa5e58162675a04785e62"}, + {file = "boto3-1.17.20-py2.py3-none-any.whl", hash = "sha256:c0d51f344b71656c2d395d2168600d91bea252a64fb5d503a955ea96426cde8b"}, + {file = "boto3-1.17.20.tar.gz", hash = "sha256:2219f1ebe88d266afa5516f993983eba8742b957fa4fd6854f3c73aa3030e931"}, ] botocore = [ - {file = "botocore-1.19.23-py2.py3-none-any.whl", hash = "sha256:d73a223bf88d067c3ae0a9a3199abe56e99c94267da77d7fed4c39f572f522c0"}, - {file = "botocore-1.19.23.tar.gz", hash = "sha256:9f9efca44b2ab2d9c133ceeafa377e4b3d260310109284123ebfffc15e28481e"}, -] -certifi = [ - {file = "certifi-2020.11.8-py2.py3-none-any.whl", hash = "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd"}, - {file = "certifi-2020.11.8.tar.gz", hash = "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4"}, -] -chardet = [ - {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, - {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, + {file = "botocore-1.20.20-py2.py3-none-any.whl", hash = "sha256:e9e724b59278ebf5caf032be1e32bde0990d79e8052e3bbbb97b6c1d32feba28"}, + {file = "botocore-1.20.20.tar.gz", hash = "sha256:80c32a81fb1ee8bdfa074a79bfb885bb2006e8a9782f2353c0c9f6392704e13a"}, ] click = [ {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, @@ -1081,48 +1054,66 @@ colorama = [ {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] coverage = [ - {file = "coverage-5.3-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270"}, - {file = "coverage-5.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4"}, - {file = "coverage-5.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9"}, - {file = "coverage-5.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729"}, - {file = "coverage-5.3-cp27-cp27m-win32.whl", hash = "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d"}, - {file = "coverage-5.3-cp27-cp27m-win_amd64.whl", hash = "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418"}, - {file = "coverage-5.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9"}, - {file = "coverage-5.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5"}, - {file = "coverage-5.3-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822"}, - {file = "coverage-5.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097"}, - {file = "coverage-5.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9"}, - {file = "coverage-5.3-cp35-cp35m-win32.whl", hash = "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636"}, - {file = "coverage-5.3-cp35-cp35m-win_amd64.whl", hash = "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f"}, - {file = "coverage-5.3-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237"}, - {file = "coverage-5.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54"}, - {file = "coverage-5.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7"}, - {file = "coverage-5.3-cp36-cp36m-win32.whl", hash = "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a"}, - {file = "coverage-5.3-cp36-cp36m-win_amd64.whl", hash = "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d"}, - {file = "coverage-5.3-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8"}, - {file = "coverage-5.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f"}, - {file = "coverage-5.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c"}, - {file = "coverage-5.3-cp37-cp37m-win32.whl", hash = "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751"}, - {file = "coverage-5.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709"}, - {file = "coverage-5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516"}, - {file = "coverage-5.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f"}, - {file = "coverage-5.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259"}, - {file = "coverage-5.3-cp38-cp38-win32.whl", hash = "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82"}, - {file = "coverage-5.3-cp38-cp38-win_amd64.whl", hash = "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221"}, - {file = "coverage-5.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978"}, - {file = "coverage-5.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21"}, - {file = "coverage-5.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24"}, - {file = "coverage-5.3-cp39-cp39-win32.whl", hash = "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7"}, - {file = "coverage-5.3-cp39-cp39-win_amd64.whl", hash = "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7"}, - {file = "coverage-5.3.tar.gz", hash = "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0"}, + {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, + {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"}, + {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"}, + {file = "coverage-5.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90"}, + {file = "coverage-5.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c"}, + {file = "coverage-5.5-cp27-cp27m-win32.whl", hash = "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a"}, + {file = "coverage-5.5-cp27-cp27m-win_amd64.whl", hash = "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5"}, + {file = "coverage-5.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81"}, + {file = "coverage-5.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6"}, + {file = "coverage-5.5-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0"}, + {file = "coverage-5.5-cp310-cp310-win_amd64.whl", hash = "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae"}, + {file = "coverage-5.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb"}, + {file = "coverage-5.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160"}, + {file = "coverage-5.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"}, + {file = "coverage-5.5-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701"}, + {file = "coverage-5.5-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793"}, + {file = "coverage-5.5-cp35-cp35m-win32.whl", hash = "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e"}, + {file = "coverage-5.5-cp35-cp35m-win_amd64.whl", hash = "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3"}, + {file = "coverage-5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066"}, + {file = "coverage-5.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a"}, + {file = "coverage-5.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465"}, + {file = "coverage-5.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb"}, + {file = "coverage-5.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821"}, + {file = "coverage-5.5-cp36-cp36m-win32.whl", hash = "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45"}, + {file = "coverage-5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184"}, + {file = "coverage-5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a"}, + {file = "coverage-5.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53"}, + {file = "coverage-5.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d"}, + {file = "coverage-5.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638"}, + {file = "coverage-5.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3"}, + {file = "coverage-5.5-cp37-cp37m-win32.whl", hash = "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a"}, + {file = "coverage-5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a"}, + {file = "coverage-5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6"}, + {file = "coverage-5.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2"}, + {file = "coverage-5.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759"}, + {file = "coverage-5.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873"}, + {file = "coverage-5.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a"}, + {file = "coverage-5.5-cp38-cp38-win32.whl", hash = "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6"}, + {file = "coverage-5.5-cp38-cp38-win_amd64.whl", hash = "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502"}, + {file = "coverage-5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b"}, + {file = "coverage-5.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529"}, + {file = "coverage-5.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b"}, + {file = "coverage-5.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff"}, + {file = "coverage-5.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b"}, + {file = "coverage-5.5-cp39-cp39-win32.whl", hash = "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6"}, + {file = "coverage-5.5-cp39-cp39-win_amd64.whl", hash = "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03"}, + {file = "coverage-5.5-pp36-none-any.whl", hash = "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079"}, + {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, + {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, ] dataclasses = [ {file = "dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf"}, {file = "dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"}, ] dnspython = [ - {file = "dnspython-2.0.0-py3-none-any.whl", hash = "sha256:40bb3c24b9d4ec12500f0124288a65df232a3aa749bb0c39734b782873a2544d"}, - {file = "dnspython-2.0.0.zip", hash = "sha256:044af09374469c3a39eeea1a146e8cac27daec951f1f1f157b1962fc7cb9d1b7"}, + {file = "dnspython-2.1.0-py3-none-any.whl", hash = "sha256:95d12f6ef0317118d2a1a6fc49aac65ffec7eb8087474158f42f26a639135216"}, + {file = "dnspython-2.1.0.zip", hash = "sha256:e4a87f0b573201a0f3727fa18a516b055fd1107e0e5477cded4a2de497df1dd4"}, ] email-validator = [ {file = "email-validator-1.1.2.tar.gz", hash = "sha256:1a13bd6050d1db4475f13e444e169b6fe872434922d38968c67cea9568cce2f0"}, @@ -1132,8 +1123,8 @@ eradicate = [ {file = "eradicate-1.0.tar.gz", hash = "sha256:4ffda82aae6fd49dfffa777a857cb758d77502a1f2e0f54c9ac5155a39d2d01a"}, ] fastjsonschema = [ - {file = "fastjsonschema-2.14.5-py3-none-any.whl", hash = "sha256:467593c61f5ba8307205a3536313a774b37df91c9a937c5267c11aee5256e77e"}, - {file = "fastjsonschema-2.14.5.tar.gz", hash = "sha256:afbc235655f06356e46caa80190512e4d9222abfaca856041be5a74c665fa094"}, + {file = "fastjsonschema-2.15.0-py3-none-any.whl", hash = "sha256:b3da206676f8b4906debf6a17b650b858c92cb304cbe0c8aa81799bde6a6b858"}, + {file = "fastjsonschema-2.15.0.tar.gz", hash = "sha256:e1ecba260bcffb7de0dda6aee74261da1e6dccde5ee04c1170b2dd97d2b87676"}, ] flake8 = [ {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"}, @@ -1143,16 +1134,16 @@ flake8-black = [ {file = "flake8-black-0.1.2.tar.gz", hash = "sha256:b79d8d868bd42dc2c1f27469b92a984ecab3579ad285a8708ea5f19bf6c1f3a2"}, ] flake8-bugbear = [ - {file = "flake8-bugbear-20.1.4.tar.gz", hash = "sha256:bd02e4b009fb153fe6072c31c52aeab5b133d508095befb2ffcf3b41c4823162"}, - {file = "flake8_bugbear-20.1.4-py36.py37.py38-none-any.whl", hash = "sha256:a3ddc03ec28ba2296fc6f89444d1c946a6b76460f859795b35b77d4920a51b63"}, + {file = "flake8-bugbear-20.11.1.tar.gz", hash = "sha256:528020129fea2dea33a466b9d64ab650aa3e5f9ffc788b70ea4bc6cf18283538"}, + {file = "flake8_bugbear-20.11.1-py36.py37.py38-none-any.whl", hash = "sha256:f35b8135ece7a014bc0aee5b5d485334ac30a6da48494998cc1fabf7ec70d703"}, ] flake8-builtins = [ {file = "flake8-builtins-1.5.3.tar.gz", hash = "sha256:09998853b2405e98e61d2ff3027c47033adbdc17f9fe44ca58443d876eb00f3b"}, {file = "flake8_builtins-1.5.3-py2.py3-none-any.whl", hash = "sha256:7706babee43879320376861897e5d1468e396a40b8918ed7bccf70e5f90b8687"}, ] flake8-comprehensions = [ - {file = "flake8-comprehensions-3.3.0.tar.gz", hash = "sha256:355ef47288523cad7977cb9c1bc81b71c82b7091e425cd9fbcd7e5c19a613677"}, - {file = "flake8_comprehensions-3.3.0-py3-none-any.whl", hash = "sha256:c1dd6d8a00e9722619a5c5e0e6c5747f5cf23c089032c86eaf614c14a2e40adb"}, + {file = "flake8-comprehensions-3.3.1.tar.gz", hash = "sha256:e734bf03806bb562886d9bf635d23a65a1a995c251b67d7e007a7b608af9bd22"}, + {file = "flake8_comprehensions-3.3.1-py3-none-any.whl", hash = "sha256:6d80dfafda0d85633f88ea5bc7de949485f71f1e28db7af7719563fe5f62dcb1"}, ] flake8-debugger = [ {file = "flake8-debugger-3.2.1.tar.gz", hash = "sha256:712d7c1ff69ddf3f0130e94cc88c2519e720760bce45e8c330bfdcb61ab4090d"}, @@ -1184,16 +1175,16 @@ gitdb = [ {file = "gitdb-4.0.5.tar.gz", hash = "sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9"}, ] gitpython = [ - {file = "GitPython-3.1.11-py3-none-any.whl", hash = "sha256:6eea89b655917b500437e9668e4a12eabdcf00229a0df1762aabd692ef9b746b"}, - {file = "GitPython-3.1.11.tar.gz", hash = "sha256:befa4d101f91bad1b632df4308ec64555db684c360bd7d2130b4807d49ce86b8"}, + {file = "GitPython-3.1.14-py3-none-any.whl", hash = "sha256:3283ae2fba31c913d857e12e5ba5f9a7772bbc064ae2bb09efafa71b0dd4939b"}, + {file = "GitPython-3.1.14.tar.gz", hash = "sha256:be27633e7509e58391f10207cd32b2a6cf5b908f92d9cd30da2e514e1137af61"}, ] idna = [ - {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, - {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, + {file = "idna-3.1-py3-none-any.whl", hash = "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16"}, + {file = "idna-3.1.tar.gz", hash = "sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1"}, ] importlib-metadata = [ - {file = "importlib_metadata-2.0.0-py2.py3-none-any.whl", hash = "sha256:cefa1a2f919b866c5beb7c9f7b0ebb4061f30a8a9bf16d609b000e2dfaceb9c3"}, - {file = "importlib_metadata-2.0.0.tar.gz", hash = "sha256:77a540690e24b0305878c37ffd421785a6f7e53c8b5720d211b211de8d0e95da"}, + {file = "importlib_metadata-3.7.0-py3-none-any.whl", hash = "sha256:c6af5dbf1126cd959c4a8d8efd61d4d3c83bddb0459a17e554284a077574b614"}, + {file = "importlib_metadata-3.7.0.tar.gz", hash = "sha256:24499ffde1b80be08284100393955842be4a59c7c16bbf2738aad0e464a8e0aa"}, ] isort = [ {file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"}, @@ -1208,12 +1199,12 @@ jmespath = [ {file = "jmespath-0.10.0.tar.gz", hash = "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9"}, ] joblib = [ - {file = "joblib-1.0.0-py3-none-any.whl", hash = "sha256:75ead23f13484a2a414874779d69ade40d4fa1abe62b222a23cd50d4bc822f6f"}, - {file = "joblib-1.0.0.tar.gz", hash = "sha256:7ad866067ac1fdec27d51c8678ea760601b70e32ff1881d4dc8e1171f2b64b24"}, + {file = "joblib-1.0.1-py3-none-any.whl", hash = "sha256:feeb1ec69c4d45129954f1b7034954241eedfd6ba39b5e9e4b6883be3332d5e5"}, + {file = "joblib-1.0.1.tar.gz", hash = "sha256:9c17567692206d2f3fb9ecf5e991084254fe631665c450b443761c4186a613f7"}, ] jsonpickle = [ - {file = "jsonpickle-1.4.1-py2.py3-none-any.whl", hash = "sha256:8919c166bac0574e3d74425c7559434062002d9dfc0ac2afa6dc746ba4a19439"}, - {file = "jsonpickle-1.4.1.tar.gz", hash = "sha256:e8d4b7cd0bd6826001a74377df1079a76ad8bae0f909282de2554164c837c8ba"}, + {file = "jsonpickle-2.0.0-py2.py3-none-any.whl", hash = "sha256:c1010994c1fbda87a48f8a56698605b598cb0fc6bb7e7927559fc1100e69aeac"}, + {file = "jsonpickle-2.0.0.tar.gz", hash = "sha256:0be49cba80ea6f87a168aa8168d717d00c6ca07ba83df3cec32d3b30bfe6fb9a"}, ] livereload = [ {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"}, @@ -1223,16 +1214,15 @@ lunr = [ {file = "lunr-0.5.8.tar.gz", hash = "sha256:c4fb063b98eff775dd638b3df380008ae85e6cb1d1a24d1cd81a10ef6391c26e"}, ] mako = [ - {file = "Mako-1.1.3-py2.py3-none-any.whl", hash = "sha256:93729a258e4ff0747c876bd9e20df1b9758028946e976324ccd2d68245c7b6a9"}, - {file = "Mako-1.1.3.tar.gz", hash = "sha256:8195c8c1400ceb53496064314c6736719c6f25e7479cd24c77be3d9361cddc27"}, + {file = "Mako-1.1.4.tar.gz", hash = "sha256:17831f0b7087c313c0ffae2bcbbd3c1d5ba9eeac9c38f2eb7b50e8c99fe9d5ab"}, ] mando = [ {file = "mando-0.6.4-py2.py3-none-any.whl", hash = "sha256:4ce09faec7e5192ffc3c57830e26acba0fd6cd11e1ee81af0d4df0657463bd1c"}, {file = "mando-0.6.4.tar.gz", hash = "sha256:79feb19dc0f097daa64a1243db578e7674909b75f88ac2220f1c065c10a0d960"}, ] markdown = [ - {file = "Markdown-3.3.3-py3-none-any.whl", hash = "sha256:c109c15b7dc20a9ac454c9e6025927d44460b85bd039da028d85e2b6d0bcc328"}, - {file = "Markdown-3.3.3.tar.gz", hash = "sha256:5d9f2b5ca24bc4c7a390d22323ca4bad200368612b5aaa7796babf971d2b2f18"}, + {file = "Markdown-3.3.4-py3-none-any.whl", hash = "sha256:96c3ba1261de2f7547b46a00ea8463832c921d3f9d6aba3f255a6f71386db20c"}, + {file = "Markdown-3.3.4.tar.gz", hash = "sha256:31b5b491868dcc87d6c24b7e3d19a0d730d59d3e46f4eea6430a321bed387a49"}, ] markupsafe = [ {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, @@ -1282,23 +1272,23 @@ mkdocs-git-revision-date-plugin = [ {file = "mkdocs_git_revision_date_plugin-0.3.1-py3-none-any.whl", hash = "sha256:8ae50b45eb75d07b150a69726041860801615aae5f4adbd6b1cf4d51abaa03d5"}, ] mkdocs-material = [ - {file = "mkdocs-material-6.2.7.tar.gz", hash = "sha256:6bef9077527be75a60883c60b07a41e59f33b5400009ea10485337f07b981e24"}, - {file = "mkdocs_material-6.2.7-py2.py3-none-any.whl", hash = "sha256:11241769c4bb6ca2a8f0bad7f1ea3bcfba021f9bbe59e2456b5465e759c2202a"}, + {file = "mkdocs-material-6.2.8.tar.gz", hash = "sha256:ce2f4a71e5db49540d71fd32f9afba7645765f7eca391e560d1d27f947eb344c"}, + {file = "mkdocs_material-6.2.8-py2.py3-none-any.whl", hash = "sha256:c9b63d709d29778aa3dafc7178b6a8c655b00937be2594aab016d1423696c792"}, ] mkdocs-material-extensions = [ {file = "mkdocs-material-extensions-1.0.1.tar.gz", hash = "sha256:6947fb7f5e4291e3c61405bad3539d81e0b3cd62ae0d66ced018128af509c68f"}, {file = "mkdocs_material_extensions-1.0.1-py3-none-any.whl", hash = "sha256:d90c807a88348aa6d1805657ec5c0b2d8d609c110e62b9dce4daf7fa981fa338"}, ] more-itertools = [ - {file = "more-itertools-8.6.0.tar.gz", hash = "sha256:b3a9005928e5bed54076e6e549c792b306fddfe72b2d1d22dd63d42d5d3899cf"}, - {file = "more_itertools-8.6.0-py3-none-any.whl", hash = "sha256:8e1a2a43b2f2727425f2b5839587ae37093f19153dc26c0927d1048ff6557330"}, + {file = "more-itertools-8.7.0.tar.gz", hash = "sha256:c5d6da9ca3ff65220c3bfd2a8db06d698f05d4d2b9be57e1deb2be5a45019713"}, + {file = "more_itertools-8.7.0-py3-none-any.whl", hash = "sha256:5652a9ac72209ed7df8d9c15daf4e1aa0e3d2ccd3c87f8265a0673cd9cbc9ced"}, ] nltk = [ {file = "nltk-3.5.zip", hash = "sha256:845365449cd8c5f9731f7cb9f8bd6fd0767553b9d53af9eb1b3abf7700936b35"}, ] packaging = [ - {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, - {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, + {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, + {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, ] pathspec = [ {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, @@ -1316,44 +1306,44 @@ pluggy = [ {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] py = [ - {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"}, - {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"}, + {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, + {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, ] pycodestyle = [ {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, ] pydantic = [ - {file = "pydantic-1.7.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:dfaa6ed1d509b5aef4142084206584280bb6e9014f01df931ec6febdad5b200a"}, - {file = "pydantic-1.7.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:2182ba2a9290964b278bcc07a8d24207de709125d520efec9ad6fa6f92ee058d"}, - {file = "pydantic-1.7.2-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:0fe8b45d31ae53d74a6aa0bf801587bd49970070eac6a6326f9fa2a302703b8a"}, - {file = "pydantic-1.7.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:01f0291f4951580f320f7ae3f2ecaf0044cdebcc9b45c5f882a7e84453362420"}, - {file = "pydantic-1.7.2-cp36-cp36m-win_amd64.whl", hash = "sha256:4ba6b903e1b7bd3eb5df0e78d7364b7e831ed8b4cd781ebc3c4f1077fbcb72a4"}, - {file = "pydantic-1.7.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b11fc9530bf0698c8014b2bdb3bbc50243e82a7fa2577c8cfba660bcc819e768"}, - {file = "pydantic-1.7.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:a3c274c49930dc047a75ecc865e435f3df89715c775db75ddb0186804d9b04d0"}, - {file = "pydantic-1.7.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:c68b5edf4da53c98bb1ccb556ae8f655575cb2e676aef066c12b08c724a3f1a1"}, - {file = "pydantic-1.7.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:95d4410c4e429480c736bba0db6cce5aaa311304aea685ebcf9ee47571bfd7c8"}, - {file = "pydantic-1.7.2-cp37-cp37m-win_amd64.whl", hash = "sha256:a2fc7bf77ed4a7a961d7684afe177ff59971828141e608f142e4af858e07dddc"}, - {file = "pydantic-1.7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b9572c0db13c8658b4a4cb705dcaae6983aeb9842248b36761b3fbc9010b740f"}, - {file = "pydantic-1.7.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:f83f679e727742b0c465e7ef992d6da4a7e5268b8edd8fdaf5303276374bef52"}, - {file = "pydantic-1.7.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:e5fece30e80087d9b7986104e2ac150647ec1658c4789c89893b03b100ca3164"}, - {file = "pydantic-1.7.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:ce2d452961352ba229fe1e0b925b41c0c37128f08dddb788d0fd73fd87ea0f66"}, - {file = "pydantic-1.7.2-cp38-cp38-win_amd64.whl", hash = "sha256:fc21a37ff3f545de80b166e1735c4172b41b017948a3fb2d5e2f03c219eac50a"}, - {file = "pydantic-1.7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c9760d1556ec59ff745f88269a8f357e2b7afc75c556b3a87b8dda5bc62da8ba"}, - {file = "pydantic-1.7.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c1673633ad1eea78b1c5c420a47cd48717d2ef214c8230d96ca2591e9e00958"}, - {file = "pydantic-1.7.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:388c0c26c574ff49bad7d0fd6ed82fbccd86a0473fa3900397d3354c533d6ebb"}, - {file = "pydantic-1.7.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:ab1d5e4d8de00575957e1c982b951bffaedd3204ddd24694e3baca3332e53a23"}, - {file = "pydantic-1.7.2-cp39-cp39-win_amd64.whl", hash = "sha256:f045cf7afb3352a03bc6cb993578a34560ac24c5d004fa33c76efec6ada1361a"}, - {file = "pydantic-1.7.2-py3-none-any.whl", hash = "sha256:6665f7ab7fbbf4d3c1040925ff4d42d7549a8c15fe041164adfe4fc2134d4cce"}, - {file = "pydantic-1.7.2.tar.gz", hash = "sha256:c8200aecbd1fb914e1bd061d71a4d1d79ecb553165296af0c14989b89e90d09b"}, + {file = "pydantic-1.7.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c59ea046aea25be14dc22d69c97bee629e6d48d2b2ecb724d7fe8806bf5f61cd"}, + {file = "pydantic-1.7.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a4143c8d0c456a093387b96e0f5ee941a950992904d88bc816b4f0e72c9a0009"}, + {file = "pydantic-1.7.3-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:d8df4b9090b595511906fa48deda47af04e7d092318bfb291f4d45dfb6bb2127"}, + {file = "pydantic-1.7.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:514b473d264671a5c672dfb28bdfe1bf1afd390f6b206aa2ec9fed7fc592c48e"}, + {file = "pydantic-1.7.3-cp36-cp36m-win_amd64.whl", hash = "sha256:dba5c1f0a3aeea5083e75db9660935da90216f8a81b6d68e67f54e135ed5eb23"}, + {file = "pydantic-1.7.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:59e45f3b694b05a69032a0d603c32d453a23f0de80844fb14d55ab0c6c78ff2f"}, + {file = "pydantic-1.7.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:5b24e8a572e4b4c18f614004dda8c9f2c07328cb5b6e314d6e1bbd536cb1a6c1"}, + {file = "pydantic-1.7.3-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:b2b054d095b6431cdda2f852a6d2f0fdec77686b305c57961b4c5dd6d863bf3c"}, + {file = "pydantic-1.7.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:025bf13ce27990acc059d0c5be46f416fc9b293f45363b3d19855165fee1874f"}, + {file = "pydantic-1.7.3-cp37-cp37m-win_amd64.whl", hash = "sha256:6e3874aa7e8babd37b40c4504e3a94cc2023696ced5a0500949f3347664ff8e2"}, + {file = "pydantic-1.7.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e682f6442ebe4e50cb5e1cfde7dda6766fb586631c3e5569f6aa1951fd1a76ef"}, + {file = "pydantic-1.7.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:185e18134bec5ef43351149fe34fda4758e53d05bb8ea4d5928f0720997b79ef"}, + {file = "pydantic-1.7.3-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:f5b06f5099e163295b8ff5b1b71132ecf5866cc6e7f586d78d7d3fd6e8084608"}, + {file = "pydantic-1.7.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:24ca47365be2a5a3cc3f4a26dcc755bcdc9f0036f55dcedbd55663662ba145ec"}, + {file = "pydantic-1.7.3-cp38-cp38-win_amd64.whl", hash = "sha256:d1fe3f0df8ac0f3a9792666c69a7cd70530f329036426d06b4f899c025aca74e"}, + {file = "pydantic-1.7.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f6864844b039805add62ebe8a8c676286340ba0c6d043ae5dea24114b82a319e"}, + {file = "pydantic-1.7.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:ecb54491f98544c12c66ff3d15e701612fc388161fd455242447083350904730"}, + {file = "pydantic-1.7.3-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:ffd180ebd5dd2a9ac0da4e8b995c9c99e7c74c31f985ba090ee01d681b1c4b95"}, + {file = "pydantic-1.7.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:8d72e814c7821125b16f1553124d12faba88e85405b0864328899aceaad7282b"}, + {file = "pydantic-1.7.3-cp39-cp39-win_amd64.whl", hash = "sha256:475f2fa134cf272d6631072554f845d0630907fce053926ff634cc6bc45bf1af"}, + {file = "pydantic-1.7.3-py3-none-any.whl", hash = "sha256:38be427ea01a78206bcaf9a56f835784afcba9e5b88fbdce33bbbfbcd7841229"}, + {file = "pydantic-1.7.3.tar.gz", hash = "sha256:213125b7e9e64713d16d988d10997dabc6a1f73f3991e1ff8e35ebb1409c7dc9"}, ] pyflakes = [ {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, ] pygments = [ - {file = "Pygments-2.7.4-py3-none-any.whl", hash = "sha256:bc9591213a8f0e0ca1a5e68a479b4887fdc3e75d0774e5c71c31920c427de435"}, - {file = "Pygments-2.7.4.tar.gz", hash = "sha256:df49d09b498e83c1a73128295860250b0b7edd4c723a32e9bc0d295c7c2ec337"}, + {file = "Pygments-2.8.0-py3-none-any.whl", hash = "sha256:b21b072d0ccdf29297a82a2363359d99623597b8a265b8081760e4d0f7153c88"}, + {file = "Pygments-2.8.0.tar.gz", hash = "sha256:37a13ba168a02ac54cc5891a42b1caec333e59b66addb7fa633ea8a6d73445c0"}, ] pymdown-extensions = [ {file = "pymdown-extensions-8.1.1.tar.gz", hash = "sha256:632371fa3bf1b21a0e3f4063010da59b41db049f261f4c0b0872069a9b6d1735"}, @@ -1371,8 +1361,8 @@ pytest-asyncio = [ {file = "pytest-asyncio-0.12.0.tar.gz", hash = "sha256:475bd2f3dc0bc11d2463656b3cbaafdbec5a47b47508ea0b329ee693040eebd2"}, ] pytest-cov = [ - {file = "pytest-cov-2.10.1.tar.gz", hash = "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e"}, - {file = "pytest_cov-2.10.1-py2.py3-none-any.whl", hash = "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191"}, + {file = "pytest-cov-2.11.1.tar.gz", hash = "sha256:359952d9d39b9f822d9d29324483e7ba04a3a17dd7d05aa6beb7ea01e359e5f7"}, + {file = "pytest_cov-2.11.1-py2.py3-none-any.whl", hash = "sha256:bdb9fdb0b85a7cc825269a4c56b48ccaa5c7e365054b6038772c32ddcdc969da"}, ] pytest-mock = [ {file = "pytest-mock-2.0.0.tar.gz", hash = "sha256:b35eb281e93aafed138db25c8772b95d3756108b601947f89af503f8c629413f"}, @@ -1383,19 +1373,27 @@ python-dateutil = [ {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, ] pyyaml = [ - {file = "PyYAML-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f"}, - {file = "PyYAML-5.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76"}, - {file = "PyYAML-5.3.1-cp35-cp35m-win32.whl", hash = "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2"}, - {file = "PyYAML-5.3.1-cp35-cp35m-win_amd64.whl", hash = "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c"}, - {file = "PyYAML-5.3.1-cp36-cp36m-win32.whl", hash = "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2"}, - {file = "PyYAML-5.3.1-cp36-cp36m-win_amd64.whl", hash = "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648"}, - {file = "PyYAML-5.3.1-cp37-cp37m-win32.whl", hash = "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"}, - {file = "PyYAML-5.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf"}, - {file = "PyYAML-5.3.1-cp38-cp38-win32.whl", hash = "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97"}, - {file = "PyYAML-5.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee"}, - {file = "PyYAML-5.3.1-cp39-cp39-win32.whl", hash = "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a"}, - {file = "PyYAML-5.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e"}, - {file = "PyYAML-5.3.1.tar.gz", hash = "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d"}, + {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"}, + {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"}, + {file = "PyYAML-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8"}, + {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"}, + {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"}, + {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"}, + {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"}, + {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"}, + {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"}, + {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"}, + {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"}, + {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"}, + {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"}, + {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"}, + {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"}, + {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"}, + {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"}, + {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"}, + {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"}, + {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, + {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] radon = [ {file = "radon-4.3.2-py2.py3-none-any.whl", hash = "sha256:b991de491eb2edbc2aac8f5f7ebf02b799852f076fa5a73fedf79d144d85e37e"}, @@ -1445,28 +1443,28 @@ regex = [ {file = "regex-2020.11.13.tar.gz", hash = "sha256:83d6b356e116ca119db8e7c6fc2983289d87b27b3fac238cfe5dca529d884562"}, ] requests = [ - {file = "requests-2.25.0-py2.py3-none-any.whl", hash = "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998"}, - {file = "requests-2.25.0.tar.gz", hash = "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8"}, + {file = "requests-2.15.1-py2.py3-none-any.whl", hash = "sha256:ff753b2196cd18b1bbeddc9dcd5c864056599f7a7d9a4fb5677e723efa2b7fb9"}, + {file = "requests-2.15.1.tar.gz", hash = "sha256:e5659b9315a0610505e050bb7190bf6fa2ccee1ac295f2b760ef9d8a03ebbb2e"}, ] s3transfer = [ - {file = "s3transfer-0.3.3-py2.py3-none-any.whl", hash = "sha256:2482b4259524933a022d59da830f51bd746db62f047d6eb213f2f8855dcb8a13"}, - {file = "s3transfer-0.3.3.tar.gz", hash = "sha256:921a37e2aefc64145e7b73d50c71bb4f26f46e4c9f414dc648c6245ff92cf7db"}, + {file = "s3transfer-0.3.4-py2.py3-none-any.whl", hash = "sha256:1e28620e5b444652ed752cf87c7e0cb15b0e578972568c6609f0f18212f259ed"}, + {file = "s3transfer-0.3.4.tar.gz", hash = "sha256:7fdddb4f22275cf1d32129e21f056337fd2a80b6ccef1664528145b72c49e6d2"}, ] six = [ {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, ] smmap = [ - {file = "smmap-3.0.4-py2.py3-none-any.whl", hash = "sha256:54c44c197c819d5ef1991799a7e30b662d1e520f2ac75c9efbeb54a742214cf4"}, - {file = "smmap-3.0.4.tar.gz", hash = "sha256:9c98bbd1f9786d22f14b3d4126894d56befb835ec90cef151af566c7e19b5d24"}, + {file = "smmap-3.0.5-py2.py3-none-any.whl", hash = "sha256:7bfcf367828031dc893530a29cb35eb8c8f2d7c8f2d0989354d75d24c8573714"}, + {file = "smmap-3.0.5.tar.gz", hash = "sha256:84c2751ef3072d4f6b2785ec7ee40244c6f45eb934d9e543e2c51f1bd3d54c50"}, ] stevedore = [ - {file = "stevedore-3.2.2-py3-none-any.whl", hash = "sha256:5e1ab03eaae06ef6ce23859402de785f08d97780ed774948ef16c4652c41bc62"}, - {file = "stevedore-3.2.2.tar.gz", hash = "sha256:f845868b3a3a77a2489d226568abe7328b5c2d4f6a011cc759dfa99144a521f0"}, + {file = "stevedore-3.3.0-py3-none-any.whl", hash = "sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a"}, + {file = "stevedore-3.3.0.tar.gz", hash = "sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee"}, ] testfixtures = [ - {file = "testfixtures-6.15.0-py2.py3-none-any.whl", hash = "sha256:e17f4f526fc90b0ac9bc7f8ca62b7dec17d9faf3d721f56bda4f0fd94d02f85a"}, - {file = "testfixtures-6.15.0.tar.gz", hash = "sha256:409f77cfbdad822d12a8ce5c4aa8fb4d0bb38073f4a5444fede3702716a2cec2"}, + {file = "testfixtures-6.17.1-py2.py3-none-any.whl", hash = "sha256:9ed31e83f59619e2fa17df053b241e16e0608f4580f7b5a9333a0c9bdcc99137"}, + {file = "testfixtures-6.17.1.tar.gz", hash = "sha256:5ec3a0dd6f71cc4c304fbc024a10cc293d3e0b852c868014b9f233203e149bda"}, ] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, @@ -1516,40 +1514,40 @@ tornado = [ {file = "tornado-6.1.tar.gz", hash = "sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791"}, ] tqdm = [ - {file = "tqdm-4.56.0-py2.py3-none-any.whl", hash = "sha256:4621f6823bab46a9cc33d48105753ccbea671b68bab2c50a9f0be23d4065cb5a"}, - {file = "tqdm-4.56.0.tar.gz", hash = "sha256:fe3d08dd00a526850568d542ff9de9bbc2a09a791da3c334f3213d8d0bbbca65"}, + {file = "tqdm-4.58.0-py2.py3-none-any.whl", hash = "sha256:2c44efa73b8914dba7807aefd09653ac63c22b5b4ea34f7a80973f418f1a3089"}, + {file = "tqdm-4.58.0.tar.gz", hash = "sha256:c23ac707e8e8aabb825e4d91f8e17247f9cc14b0d64dd9e97be0781e9e525bba"}, ] typed-ast = [ - {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, - {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, - {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, - {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"}, - {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, - {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, - {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, - {file = "typed_ast-1.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f"}, - {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, - {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, - {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, - {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, - {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, - {file = "typed_ast-1.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298"}, - {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, - {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, - {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, - {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, - {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, - {file = "typed_ast-1.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d"}, - {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, - {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, - {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, - {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c"}, - {file = "typed_ast-1.4.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072"}, - {file = "typed_ast-1.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91"}, - {file = "typed_ast-1.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d"}, - {file = "typed_ast-1.4.1-cp39-cp39-win32.whl", hash = "sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395"}, - {file = "typed_ast-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c"}, - {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, + {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70"}, + {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487"}, + {file = "typed_ast-1.4.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412"}, + {file = "typed_ast-1.4.2-cp35-cp35m-win32.whl", hash = "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400"}, + {file = "typed_ast-1.4.2-cp35-cp35m-win_amd64.whl", hash = "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606"}, + {file = "typed_ast-1.4.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64"}, + {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07"}, + {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc"}, + {file = "typed_ast-1.4.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a"}, + {file = "typed_ast-1.4.2-cp36-cp36m-win32.whl", hash = "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151"}, + {file = "typed_ast-1.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3"}, + {file = "typed_ast-1.4.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41"}, + {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f"}, + {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581"}, + {file = "typed_ast-1.4.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37"}, + {file = "typed_ast-1.4.2-cp37-cp37m-win32.whl", hash = "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd"}, + {file = "typed_ast-1.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496"}, + {file = "typed_ast-1.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc"}, + {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10"}, + {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea"}, + {file = "typed_ast-1.4.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787"}, + {file = "typed_ast-1.4.2-cp38-cp38-win32.whl", hash = "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2"}, + {file = "typed_ast-1.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937"}, + {file = "typed_ast-1.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1"}, + {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6"}, + {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166"}, + {file = "typed_ast-1.4.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d"}, + {file = "typed_ast-1.4.2-cp39-cp39-win32.whl", hash = "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b"}, + {file = "typed_ast-1.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440"}, + {file = "typed_ast-1.4.2.tar.gz", hash = "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a"}, ] typing-extensions = [ {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, @@ -1557,8 +1555,8 @@ typing-extensions = [ {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, ] urllib3 = [ - {file = "urllib3-1.26.2-py2.py3-none-any.whl", hash = "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"}, - {file = "urllib3-1.26.2.tar.gz", hash = "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08"}, + {file = "urllib3-1.26.3-py2.py3-none-any.whl", hash = "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80"}, + {file = "urllib3-1.26.3.tar.gz", hash = "sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73"}, ] wcwidth = [ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, diff --git a/tests/functional/test_metrics.py b/tests/functional/test_metrics.py index a8dc4e61656..6386e76e42f 100644 --- a/tests/functional/test_metrics.py +++ b/tests/functional/test_metrics.py @@ -77,7 +77,7 @@ def metadata() -> Dict[str, str]: @pytest.fixture -def a_hundred_metrics(namespace=namespace) -> List[Dict[str, str]]: +def a_hundred_metrics() -> List[Dict[str, str]]: return [{"name": f"metric_{i}", "unit": "Count", "value": 1} for i in range(100)] @@ -257,7 +257,7 @@ def test_schema_validation_no_namespace(metric, dimension): # GIVEN we don't add any namespace # WHEN we attempt to serialize a valid EMF object # THEN it should fail namespace validation - with pytest.raises(SchemaValidationError, match=".*Namespace must be string"): + with pytest.raises(SchemaValidationError, match="Must contain a metric namespace."): with single_metric(**metric) as my_metric: my_metric.add_dimension(**dimension) @@ -278,7 +278,7 @@ def test_schema_no_metrics(service, namespace): my_metrics = Metrics(service=service, namespace=namespace) # THEN it should fail validation and raise SchemaValidationError - with pytest.raises(SchemaValidationError, match=".*Metrics must contain at least 1 items"): + with pytest.raises(SchemaValidationError, match="Must contain at least one metric."): my_metrics.serialize_metric_set() @@ -288,7 +288,7 @@ def test_exceed_number_of_dimensions(metric, namespace): # WHEN we attempt to serialize them into a valid EMF object # THEN it should fail validation and raise SchemaValidationError - with pytest.raises(SchemaValidationError, match="must contain less than or equal to 9 items"): + with pytest.raises(SchemaValidationError, match="Maximum number of dimensions exceeded.*"): with single_metric(**metric, namespace=namespace) as my_metric: for dimension in dimensions: my_metric.add_dimension(**dimension) @@ -328,7 +328,7 @@ def lambda_handler(evt, context): # THEN the raised exception should be SchemaValidationError # and specifically about the lack of Metrics - with pytest.raises(SchemaValidationError, match=".*Metrics must contain at least 1 items"): + with pytest.raises(SchemaValidationError, match="Must contain at least one metric."): lambda_handler({}, {}) diff --git a/tests/performance/conftest.py b/tests/performance/conftest.py new file mode 100644 index 00000000000..30cb371ca87 --- /dev/null +++ b/tests/performance/conftest.py @@ -0,0 +1,18 @@ +import time +from contextlib import contextmanager +from typing import Generator + + +@contextmanager +def timing() -> Generator: + """ "Generator to quickly time operations. It can add 5ms so take that into account in elapsed time + + Examples + -------- + + with timing() as t: + print("something") + elapsed = t() + """ + start = time.perf_counter() + yield lambda: time.perf_counter() - start # gen as lambda to calculate elapsed time diff --git a/tests/performance/test_metrics.py b/tests/performance/test_metrics.py new file mode 100644 index 00000000000..bfbe67b60b1 --- /dev/null +++ b/tests/performance/test_metrics.py @@ -0,0 +1,87 @@ +import json +import time +from contextlib import contextmanager +from typing import Dict, Generator + +import pytest + +from aws_lambda_powertools import Metrics +from aws_lambda_powertools.metrics import MetricUnit +from aws_lambda_powertools.metrics import metrics as metrics_global + +# adjusted for slower machines in CI too +METRICS_VALIDATION_SLA: float = 0.0013 +METRICS_SERIALIZATION_SLA: float = 0.0013 + + +@contextmanager +def timing() -> Generator: + """ "Generator to quickly time operations. It can add 5ms so take that into account in elapsed time + + Examples + -------- + + with timing() as t: + print("something") + elapsed = t() + """ + start = time.perf_counter() + yield lambda: time.perf_counter() - start # gen as lambda to calculate elapsed time + + +@pytest.fixture(scope="function", autouse=True) +def reset_metric_set(): + metrics = Metrics() + metrics.clear_metrics() + metrics_global.is_cold_start = True # ensure each test has cold start + yield + + +@pytest.fixture +def namespace() -> str: + return "test_namespace" + + +@pytest.fixture +def metric() -> Dict[str, str]: + return {"name": "single_metric", "unit": MetricUnit.Count, "value": 1} + + +def add_max_metrics_before_serialization(metrics_instance: Metrics): + metrics_instance.add_dimension(name="test_dimension", value="test") + + for i in range(99): + metrics_instance.add_metric(name=f"metric_{i}", unit="Count", value=1) + + +@pytest.mark.perf +def test_metrics_large_operation_without_json_serialization_sla(namespace): + # GIVEN Metrics is initialized + my_metrics = Metrics(namespace=namespace) + + # WHEN we add and serialize 99 metrics + with timing() as t: + add_max_metrics_before_serialization(metrics_instance=my_metrics) + my_metrics.serialize_metric_set() + + # THEN completion time should be below our validation SLA + elapsed = t() + if elapsed > METRICS_VALIDATION_SLA: + pytest.fail(f"Metric validation should be below {METRICS_VALIDATION_SLA}s: {elapsed}") + + +@pytest.mark.perf +def test_metrics_large_operation_and_json_serialization_sla(namespace): + # GIVEN Metrics is initialized with validation disabled + my_metrics = Metrics(namespace=namespace) + + # WHEN we add and serialize 99 metrics + with timing() as t: + add_max_metrics_before_serialization(metrics_instance=my_metrics) + metrics = my_metrics.serialize_metric_set() + print(json.dumps(metrics, separators=(",", ":"))) + + # THEN completion time should be below our serialization SLA + elapsed = t() + if elapsed > METRICS_SERIALIZATION_SLA: + pytest.fail(f"Metric serialization should be below {METRICS_SERIALIZATION_SLA}s: {elapsed}") From 153567e0c3bdb15a5e118cfa17ff3c649e02c1c2 Mon Sep 17 00:00:00 2001 From: Heitor Lessa Date: Thu, 4 Mar 2021 19:23:43 +0100 Subject: [PATCH 15/21] docs(batch): add example on how to integrate with sentry.io (#308) --- docs/utilities/batch.md | 156 ++++++++++++++++++++++++++-------------- 1 file changed, 104 insertions(+), 52 deletions(-) diff --git a/docs/utilities/batch.md b/docs/utilities/batch.md index aa284e7f38b..ca4606e0f40 100644 --- a/docs/utilities/batch.md +++ b/docs/utilities/batch.md @@ -5,13 +5,13 @@ description: Utility The SQS batch processing utility provides a way to handle partial failures when processing batches of messages from SQS. -**Key Features** +## Key Features * Prevent successfully processed messages being returned to SQS * Simple interface for individually processing messages from a batch * Build your own batch processor using the base classes -**Background** +## Background When using SQS as a Lambda event source mapping, Lambda functions are triggered with a batch of messages from SQS. @@ -25,35 +25,76 @@ are returned to the queue. More details on how Lambda works with SQS can be found in the [AWS documentation](https://docs.aws.amazon.com/lambda/latest/dg/with-sqs.html) +## Getting started -**IAM Permissions** +### IAM Permissions -This utility requires additional permissions to work as expected. Lambda functions using this utility require the `sqs:DeleteMessageBatch` permission. +Before your use this utility, your AWS Lambda function must have `sqs:DeleteMessageBatch` permission to delete successful messages directly from the queue. -## Processing messages from SQS +> Example using AWS Serverless Application Model (SAM) -You can use either **[sqs_batch_processor](#sqs_batch_processor-decorator)** decorator, or **[PartialSQSProcessor](#partialsqsprocessor-context-manager)** as a context manager. +=== "template.yml" + ```yaml hl_lines="2-3 12-15" + Resources: + MyQueue: + Type: AWS::SQS::Queue -They have nearly the same behaviour when it comes to processing messages from the batch: + HelloWorldFunction: + Type: AWS::Serverless::Function + Properties: + Runtime: python3.8 + Environment: + Variables: + POWERTOOLS_SERVICE_NAME: example + Policies: + - SQSPollerPolicy: + QueueName: + !GetAtt MyQueue.QueueName + ``` -* **Entire batch has been successfully processed**, where your Lambda handler returned successfully, we will let SQS delete the batch to optimize your cost -* **Entire Batch has been partially processed successfully**, where exceptions were raised within your `record handler`, we will: - - **1)** Delete successfully processed messages from the queue by directly calling `sqs:DeleteMessageBatch` - - **2)** Raise `SQSBatchProcessingError` to ensure failed messages return to your SQS queue +### Processing messages from SQS -The only difference is that **PartialSQSProcessor** will give you access to processed messages if you need. +You can use either **[sqs_batch_processor](#sqs_batch_processor-decorator)** decorator, or **[PartialSQSProcessor](#partialsqsprocessor-context-manager)** as a context manager if you'd like access to the processed results. -## Record Handler +You need to create a function to handle each record from the batch - We call it `record_handler` from here on. -Both decorator and context managers require an explicit function to process the batch of messages - namely `record_handler` parameter. +=== "Decorator" -This function is responsible for processing each individual message from the batch, and to raise an exception if unable to process any of the messages sent. + ```python hl_lines="3 6" + from aws_lambda_powertools.utilities.batch import sqs_batch_processor -**Any non-exception/successful return from your record handler function** will instruct both decorator and context manager to queue up each individual message for deletion. + def record_handler(record): + return do_something_with(record["body"]) -### sqs_batch_processor decorator + @sqs_batch_processor(record_handler=record_handler) + def lambda_handler(event, context): + return {"statusCode": 200} + ``` +=== "Context manager" -When using this decorator, you need provide a function via `record_handler` param that will process individual messages from the batch - It should raise an exception if it is unable to process the record. + ```python hl_lines="3 9 11-12" + from aws_lambda_powertools.utilities.batch import PartialSQSProcessor + + def record_handler(record): + return_value = do_something_with(record["body"]) + return return_value + + def lambda_handler(event, context): + records = event["Records"] + processor = PartialSQSProcessor() + + with processor(records, record_handler) as proc: + result = proc.process() # Returns a list of all results from record_handler + + return result + ``` + +!!! tip + **Any non-exception/successful return from your record handler function** will instruct both decorator and context manager to queue up each individual message for deletion. + + If the entire batch succeeds, we let Lambda to proceed in deleting the records from the queue for cost reasons. + +### Partial failure mechanics All records in the batch will be passed to this handler for processing, even if exceptions are thrown - Here's the behaviour after completing the batch: @@ -61,29 +102,26 @@ All records in the batch will be passed to this handler for processing, even if * **Any unprocessed messages detected**, we will raise `SQSBatchProcessingError` to ensure failed messages return to your SQS queue !!! warning - You will not have accessed to the processed messages within the Lambda Handler - all processing logic will and should be performed by the record_handler function. + You will not have accessed to the **processed messages** within the Lambda Handler. -=== "app.py" + All processing logic will and should be performed by the `record_handler` function. - ```python - from aws_lambda_powertools.utilities.batch import sqs_batch_processor +## Advanced - def record_handler(record): - # This will be called for each individual message from a batch - # It should raise an exception if the message was not processed successfully - return_value = do_something_with(record["body"]) - return return_value +### Choosing between decorator and context manager - @sqs_batch_processor(record_handler=record_handler) - def lambda_handler(event, context): - return {"statusCode": 200} - ``` +They have nearly the same behaviour when it comes to processing messages from the batch: -### PartialSQSProcessor context manager +* **Entire batch has been successfully processed**, where your Lambda handler returned successfully, we will let SQS delete the batch to optimize your cost +* **Entire Batch has been partially processed successfully**, where exceptions were raised within your `record handler`, we will: + - **1)** Delete successfully processed messages from the queue by directly calling `sqs:DeleteMessageBatch` + - **2)** Raise `SQSBatchProcessingError` to ensure failed messages return to your SQS queue + +The only difference is that **PartialSQSProcessor** will give you access to processed messages if you need. -If you require access to the result of processed messages, you can use this context manager. +### Accessing processed messages -The result from calling `process()` on the context manager will be a list of all the return values from your `record_handler` function. +Use `PartialSQSProcessor` context manager to access a list of all return values from your `record_handler` function. === "app.py" @@ -91,11 +129,7 @@ The result from calling `process()` on the context manager will be a list of all from aws_lambda_powertools.utilities.batch import PartialSQSProcessor def record_handler(record): - # This will be called for each individual message from a batch - # It should raise an exception if the message was not processed successfully - return_value = do_something_with(record["body"]) - return return_value - + return do_something_with(record["body"]) def lambda_handler(event, context): records = event["Records"] @@ -108,7 +142,7 @@ The result from calling `process()` on the context manager will be a list of all return result ``` -## Passing custom boto3 config +### Passing custom boto3 config If you need to pass custom configuration such as region to the SDK, you can pass your own [botocore config object](https://botocore.amazonaws.com/v1/documentation/api/latest/reference/config.html) to the `sqs_batch_processor` decorator: @@ -159,14 +193,15 @@ the `sqs_batch_processor` decorator: ``` -## Suppressing exceptions +### Suppressing exceptions If you want to disable the default behavior where `SQSBatchProcessingError` is raised if there are any errors, you can pass the `suppress_exception` boolean argument. === "Decorator" - ```python hl_lines="2" - ... + ```python hl_lines="3" + from aws_lambda_powertools.utilities.batch import sqs_batch_processor + @sqs_batch_processor(record_handler=record_handler, config=config, suppress_exception=True) def lambda_handler(event, context): return {"statusCode": 200} @@ -174,15 +209,16 @@ If you want to disable the default behavior where `SQSBatchProcessingError` is r === "Context manager" - ```python hl_lines="2" - ... + ```python hl_lines="3" + from aws_lambda_powertools.utilities.batch import PartialSQSProcessor + processor = PartialSQSProcessor(config=config, suppress_exception=True) with processor(records, record_handler): result = processor.process() ``` -## Create your own partial processor +### Create your own partial processor You can create your own partial batch processor by inheriting the `BasePartialProcessor` class, and implementing `_prepare()`, `_clean()` and `_process_record()`. @@ -192,11 +228,9 @@ You can create your own partial batch processor by inheriting the `BasePartialPr You can then use this class as a context manager, or pass it to `batch_processor` to use as a decorator on your Lambda handler function. -**Example:** - === "custom_processor.py" - ```python + ```python hl_lines="3 9 24 30 37 57" from random import randint from aws_lambda_powertools.utilities.batch import BasePartialProcessor, batch_processor @@ -223,14 +257,12 @@ You can then use this class as a context manager, or pass it to `batch_processor def _prepare(self): # It's called once, *before* processing # Creates table resource and clean previous results - # E.g.: self.ddb_table = boto3.resource("dynamodb").Table(self.table_name) self.success_messages.clear() def _clean(self): # It's called once, *after* closing processing all records (closing the context manager) # Here we're sending, at once, all successful messages to a ddb table - # E.g.: with ddb_table.batch_writer() as batch: for result in self.success_messages: batch.put_item(Item=result) @@ -239,7 +271,6 @@ You can then use this class as a context manager, or pass it to `batch_processor # It handles how your record is processed # Here we're keeping the status of each run # where self.handler is the record_handler function passed as an argument - # E.g.: try: result = self.handler(record) # record_handler passed to decorator/context manager return self.success_handler(record, result) @@ -260,3 +291,24 @@ You can then use this class as a context manager, or pass it to `batch_processor def lambda_handler(event, context): return {"statusCode": 200} ``` + +### Integrating exception handling with Sentry.io + +When using Sentry.io for error monitoring, you can override `failure_handler` to include to capture each processing exception: + +> Credits to [Charles-Axel Dein](https://github.com/awslabs/aws-lambda-powertools-python/issues/293#issuecomment-781961732) + +=== "sentry_integration.py" + + ```python hl_lines="4 7-8" + from typing import Tuple + + from aws_lambda_powertools.utilities.batch import PartialSQSProcessor + from sentry_sdk import capture_exception + + class SQSProcessor(PartialSQSProcessor): + def failure_handler(self, record: Event, exception: Tuple) -> Tuple: # type: ignore + capture_exception() # send exception to Sentry + logger.exception("got exception while processing SQS message") + return super().failure_handler(record, exception) # type: ignore + ``` From 9763bbe7413fb4b3f4e0699b968e4e048ddd4e8c Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Fri, 5 Mar 2021 02:30:22 -0800 Subject: [PATCH 16/21] refactor(idempotent): Change UX to use a config class for non-persistence related features (#306) * refactor(idempotent): Create a config class * fix: Reanable test * chore: Some refactoring * docs: Update docs * docs(batch): add example on how to integrate with sentry.io (#308) * chore(docs): Update the docs * fix(tests): Fix coverage-html and various update * refactor: Change back to configure * chore: Hide missing code coverage * chore: bump ci * chore: bump ci Co-authored-by: Heitor Lessa --- Makefile | 2 +- aws_lambda_powertools/tracing/extensions.py | 6 +- .../utilities/idempotency/__init__.py | 4 +- .../utilities/idempotency/config.py | 43 +++++ .../utilities/idempotency/idempotency.py | 17 +- .../utilities/idempotency/persistence/base.py | 87 ++++----- .../idempotency/persistence/dynamodb.py | 8 +- docs/utilities/idempotency.md | 113 +++++++---- examples/__init__.py | 0 tests/functional/idempotency/conftest.py | 34 ++-- .../idempotency/test_idempotency.py | 182 ++++++++++++------ 11 files changed, 311 insertions(+), 185 deletions(-) create mode 100644 aws_lambda_powertools/utilities/idempotency/config.py create mode 100644 examples/__init__.py diff --git a/Makefile b/Makefile index e56eb4bb266..d11ea72779f 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ test: poetry run pytest --cache-clear tests/performance coverage-html: - poetry run pytest --cov-report html + poetry run pytest -m "not perf" --cov-report=html pr: lint test security-baseline complexity-baseline diff --git a/aws_lambda_powertools/tracing/extensions.py b/aws_lambda_powertools/tracing/extensions.py index 2bb0125e841..6c641238c98 100644 --- a/aws_lambda_powertools/tracing/extensions.py +++ b/aws_lambda_powertools/tracing/extensions.py @@ -8,8 +8,8 @@ def aiohttp_trace_config(): TraceConfig aiohttp trace config """ - from aws_xray_sdk.ext.aiohttp.client import aws_xray_trace_config + from aws_xray_sdk.ext.aiohttp.client import aws_xray_trace_config # pragma: no cover - aws_xray_trace_config.__doc__ = "aiohttp extension for X-Ray (aws_xray_trace_config)" + aws_xray_trace_config.__doc__ = "aiohttp extension for X-Ray (aws_xray_trace_config)" # pragma: no cover - return aws_xray_trace_config() + return aws_xray_trace_config() # pragma: no cover diff --git a/aws_lambda_powertools/utilities/idempotency/__init__.py b/aws_lambda_powertools/utilities/idempotency/__init__.py index 98e2be15415..b46d0855a93 100644 --- a/aws_lambda_powertools/utilities/idempotency/__init__.py +++ b/aws_lambda_powertools/utilities/idempotency/__init__.py @@ -5,6 +5,6 @@ from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer from aws_lambda_powertools.utilities.idempotency.persistence.dynamodb import DynamoDBPersistenceLayer -from .idempotency import idempotent +from .idempotency import IdempotencyConfig, idempotent -__all__ = ("DynamoDBPersistenceLayer", "BasePersistenceLayer", "idempotent") +__all__ = ("DynamoDBPersistenceLayer", "BasePersistenceLayer", "idempotent", "IdempotencyConfig") diff --git a/aws_lambda_powertools/utilities/idempotency/config.py b/aws_lambda_powertools/utilities/idempotency/config.py new file mode 100644 index 00000000000..52afb3bad8c --- /dev/null +++ b/aws_lambda_powertools/utilities/idempotency/config.py @@ -0,0 +1,43 @@ +from typing import Dict + + +class IdempotencyConfig: + def __init__( + self, + event_key_jmespath: str = "", + payload_validation_jmespath: str = "", + jmespath_options: Dict = None, + raise_on_no_idempotency_key: bool = False, + expires_after_seconds: int = 60 * 60, # 1 hour default + use_local_cache: bool = False, + local_cache_max_items: int = 256, + hash_function: str = "md5", + ): + """ + Initialize the base persistence layer + + Parameters + ---------- + event_key_jmespath: str + A jmespath expression to extract the idempotency key from the event record + payload_validation_jmespath: str + A jmespath expression to extract the payload to be validated from the event record + raise_on_no_idempotency_key: bool, optional + Raise exception if no idempotency key was found in the request, by default False + expires_after_seconds: int + The number of seconds to wait before a record is expired + use_local_cache: bool, optional + Whether to locally cache idempotency results, by default False + local_cache_max_items: int, optional + Max number of items to store in local cache, by default 1024 + hash_function: str, optional + Function to use for calculating hashes, by default md5. + """ + self.event_key_jmespath = event_key_jmespath + self.payload_validation_jmespath = payload_validation_jmespath + self.jmespath_options = jmespath_options + self.raise_on_no_idempotency_key = raise_on_no_idempotency_key + self.expires_after_seconds = expires_after_seconds + self.use_local_cache = use_local_cache + self.local_cache_max_items = local_cache_max_items + self.hash_function = hash_function diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py index bc556f49912..235e5c884d6 100644 --- a/aws_lambda_powertools/utilities/idempotency/idempotency.py +++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py @@ -5,6 +5,7 @@ from typing import Any, Callable, Dict, Optional from aws_lambda_powertools.middleware_factory import lambda_handler_decorator +from aws_lambda_powertools.utilities.idempotency.config import IdempotencyConfig from aws_lambda_powertools.utilities.idempotency.exceptions import ( IdempotencyAlreadyInProgressError, IdempotencyInconsistentStateError, @@ -29,6 +30,7 @@ def idempotent( event: Dict[str, Any], context: LambdaContext, persistence_store: BasePersistenceLayer, + config: IdempotencyConfig = None, ) -> Any: """ Middleware to handle idempotency @@ -43,20 +45,25 @@ def idempotent( Lambda's Context persistence_store: BasePersistenceLayer Instance of BasePersistenceLayer to store data + config: IdempotencyConfig + Configutation Examples -------- **Processes Lambda's event in an idempotent manner** - >>> from aws_lambda_powertools.utilities.idempotency import idempotent, DynamoDBPersistenceLayer + >>> from aws_lambda_powertools.utilities.idempotency import ( + >>> idempotent, DynamoDBPersistenceLayer, IdempotencyConfig + >>> ) >>> - >>> persistence_store = DynamoDBPersistenceLayer(event_key_jmespath="body", table_name="idempotency_store") + >>> idem_config=IdempotencyConfig(event_key_jmespath="body") + >>> persistence_layer = DynamoDBPersistenceLayer(table_name="idempotency_store") >>> - >>> @idempotent(persistence_store=persistence_store) + >>> @idempotent(config=idem_config, persistence_store=persistence_layer) >>> def handler(event, context): >>> return {"StatusCode": 200} """ - idempotency_handler = IdempotencyHandler(handler, event, context, persistence_store) + idempotency_handler = IdempotencyHandler(handler, event, context, config or IdempotencyConfig(), persistence_store) # IdempotencyInconsistentStateError can happen under rare but expected cases when persistent state changes in the # small time between put & get requests. In most cases we can retry successfully on this exception. @@ -82,6 +89,7 @@ def __init__( lambda_handler: Callable[[Any, LambdaContext], Any], event: Dict[str, Any], context: LambdaContext, + config: IdempotencyConfig, persistence_store: BasePersistenceLayer, ): """ @@ -98,6 +106,7 @@ def __init__( persistence_store : BasePersistenceLayer Instance of persistence layer to store idempotency records """ + persistence_store.configure(config) self.persistence_store = persistence_store self.context = context self.event = event diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index 352ba40b5f6..58f67a292e7 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -9,13 +9,14 @@ import warnings from abc import ABC, abstractmethod from types import MappingProxyType -from typing import Any, Dict +from typing import Any, Dict, Optional import jmespath from aws_lambda_powertools.shared.cache_dict import LRUDict from aws_lambda_powertools.shared.jmespath_functions import PowertoolsFunctions from aws_lambda_powertools.shared.json_encoder import Encoder +from aws_lambda_powertools.utilities.idempotency.config import IdempotencyConfig from aws_lambda_powertools.utilities.idempotency.exceptions import ( IdempotencyInvalidStatusError, IdempotencyItemAlreadyExistsError, @@ -107,55 +108,49 @@ class BasePersistenceLayer(ABC): Abstract Base Class for Idempotency persistence layer. """ - def __init__( - self, - event_key_jmespath: str = "", - payload_validation_jmespath: str = "", - expires_after_seconds: int = 60 * 60, # 1 hour default - use_local_cache: bool = False, - local_cache_max_items: int = 256, - hash_function: str = "md5", - raise_on_no_idempotency_key: bool = False, - jmespath_options: Dict = None, - ) -> None: + def __init__(self): + """Initialize the defaults """ + self.configured = False + self.event_key_jmespath: Optional[str] = None + self.event_key_compiled_jmespath = None + self.jmespath_options: Optional[dict] = None + self.payload_validation_enabled = False + self.validation_key_jmespath = None + self.raise_on_no_idempotency_key = False + self.expires_after_seconds: int = 60 * 60 # 1 hour default + self.use_local_cache = False + self._cache: Optional[LRUDict] = None + self.hash_function = None + + def configure(self, config: IdempotencyConfig) -> None: """ - Initialize the base persistence layer + Initialize the base persistence layer from the configuration settings Parameters ---------- - event_key_jmespath: str - A jmespath expression to extract the idempotency key from the event record - payload_validation_jmespath: str - A jmespath expression to extract the payload to be validated from the event record - expires_after_seconds: int - The number of seconds to wait before a record is expired - use_local_cache: bool, optional - Whether to locally cache idempotency results, by default False - local_cache_max_items: int, optional - Max number of items to store in local cache, by default 1024 - hash_function: str, optional - Function to use for calculating hashes, by default md5. - raise_on_no_idempotency_key: bool, optional - Raise exception if no idempotency key was found in the request, by default False - jmespath_options : Dict - Alternative JMESPath options to be included when filtering expr - """ - self.event_key_jmespath = event_key_jmespath - if self.event_key_jmespath: - self.event_key_compiled_jmespath = jmespath.compile(event_key_jmespath) - self.expires_after_seconds = expires_after_seconds - self.use_local_cache = use_local_cache - if self.use_local_cache: - self._cache = LRUDict(max_items=local_cache_max_items) - self.payload_validation_enabled = False - if payload_validation_jmespath: - self.validation_key_jmespath = jmespath.compile(payload_validation_jmespath) + config: IdempotencyConfig + Idempotency configuration settings + """ + if self.configured: + # Prevent being reconfigured multiple times + return + self.configured = True + + self.event_key_jmespath = config.event_key_jmespath + if config.event_key_jmespath: + self.event_key_compiled_jmespath = jmespath.compile(config.event_key_jmespath) + self.jmespath_options = config.jmespath_options + if not self.jmespath_options: + self.jmespath_options = {"custom_functions": PowertoolsFunctions()} + if config.payload_validation_jmespath: + self.validation_key_jmespath = jmespath.compile(config.payload_validation_jmespath) self.payload_validation_enabled = True - self.hash_function = getattr(hashlib, hash_function) - self.raise_on_no_idempotency_key = raise_on_no_idempotency_key - if not jmespath_options: - jmespath_options = {"custom_functions": PowertoolsFunctions()} - self.jmespath_options = jmespath_options + self.raise_on_no_idempotency_key = config.raise_on_no_idempotency_key + self.expires_after_seconds = config.expires_after_seconds + self.use_local_cache = config.use_local_cache + if self.use_local_cache: + self._cache = LRUDict(max_items=config.local_cache_max_items) + self.hash_function = getattr(hashlib, config.hash_function) def _get_hashed_idempotency_key(self, lambda_event: Dict[str, Any]) -> str: """ @@ -180,9 +175,9 @@ def _get_hashed_idempotency_key(self, lambda_event: Dict[str, Any]) -> str: ) if self.is_missing_idempotency_key(data): - warnings.warn(f"No value found for idempotency_key. jmespath: {self.event_key_jmespath}") if self.raise_on_no_idempotency_key: raise IdempotencyKeyError("No data found to create a hashed idempotency_key") + warnings.warn(f"No value found for idempotency_key. jmespath: {self.event_key_jmespath}") return self._generate_hash(data) diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py index 4d66448755d..d87cd71ff4e 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py @@ -26,8 +26,6 @@ def __init__( validation_key_attr: str = "validation", boto_config: Optional[Config] = None, boto3_session: Optional[boto3.session.Session] = None, - *args, - **kwargs, ): """ Initialize the DynamoDB client @@ -57,9 +55,9 @@ def __init__( **Create a DynamoDB persistence layer with custom settings** >>> from aws_lambda_powertools.utilities.idempotency import idempotent, DynamoDBPersistenceLayer >>> - >>> persistence_store = DynamoDBPersistenceLayer(event_key="body", table_name="idempotency_store") + >>> persistence_store = DynamoDBPersistenceLayer(table_name="idempotency_store") >>> - >>> @idempotent(persistence_store=persistence_store) + >>> @idempotent(persistence_store=persistence_store, event_key="body") >>> def handler(event, context): >>> return {"StatusCode": 200} """ @@ -74,7 +72,7 @@ def __init__( self.status_attr = status_attr self.data_attr = data_attr self.validation_key_attr = validation_key_attr - super(DynamoDBPersistenceLayer, self).__init__(*args, **kwargs) + super(DynamoDBPersistenceLayer, self).__init__() def _item_to_data_record(self, item: Dict[str, Any]) -> DataRecord: """ diff --git a/docs/utilities/idempotency.md b/docs/utilities/idempotency.md index 8ea23bde5ae..784f6597a23 100644 --- a/docs/utilities/idempotency.md +++ b/docs/utilities/idempotency.md @@ -75,7 +75,7 @@ storage layer, so you'll need to create a table first. You can quickly start by initializing the `DynamoDBPersistenceLayer` class outside the Lambda handler, and using it with the `idempotent` decorator on your lambda handler. The only required parameter is `table_name`, but you likely -want to specify `event_key_jmespath` as well. +want to specify `event_key_jmespath` via `IdempotencyConfig` class. `event_key_jmespath`: A JMESpath expression which will be used to extract the payload from the event your Lambda handler is called with. This payload will be used as the key to decide if future invocations are duplicates. If you don't pass @@ -83,24 +83,24 @@ this parameter, the entire event will be used as the key. === "app.py" - ```python hl_lines="2 6-9 11" + ```python hl_lines="2-4 8-9 11" import json - from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent + from aws_lambda_powertools.utilities.idempotency import ( + IdempotencyConfig, DynamoDBPersistenceLayer, idempotent + ) # Treat everything under the "body" key in # the event json object as our payload - persistence_layer = DynamoDBPersistenceLayer( - table_name="IdempotencyTable", - event_key_jmespath="body", - ) + config = IdempotencyConfig(event_key_jmespath="body") + persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") - @idempotent(persistence_store=persistence_layer) + @idempotent(config=config, persistence_store=persistence_layer) def handler(event, context): body = json.loads(event['body']) payment = create_subscription_payment( user=body['user'], product=body['product_id'] - ) + ) ... return {"message": "success", "statusCode": 200, "payment_id": payment.id} ``` @@ -174,9 +174,8 @@ change this window with the `expires_after_seconds` parameter: === "app.py" - ```python hl_lines="4" - DynamoDBPersistenceLayer( - table_name="IdempotencyTable", + ```python hl_lines="3" + IdempotencyConfig( event_key_jmespath="body", expires_after_seconds=5*60, # 5 minutes ) @@ -203,9 +202,8 @@ execution environment. You can change this with the `local_cache_max_items` para === "app.py" - ```python hl_lines="4 5" - DynamoDBPersistenceLayer( - table_name="IdempotencyTable", + ```python hl_lines="3 4" + IdempotencyConfig( event_key_jmespath="body", use_local_cache=True, local_cache_max_items=1000 @@ -224,16 +222,18 @@ idempotent invocations. === "app.py" - ```python hl_lines="6" - from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent + ```python hl_lines="7" + from aws_lambda_powertools.utilities.idempotency import ( + IdempotencyConfig, DynamoDBPersistenceLayer, idempotent + ) - persistence_layer = DynamoDBPersistenceLayer( - table_name="IdempotencyTable", + config = IdempotencyConfig( event_key_jmespath="[userDetail, productId]", payload_validation_jmespath="amount" ) + persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") - @idempotent(persistence_store=persistence_layer) + @idempotent(config=config, persistence_store=persistence_layer) def handler(event, context): # Creating a subscription payment is a side # effect of calling this function! @@ -273,12 +273,42 @@ and we will raise `IdempotencyKeyError` if none was found. === "app.py" - ```python hl_lines="4" - DynamoDBPersistenceLayer( - table_name="IdempotencyTable", - event_key_jmespath="body", + ```python hl_lines="8" + from aws_lambda_powertools.utilities.idempotency import ( + IdempotencyConfig, DynamoDBPersistenceLayer, idempotent + ) + + # Requires "user"."uid" and from the "body" json parsed "order_id" to be present + config = IdempotencyConfig( + event_key_jmespath="[user.uid, powertools_json(body).order_id]", raise_on_no_idempotency_key=True, ) + persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") + + @idempotent(config=config, persistence_store=persistence_layer) + def handler(event, context): + pass + ``` +=== "Success Event" + + ```json + { + "user": { + "uid": "BB0D045C-8878-40C8-889E-38B3CB0A61B1", + "name": "Foo" + }, + "body": "{\"order_id\": 10000}" + } + ``` +=== "Failure Event" + + ```json + { + "user": { + "name": "Joe Bloggs" + }, + "body": "{\"total_amount\": 10000}" + } ``` ### Changing dynamoDB attribute names @@ -298,10 +328,9 @@ This example demonstrates changing the attribute names to custom values: === "app.py" - ```python hl_lines="4-8" + ```python hl_lines="3-7" persistence_layer = DynamoDBPersistenceLayer( table_name="IdempotencyTable", - event_key_jmespath="[userDetail, productId]", key_attr="idempotency_key", expiry_attr="expires_at", status_attr="current_status", @@ -316,35 +345,39 @@ or `boto3_session` parameters when constructing the persistence store. === "Custom session" - ```python hl_lines="1 4 8" + ```python hl_lines="1 7 10" import boto3 - from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent + from aws_lambda_powertools.utilities.idempotency import ( + IdempotencyConfig, DynamoDBPersistenceLayer, idempotent + ) + config = IdempotencyConfig(event_key_jmespath="body") boto3_session = boto3.session.Session() persistence_layer = DynamoDBPersistenceLayer( table_name="IdempotencyTable", - event_key_jmespath="body", boto3_session=boto3_session ) - @idempotent(persistence_store=persistence_layer) + @idempotent(config=config, persistence_store=persistence_layer) def handler(event, context): ... ``` === "Custom config" - ```python hl_lines="1 4 8" + ```python hl_lines="1 7 10" from botocore.config import Config - from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent + from aws_lambda_powertools.utilities.idempotency import ( + IdempotencyConfig, DynamoDBPersistenceLayer, idempotent + ) + config = IdempotencyConfig(event_key_jmespath="body") boto_config = Config() persistence_layer = DynamoDBPersistenceLayer( table_name="IdempotencyTable", - event_key_jmespath="body", boto_config=boto_config ) - @idempotent(persistence_store=persistence_layer) + @idempotent(config=config, persistence_store=persistence_layer) def handler(event, context): ... ``` @@ -372,15 +405,15 @@ The idempotency utility can be used with the `validator` decorator. Ensure that ```python hl_lines="9 10" from aws_lambda_powertools.utilities.validation import validator, envelopes - from aws_lambda_powertools.utilities.idempotency.idempotency import idempotent - - persistence_layer = DynamoDBPersistenceLayer( - table_name="IdempotencyTable", - event_key_jmespath="[message, username]", + from aws_lambda_powertools.utilities.idempotency import ( + IdempotencyConfig, DynamoDBPersistenceLayer, idempotent ) + config = IdempotencyConfig(event_key_jmespath="[message, username]") + persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") + @validator(envelope=envelopes.API_GATEWAY_HTTP) - @idempotent(persistence_store=persistence_layer) + @idempotent(config=config, persistence_store=persistence_layer) def lambda_handler(event, context): cause_some_side_effects(event['username') return {"message": event['message'], "statusCode": 200} diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/functional/idempotency/conftest.py b/tests/functional/idempotency/conftest.py index 9ae030f02d1..532d551ef40 100644 --- a/tests/functional/idempotency/conftest.py +++ b/tests/functional/idempotency/conftest.py @@ -13,6 +13,7 @@ from aws_lambda_powertools.shared.json_encoder import Encoder from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer +from aws_lambda_powertools.utilities.idempotency.idempotency import IdempotencyConfig from aws_lambda_powertools.utilities.validation import envelopes from aws_lambda_powertools.utilities.validation.base import unwrap_event_from_envelope @@ -151,51 +152,44 @@ def hashed_validation_key(lambda_apigw_event): @pytest.fixture -def persistence_store(config, request, default_jmespath): - persistence_store = DynamoDBPersistenceLayer( +def persistence_store(config): + return DynamoDBPersistenceLayer(table_name=TABLE_NAME, boto_config=config) + + +@pytest.fixture +def idempotency_config(config, request, default_jmespath): + return IdempotencyConfig( event_key_jmespath=request.param.get("event_key_jmespath") or default_jmespath, - table_name=TABLE_NAME, - boto_config=config, use_local_cache=request.param["use_local_cache"], ) - return persistence_store @pytest.fixture -def persistence_store_without_jmespath(config, request): - persistence_store = DynamoDBPersistenceLayer( - table_name=TABLE_NAME, boto_config=config, use_local_cache=request.param["use_local_cache"], - ) - return persistence_store +def config_without_jmespath(config, request): + return IdempotencyConfig(use_local_cache=request.param["use_local_cache"]) @pytest.fixture -def persistence_store_with_validation(config, request, default_jmespath): - persistence_store = DynamoDBPersistenceLayer( +def config_with_validation(config, request, default_jmespath): + return IdempotencyConfig( event_key_jmespath=default_jmespath, - table_name=TABLE_NAME, - boto_config=config, use_local_cache=request.param, payload_validation_jmespath="requestContext", ) - return persistence_store @pytest.fixture -def persistence_store_with_jmespath_options(config, request): +def config_with_jmespath_options(config, request): class CustomFunctions(functions.Functions): @functions.signature({"types": ["string"]}) def _func_echo_decoder(self, value): return value - persistence_store = DynamoDBPersistenceLayer( - table_name=TABLE_NAME, - boto_config=config, + return IdempotencyConfig( use_local_cache=False, event_key_jmespath=request.param, jmespath_options={"custom_functions": CustomFunctions()}, ) - return persistence_store @pytest.fixture diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index 872d9f39365..999b34fc8f6 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -7,6 +7,7 @@ import pytest from botocore import stub +from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, IdempotencyConfig from aws_lambda_powertools.utilities.idempotency.exceptions import ( IdempotencyAlreadyInProgressError, IdempotencyInconsistentStateError, @@ -24,9 +25,10 @@ # Using parametrize to run test twice, with two separate instances of persistence store. One instance with caching # enabled, and one without. -@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) +@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) def test_idempotent_lambda_already_completed( - persistence_store, + idempotency_config: IdempotencyConfig, + persistence_store: DynamoDBPersistenceLayer, lambda_apigw_event, timestamp_future, hashed_idempotency_key, @@ -56,7 +58,7 @@ def test_idempotent_lambda_already_completed( stubber.add_response("get_item", ddb_response, expected_params) stubber.activate() - @idempotent(persistence_store=persistence_store) + @idempotent(config=idempotency_config, persistence_store=persistence_store) def lambda_handler(event, context): raise Exception @@ -67,9 +69,14 @@ def lambda_handler(event, context): stubber.deactivate() -@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) +@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) def test_idempotent_lambda_in_progress( - persistence_store, lambda_apigw_event, lambda_response, timestamp_future, hashed_idempotency_key + idempotency_config: IdempotencyConfig, + persistence_store: DynamoDBPersistenceLayer, + lambda_apigw_event, + lambda_response, + timestamp_future, + hashed_idempotency_key, ): """ Test idempotent decorator where lambda_handler is already processing an event with matching event key @@ -94,7 +101,7 @@ def test_idempotent_lambda_in_progress( stubber.add_response("get_item", ddb_response, expected_params) stubber.activate() - @idempotent(persistence_store=persistence_store) + @idempotent(config=idempotency_config, persistence_store=persistence_store) def lambda_handler(event, context): return lambda_response @@ -110,9 +117,15 @@ def lambda_handler(event, context): @pytest.mark.skipif(sys.version_info < (3, 8), reason="issue with pytest mock lib for < 3.8") -@pytest.mark.parametrize("persistence_store", [{"use_local_cache": True}], indirect=True) +@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": True}], indirect=True) def test_idempotent_lambda_in_progress_with_cache( - persistence_store, lambda_apigw_event, lambda_response, timestamp_future, hashed_idempotency_key, mocker + idempotency_config: IdempotencyConfig, + persistence_store: DynamoDBPersistenceLayer, + lambda_apigw_event, + lambda_response, + timestamp_future, + hashed_idempotency_key, + mocker, ): """ Test idempotent decorator where lambda_handler is already processing an event with matching event key, cache @@ -145,7 +158,7 @@ def test_idempotent_lambda_in_progress_with_cache( stubber.add_response("get_item", copy.deepcopy(ddb_response), copy.deepcopy(expected_params)) stubber.activate() - @idempotent(persistence_store=persistence_store) + @idempotent(config=idempotency_config, persistence_store=persistence_store) def lambda_handler(event, context): return lambda_response @@ -168,9 +181,10 @@ def lambda_handler(event, context): stubber.deactivate() -@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) +@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) def test_idempotent_lambda_first_execution( - persistence_store, + idempotency_config: IdempotencyConfig, + persistence_store: DynamoDBPersistenceLayer, lambda_apigw_event, expected_params_update_item, expected_params_put_item, @@ -190,7 +204,7 @@ def test_idempotent_lambda_first_execution( stubber.add_response("update_item", ddb_response, expected_params_update_item) stubber.activate() - @idempotent(persistence_store=persistence_store) + @idempotent(config=idempotency_config, persistence_store=persistence_store) def lambda_handler(event, context): return lambda_response @@ -201,10 +215,11 @@ def lambda_handler(event, context): @pytest.mark.skipif(sys.version_info < (3, 8), reason="issue with pytest mock lib for < 3.8") -@pytest.mark.parametrize("persistence_store", [{"use_local_cache": True}], indirect=True) +@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": True}], indirect=True) def test_idempotent_lambda_first_execution_cached( - persistence_store, - lambda_apigw_event, + idempotency_config: IdempotencyConfig, + persistence_store: DynamoDBPersistenceLayer, + lambda_apigw_event: DynamoDBPersistenceLayer, expected_params_update_item, expected_params_put_item, lambda_response, @@ -224,7 +239,7 @@ def test_idempotent_lambda_first_execution_cached( stubber.add_response("update_item", ddb_response, expected_params_update_item) stubber.activate() - @idempotent(persistence_store=persistence_store) + @idempotent(config=idempotency_config, persistence_store=persistence_store) def lambda_handler(event, context): return lambda_response @@ -245,9 +260,10 @@ def lambda_handler(event, context): stubber.deactivate() -@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) +@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) def test_idempotent_lambda_expired( - persistence_store, + idempotency_config: IdempotencyConfig, + persistence_store: DynamoDBPersistenceLayer, lambda_apigw_event, timestamp_expired, lambda_response, @@ -268,7 +284,7 @@ def test_idempotent_lambda_expired( stubber.add_response("update_item", ddb_response, expected_params_update_item) stubber.activate() - @idempotent(persistence_store=persistence_store) + @idempotent(config=idempotency_config, persistence_store=persistence_store) def lambda_handler(event, context): return lambda_response @@ -278,9 +294,10 @@ def lambda_handler(event, context): stubber.deactivate() -@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) +@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) def test_idempotent_lambda_exception( - persistence_store, + idempotency_config: IdempotencyConfig, + persistence_store: DynamoDBPersistenceLayer, lambda_apigw_event, timestamp_future, lambda_response, @@ -304,7 +321,7 @@ def test_idempotent_lambda_exception( stubber.add_response("delete_item", ddb_response, expected_params_delete_item) stubber.activate() - @idempotent(persistence_store=persistence_store) + @idempotent(config=idempotency_config, persistence_store=persistence_store) def lambda_handler(event, context): raise Exception("Something went wrong!") @@ -316,10 +333,11 @@ def lambda_handler(event, context): @pytest.mark.parametrize( - "persistence_store_with_validation", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True + "config_with_validation", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True ) def test_idempotent_lambda_already_completed_with_validation_bad_payload( - persistence_store_with_validation, + config_with_validation: IdempotencyConfig, + persistence_store: DynamoDBPersistenceLayer, lambda_apigw_event, timestamp_future, lambda_response, @@ -330,7 +348,7 @@ def test_idempotent_lambda_already_completed_with_validation_bad_payload( Test idempotent decorator where event with matching event key has already been succesfully processed """ - stubber = stub.Stubber(persistence_store_with_validation.table.meta.client) + stubber = stub.Stubber(persistence_store.table.meta.client) ddb_response = { "Item": { "id": {"S": hashed_idempotency_key}, @@ -347,7 +365,7 @@ def test_idempotent_lambda_already_completed_with_validation_bad_payload( stubber.add_response("get_item", ddb_response, expected_params) stubber.activate() - @idempotent(persistence_store=persistence_store_with_validation) + @idempotent(config=config_with_validation, persistence_store=persistence_store) def lambda_handler(event, context): return lambda_response @@ -359,9 +377,10 @@ def lambda_handler(event, context): stubber.deactivate() -@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) +@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) def test_idempotent_lambda_expired_during_request( - persistence_store, + idempotency_config: IdempotencyConfig, + persistence_store: DynamoDBPersistenceLayer, lambda_apigw_event, timestamp_expired, lambda_response, @@ -402,7 +421,7 @@ def test_idempotent_lambda_expired_during_request( stubber.activate() - @idempotent(persistence_store=persistence_store) + @idempotent(config=idempotency_config, persistence_store=persistence_store) def lambda_handler(event, context): return lambda_response @@ -414,9 +433,10 @@ def lambda_handler(event, context): stubber.deactivate() -@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) +@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) def test_idempotent_persistence_exception_deleting( - persistence_store, + idempotency_config: IdempotencyConfig, + persistence_store: DynamoDBPersistenceLayer, lambda_apigw_event, timestamp_future, lambda_response, @@ -435,7 +455,7 @@ def test_idempotent_persistence_exception_deleting( stubber.add_client_error("delete_item", "UnrecoverableError") stubber.activate() - @idempotent(persistence_store=persistence_store) + @idempotent(config=idempotency_config, persistence_store=persistence_store) def lambda_handler(event, context): raise Exception("Something went wrong!") @@ -447,9 +467,10 @@ def lambda_handler(event, context): stubber.deactivate() -@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) +@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) def test_idempotent_persistence_exception_updating( - persistence_store, + idempotency_config: IdempotencyConfig, + persistence_store: DynamoDBPersistenceLayer, lambda_apigw_event, timestamp_future, lambda_response, @@ -468,7 +489,7 @@ def test_idempotent_persistence_exception_updating( stubber.add_client_error("update_item", "UnrecoverableError") stubber.activate() - @idempotent(persistence_store=persistence_store) + @idempotent(config=idempotency_config, persistence_store=persistence_store) def lambda_handler(event, context): return {"message": "success!"} @@ -480,9 +501,10 @@ def lambda_handler(event, context): stubber.deactivate() -@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) +@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) def test_idempotent_persistence_exception_getting( - persistence_store, + idempotency_config: IdempotencyConfig, + persistence_store: DynamoDBPersistenceLayer, lambda_apigw_event, timestamp_future, lambda_response, @@ -499,7 +521,7 @@ def test_idempotent_persistence_exception_getting( stubber.add_client_error("get_item", "UnexpectedException") stubber.activate() - @idempotent(persistence_store=persistence_store) + @idempotent(config=idempotency_config, persistence_store=persistence_store) def lambda_handler(event, context): return {"message": "success!"} @@ -512,10 +534,11 @@ def lambda_handler(event, context): @pytest.mark.parametrize( - "persistence_store_with_validation", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True + "config_with_validation", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True ) def test_idempotent_lambda_first_execution_with_validation( - persistence_store_with_validation, + config_with_validation: IdempotencyConfig, + persistence_store: DynamoDBPersistenceLayer, lambda_apigw_event, expected_params_update_item_with_validation, expected_params_put_item_with_validation, @@ -526,15 +549,15 @@ def test_idempotent_lambda_first_execution_with_validation( """ Test idempotent decorator when lambda is executed with an event with a previously unknown event key """ - stubber = stub.Stubber(persistence_store_with_validation.table.meta.client) + stubber = stub.Stubber(persistence_store.table.meta.client) ddb_response = {} stubber.add_response("put_item", ddb_response, expected_params_put_item_with_validation) stubber.add_response("update_item", ddb_response, expected_params_update_item_with_validation) stubber.activate() - @idempotent(persistence_store=persistence_store_with_validation) - def lambda_handler(lambda_apigw_event, context): + @idempotent(config=config_with_validation, persistence_store=persistence_store) + def lambda_handler(event, context): return lambda_response lambda_handler(lambda_apigw_event, {}) @@ -544,10 +567,11 @@ def lambda_handler(lambda_apigw_event, context): @pytest.mark.parametrize( - "persistence_store_without_jmespath", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True + "config_without_jmespath", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True ) def test_idempotent_lambda_with_validator_util( - persistence_store_without_jmespath, + config_without_jmespath: IdempotencyConfig, + persistence_store: DynamoDBPersistenceLayer, lambda_apigw_event, timestamp_future, serialized_lambda_response, @@ -560,7 +584,7 @@ def test_idempotent_lambda_with_validator_util( validator utility to unwrap the event """ - stubber = stub.Stubber(persistence_store_without_jmespath.table.meta.client) + stubber = stub.Stubber(persistence_store.table.meta.client) ddb_response = { "Item": { "id": {"S": hashed_idempotency_key_with_envelope}, @@ -580,7 +604,7 @@ def test_idempotent_lambda_with_validator_util( stubber.activate() @validator(envelope=envelopes.API_GATEWAY_HTTP) - @idempotent(persistence_store=persistence_store_without_jmespath) + @idempotent(config=config_without_jmespath, persistence_store=persistence_store) def lambda_handler(event, context): mock_function() return "shouldn't get here!" @@ -601,10 +625,13 @@ def test_data_record_invalid_status_value(): assert e.value.args[0] == "UNSUPPORTED_STATUS" -@pytest.mark.parametrize("persistence_store", [{"use_local_cache": True}], indirect=True) -def test_in_progress_never_saved_to_cache(persistence_store): +@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": True}], indirect=True) +def test_in_progress_never_saved_to_cache( + idempotency_config: IdempotencyConfig, persistence_store: DynamoDBPersistenceLayer +): # GIVEN a data record with status "INPROGRESS" # and persistence_store has use_local_cache = True + persistence_store.configure(idempotency_config) data_record = DataRecord("key", status="INPROGRESS") # WHEN saving to local cache @@ -614,9 +641,10 @@ def test_in_progress_never_saved_to_cache(persistence_store): assert persistence_store._cache.get("key") is None -@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False}], indirect=True) -def test_user_local_disabled(persistence_store): +@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}], indirect=True) +def test_user_local_disabled(idempotency_config: IdempotencyConfig, persistence_store: DynamoDBPersistenceLayer): # GIVEN a persistence_store with use_local_cache = False + persistence_store.configure(idempotency_config) # WHEN calling any local cache options data_record = DataRecord("key", status="COMPLETED") @@ -633,9 +661,13 @@ def test_user_local_disabled(persistence_store): assert not hasattr("persistence_store", "_cache") -@pytest.mark.parametrize("persistence_store", [{"use_local_cache": True}], indirect=True) -def test_delete_from_cache_when_empty(persistence_store): +@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": True}], indirect=True) +def test_delete_from_cache_when_empty( + idempotency_config: IdempotencyConfig, persistence_store: DynamoDBPersistenceLayer +): # GIVEN use_local_cache is True AND the local cache is empty + persistence_store.configure(idempotency_config) + try: # WHEN we _delete_from_cache persistence_store._delete_from_cache("key_does_not_exist") @@ -663,9 +695,14 @@ def test_is_missing_idempotency_key(): assert BasePersistenceLayer.is_missing_idempotency_key("") -@pytest.mark.parametrize("persistence_store", [{"use_local_cache": False, "event_key_jmespath": "body"}], indirect=True) -def test_default_no_raise_on_missing_idempotency_key(persistence_store): +@pytest.mark.parametrize( + "idempotency_config", [{"use_local_cache": False, "event_key_jmespath": "body"}], indirect=True +) +def test_default_no_raise_on_missing_idempotency_key( + idempotency_config: IdempotencyConfig, persistence_store: DynamoDBPersistenceLayer +): # GIVEN a persistence_store with use_local_cache = False and event_key_jmespath = "body" + persistence_store.configure(idempotency_config) assert persistence_store.use_local_cache is False assert "body" in persistence_store.event_key_jmespath @@ -677,10 +714,13 @@ def test_default_no_raise_on_missing_idempotency_key(persistence_store): @pytest.mark.parametrize( - "persistence_store", [{"use_local_cache": False, "event_key_jmespath": "[body, x]"}], indirect=True + "idempotency_config", [{"use_local_cache": False, "event_key_jmespath": "[body, x]"}], indirect=True ) -def test_raise_on_no_idempotency_key(persistence_store): +def test_raise_on_no_idempotency_key( + idempotency_config: IdempotencyConfig, persistence_store: DynamoDBPersistenceLayer +): # GIVEN a persistence_store with raise_on_no_idempotency_key and no idempotency key in the request + persistence_store.configure(idempotency_config) persistence_store.raise_on_no_idempotency_key = True assert persistence_store.use_local_cache is False assert "body" in persistence_store.event_key_jmespath @@ -693,11 +733,21 @@ def test_raise_on_no_idempotency_key(persistence_store): assert "No data found to create a hashed idempotency_key" in str(excinfo.value) -@pytest.mark.parametrize("persistence_store", [{"use_local_cache": True}], indirect=True) -def test_jmespath_with_powertools_json(persistence_store): +@pytest.mark.parametrize( + "idempotency_config", + [ + { + "use_local_cache": False, + "event_key_jmespath": "[requestContext.authorizer.claims.sub, powertools_json(body).id]", + } + ], + indirect=True, +) +def test_jmespath_with_powertools_json( + idempotency_config: IdempotencyConfig, persistence_store: DynamoDBPersistenceLayer +): # GIVEN an event_key_jmespath with powertools_json custom function - persistence_store.event_key_jmespath = "[requestContext.authorizer.claims.sub, powertools_json(body).id]" - persistence_store.event_key_compiled_jmespath = jmespath.compile(persistence_store.event_key_jmespath) + persistence_store.configure(idempotency_config) sub_attr_value = "cognito_user" key_attr_value = "some_key" expected_value = [sub_attr_value, key_attr_value] @@ -713,11 +763,15 @@ def test_jmespath_with_powertools_json(persistence_store): assert result == persistence_store._generate_hash(expected_value) -@pytest.mark.parametrize("persistence_store_with_jmespath_options", ["powertools_json(data).payload"], indirect=True) -def test_custom_jmespath_function_overrides_builtin_functions(persistence_store_with_jmespath_options): +@pytest.mark.parametrize("config_with_jmespath_options", ["powertools_json(data).payload"], indirect=True) +def test_custom_jmespath_function_overrides_builtin_functions( + config_with_jmespath_options: IdempotencyConfig, persistence_store: DynamoDBPersistenceLayer +): # GIVEN an persistence store with a custom jmespath_options # AND use a builtin powertools custom function + persistence_store.configure(config_with_jmespath_options) + with pytest.raises(jmespath.exceptions.UnknownFunctionError, match="Unknown function: powertools_json()"): # WHEN calling _get_hashed_idempotency_key # THEN raise unknown function - persistence_store_with_jmespath_options._get_hashed_idempotency_key({}) + persistence_store._get_hashed_idempotency_key({}) From ae89970c3e28af206e47c06153ebe943567d7cae Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Fri, 5 Mar 2021 11:31:32 +0100 Subject: [PATCH 17/21] fix(idempotency): PR feedback on config and kwargs --- aws_lambda_powertools/utilities/idempotency/idempotency.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py index 235e5c884d6..149ee3999aa 100644 --- a/aws_lambda_powertools/utilities/idempotency/idempotency.py +++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py @@ -63,10 +63,14 @@ def idempotent( >>> return {"StatusCode": 200} """ - idempotency_handler = IdempotencyHandler(handler, event, context, config or IdempotencyConfig(), persistence_store) + config = config or IdempotencyConfig() + idempotency_handler = IdempotencyHandler( + lambda_handler=handler, event=event, context=context, persistence_store=persistence_store, config=config + ) # IdempotencyInconsistentStateError can happen under rare but expected cases when persistent state changes in the # small time between put & get requests. In most cases we can retry successfully on this exception. + # Maintenance: Allow customers to specify number of retries max_handler_retries = 2 for i in range(max_handler_retries + 1): try: From 7d2dabe9a0af3ffd2ffc857e0f9111ea44604040 Mon Sep 17 00:00:00 2001 From: Heitor Lessa Date: Fri, 5 Mar 2021 15:56:51 +0100 Subject: [PATCH 18/21] docs(idempotency): tidy up doc before release (#309) * docs: refresh doc navigation and some initial tidy up * docs: mention built-in jmespath functions are also available * docs: add initial example of custom persistence layer * docs: emphasize about serialization and hashing, reorder exception handling --- .../utilities/idempotency/idempotency.py | 2 +- docs/utilities/idempotency.md | 539 +++++++++++++----- mkdocs.yml | 1 + .../idempotency/test_idempotency.py | 2 +- 4 files changed, 403 insertions(+), 141 deletions(-) diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py index 149ee3999aa..06d77a9fd72 100644 --- a/aws_lambda_powertools/utilities/idempotency/idempotency.py +++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py @@ -46,7 +46,7 @@ def idempotent( persistence_store: BasePersistenceLayer Instance of BasePersistenceLayer to store data config: IdempotencyConfig - Configutation + Configuration Examples -------- diff --git a/docs/utilities/idempotency.md b/docs/utilities/idempotency.md index 784f6597a23..a850a8cfa50 100644 --- a/docs/utilities/idempotency.md +++ b/docs/utilities/idempotency.md @@ -12,15 +12,18 @@ are safe to retry. ## Terminology The property of idempotency means that an operation does not cause additional side effects if it is called more than -once with the same input parameters. Idempotent operations will return the same result when they are called multiple -times with the same parameters. This makes idempotent operations safe to retry. +once with the same input parameters. +**Idempotent operations will return the same result when they are called multiple +times with the same parameters**. This makes idempotent operations safe to retry. + +**Idempotency key** is a hash representation of either the entire event or a specific configured subset of the event, and invocation results are **JSON serialized** and stored in your persistence storage layer. ## Key features -* Prevent Lambda handler code executing more than once on the same event payload during a time window +* Prevent Lambda handler from executing more than once on the same event payload during a time window * Ensure Lambda handler returns the same result when called with the same payload -* Select a subset of the event as the idempotency key using JMESpath expressions +* Select a subset of the event as the idempotency key using JMESPath expressions * Set a time window in which records with the same payload should be considered duplicates ## Getting started @@ -28,72 +31,116 @@ times with the same parameters. This makes idempotent operations safe to retry. ### Required resources Before getting started, you need to create a persistent storage layer where the idempotency utility can store its -state. Your lambda functions will need read and write access to it. DynamoDB is currently the only supported persistent -storage layer, so you'll need to create a table first. +state - your lambda functions will need read and write access to it. + +As of now, Amazon DynamoDB is the only supported persistent storage layer, so you'll need to create a table first. > Example using AWS Serverless Application Model (SAM) === "template.yml" - ```yaml + ```yaml hl_lines="5-13 21-23" Resources: - HelloWorldFunction: - Type: AWS::Serverless::Function - Properties: - Runtime: python3.8 - ... - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref IdempotencyTable IdempotencyTable: Type: AWS::DynamoDB::Table Properties: AttributeDefinitions: - AttributeName: id AttributeType: S - BillingMode: PAY_PER_REQUEST KeySchema: - AttributeName: id KeyType: HASH - TableName: "IdempotencyTable" TimeToLiveSpecification: AttributeName: expiration Enabled: true + BillingMode: PAY_PER_REQUEST + + HelloWorldFunction: + Type: AWS::Serverless::Function + Properties: + Runtime: python3.8 + ... + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref IdempotencyTable ``` -!!! warning - When using this utility with DynamoDB, your lambda responses must always be smaller than 400kb. Larger items cannot - be written to DynamoDB and will cause exceptions. +!!! warning "Large responses with DynamoDB persistence layer" + When using this utility with DynamoDB, your function's responses must be [smaller than 400KB](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Limits.html#limits-items). -!!! info + Larger items cannot be written to DynamoDB and will cause exceptions. + +!!! info "DynamoDB " Each function invocation will generally make 2 requests to DynamoDB. If the result returned by your Lambda is less than 1kb, you can expect 2 WCUs per invocation. For retried invocations, you will see 1WCU and 1RCU. Review the [DynamoDB pricing documentation](https://aws.amazon.com/dynamodb/pricing/) to estimate the cost. -### Lambda handler +!!! danger "CREATE SECTION FOR PERSISTENCE LAYERS" -You can quickly start by initializing the `DynamoDBPersistenceLayer` class outside the Lambda handler, and using it -with the `idempotent` decorator on your lambda handler. The only required parameter is `table_name`, but you likely -want to specify `event_key_jmespath` via `IdempotencyConfig` class. +### Idempotent decorator -`event_key_jmespath`: A JMESpath expression which will be used to extract the payload from the event your Lambda handler -is called with. This payload will be used as the key to decide if future invocations are duplicates. If you don't pass -this parameter, the entire event will be used as the key. +You can quickly start by initializing the `DynamoDBPersistenceLayer` class and using it with the `idempotent` decorator on your lambda handler. === "app.py" - ```python hl_lines="2-4 8-9 11" + ```python hl_lines="1 5 7 14" + from aws_lambda_powertools.utilities.idempotency import ( + DynamoDBPersistenceLayer, idempotent + ) + + persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") + + @idempotent(persistence_store=persistence_layer) + def handler(event, context): + payment = create_subscription_payment( + user=event['user'], + product=event['product_id'] + ) + ... + return { + "payment_id": payment.id + "message": "success", + "statusCode": 200, + } + ``` + +=== "Example event" + + ```json + { + "username": "xyz", + "product_id": "123456789" + } + ``` + +#### Choosing a payload subset for idempotency + +!!! tip "Dealing with always changing payloads" + When dealing with a more elaborate payload, where parts of the payload always change, you should use **`event_key_jmespath`** parameter. + +Use [`IdempotencyConfig`](#customizing-the-default-behaviour) to instruct the idempotent decorator to only use a portion of your payload to verify whether a request is idempotent, and therefore it should not be retried. + +> **Payment scenario** + +In this example, we have a Lambda handler that creates a payment for a user subscribing to a product. We want to ensure that we don't accidentally charge our customer by subscribing them more than once. + +Imagine the function executes successfully, but the client never receives the response due to a connection issue. It is safe to retry in this instance, as the idempotent decorator will return a previously saved response. + +=== "payment.py" + + ```python hl_lines="2-4 10 12 15 20" import json from aws_lambda_powertools.utilities.idempotency import ( IdempotencyConfig, DynamoDBPersistenceLayer, idempotent ) - # Treat everything under the "body" key in - # the event json object as our payload - config = IdempotencyConfig(event_key_jmespath="body") persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") + # Treat everything under the "body" key + # in the event json object as our payload + config = IdempotencyConfig(event_key_jmespath="body") + @idempotent(config=config, persistence_store=persistence_layer) def handler(event, context): body = json.loads(event['body']) @@ -102,11 +149,16 @@ this parameter, the entire event will be used as the key. product=body['product_id'] ) ... - return {"message": "success", "statusCode": 200, "payment_id": payment.id} + return { + "payment_id": payment.id, + "message": "success", + "statusCode": 200 + } ``` + === "Example event" - ```json + ```json hl_lines="28" { "version":"2.0", "routeKey":"ANY /createpayment", @@ -139,90 +191,158 @@ this parameter, the entire event will be used as the key. } ``` -In this example, we have a Lambda handler that creates a payment for a user subscribing to a product. We want to ensure -that we don't accidentally charge our customer by subscribing them more than once. Imagine the function executes -successfully, but the client never receives the response. When we're using the idempotent decorator, we can safely -retry. This sequence diagram shows an example flow of what happens in this case: +#### Idempotency request flow -![Idempotent sequence](../media/idempotent_sequence.png) +This sequence diagram shows an example flow of what happens in the payment scenario: +![Idempotent sequence](../media/idempotent_sequence.png) -The client was successful in receiving the result after the retry. Since the Lambda handler was only executed once, our -customer hasn't been charged twice. +The client was successful in receiving the result after the retry. Since the Lambda handler was only executed once, our customer hasn't been charged twice. !!! note - Bear in mind that the entire Lambda handler is treated as a single idempotent operation. If your Lambda handler can - cause multiple side effects, consider splitting it into separate functions. + Bear in mind that the entire Lambda handler is treated as a single idempotent operation. If your Lambda handler can cause multiple side effects, consider splitting it into separate functions. ### Handling exceptions -If your Lambda handler raises an unhandled exception, the record in the persistence layer will be deleted. This means -that if the client retries, your Lambda handler will be free to execute again. If you don't want the record to be -deleted, you need to catch Exceptions within the handler and return a successful response. +**The record in the persistence layer will be deleted** if your Lambda handler returns an exception. This means that new invocations will execute again despite having the same payload. +If you don't want the record to be deleted, you need to catch exceptions within the handler and return a successful response. ![Idempotent sequence exception](../media/idempotent_sequence_exception.png) !!! warning - If any of the calls to the persistence layer unexpectedly fail, `IdempotencyPersistenceLayerError` will be raised. - As this happens outside the scope of your Lambda handler, you are not able to catch it. + **We will raise `IdempotencyPersistenceLayerError`** if any of the calls to the persistence layer fail unexpectedly. + + As this happens outside the scope of your Lambda handler, you are not going to be able to catch it. + +### Persistence layers + +#### DynamoDBPersistenceLayer + +This persistence layer is built-in, and you can either use an existing DynamoDB table or create a new one dedicated for idempotency state (recommended). + +=== "app.py" + + ```python hl_lines="3-7" + from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer + + persistence_layer = DynamoDBPersistenceLayer( + table_name="IdempotencyTable", + key_attr="idempotency_key", + expiry_attr="expires_at", + status_attr="current_status", + data_attr="result_data", + validation_key_attr="validation_key" + ) + ``` + +These are knobs you can use when using DynamoDB as a persistence layer: + +Parameter | Required | Default | Description +------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------- +**table_name** | :heavy_check_mark: | | Table name to store state +**key_attr** | | `id` | Primary key of the table. Hashed representation of the payload +**expiry_attr** | | `expiration` | Unix timestamp of when record expires +**status_attr** | | `status` | Stores status of the lambda execution during and after invocation +**data_attr** | | `data` | Stores results of successfully executed Lambda handlers +**validation_key_attr** | | `validation` | Hashed representation of the parts of the event used for validation +## Advanced + +### Customizing the default behavior + +Idempotent decorator can be further configured with **`IdempotencyConfig`** as seen in the previous example. These are the available options for further configuration + +Parameter | Default | Description +------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------- +**event_key_jmespath** | `""` | JMESPath expression to extract the idempotency key from the event record +**payload_validation_jmespath** | `""` | JMESPath expression to validate whether certain parameters have changed in the event while the event payload +**raise_on_no_idempotency_key** | `False` | Raise exception if no idempotency key was found in the request +**expires_after_seconds** | 3600 | The number of seconds to wait before a record is expired +**use_local_cache** | `False` | Whether to locally cache idempotency results +**local_cache_max_items** | 1024 | Max number of items to store in local cache +**hash_function** | `md5` | Function to use for calculating hashes, as provided by [hashlib](https://docs.python.org/3/library/hashlib.html) in the standard library. + +### Handling concurrent executions with the same payload + +This utility will raise an **`IdempotencyAlreadyInProgressError`** exception if you receive **multiple invocations with the same payload while the first invocation hasn't completed yet**. + +!!! info "If you receive `IdempotencyAlreadyInProgressError`, you can safely retry the operation." + +This is a locking mechanism for correctness. Since we don't know the result from the first invocation yet, we can't safely allow another concurrent execution. + +### Using in-memory cache + +**By default, in-memory local caching is disabled**, since we don't know how much memory you consume per invocation compared to the maximum configured in your Lambda function. + +!!! note "This in-memory cache is local to each Lambda execution environment" + This means it will be effective in cases where your function's concurrency is low in comparison to the number of "retry" invocations with the same payload, because cache might be empty. -### Setting a time window -In most cases, it is not desirable to store the idempotency records forever. Rather, you want to guarantee that the -same payload won't be executed within a period of time. By default, the period is set to 1 hour (3600 seconds). You can -change this window with the `expires_after_seconds` parameter: +You can enable in-memory caching with the **`use_local_cache`** parameter: === "app.py" - ```python hl_lines="3" - IdempotencyConfig( + ```python hl_lines="6 8 11" + from aws_lambda_powertools.utilities.idempotency import ( + IdempotencyConfig, DynamoDBPersistenceLayer, idempotent + ) + + persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") + config = IdempotencyConfig( event_key_jmespath="body", expires_after_seconds=5*60, # 5 minutes + use_local_cache=True ) - ``` -This will mark any records older than 5 minutes as expired, and the lambda handler will be executed as normal if it is -invoked with a matching payload. If you have set the TTL field in DynamoDB like in the SAM example above, the record -will be automatically deleted from the table after a period of time. + @idempotent(config=config, persistence_store=persistence_layer) + def handler(event, context): + ... + ``` +When enabled, the default is to cache a maximum of 256 records in each Lambda execution environment - You can change it with the **`local_cache_max_items`** parameter. +### Expiring idempotency records -### Handling concurrent executions -If you invoke a Lambda function with a given payload, then try to invoke it again with the same payload before the -first invocation has finished, we'll raise an `IdempotencyAlreadyInProgressError` exception. This is the utility's -locking mechanism at work. Since we don't know the result from the first invocation yet, we can't safely allow another -concurrent execution. If you receive this error, you can safely retry the operation. +!!! note + By default, we expire idempotency records after **an hour** (3600 seconds). +In most cases, it is not desirable to store the idempotency records forever. Rather, you want to guarantee that the same payload won't be executed within a period of time. -### Using local cache -To reduce the number of lookups to the persistence storage layer, you can enable in memory caching with the -`use_local_cache` parameter, which is disabled by default. This cache is local to each Lambda execution environment. -This means it will be effective in cases where your function's concurrency is low in comparison to the number of -"retry" invocations with the same payload. When enabled, the default is to cache a maximum of 256 records in each Lambda -execution environment. You can change this with the `local_cache_max_items` parameter. +You can change this window with the **`expires_after_seconds`** parameter: === "app.py" - ```python hl_lines="3 4" - IdempotencyConfig( + ```python hl_lines="6 8 11" + from aws_lambda_powertools.utilities.idempotency import ( + IdempotencyConfig, DynamoDBPersistenceLayer, idempotent + ) + + persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") + config = IdempotencyConfig( event_key_jmespath="body", - use_local_cache=True, - local_cache_max_items=1000 + expires_after_seconds=5*60, # 5 minutes ) + + @idempotent(config=config, persistence_store=persistence_layer) + def handler(event, context): + ... ``` +This will mark any records older than 5 minutes as expired, and the lambda handler will be executed as normal if it is invoked with a matching payload. -## Advanced +!!! note "DynamoDB time-to-live field" + This utility uses **`expiration`** as the TTL field in DynamoDB, as [demonstrated in the SAM example earlier](#required-resources). ### Payload validation -What happens if lambda is invoked with a payload that it has seen before, but some parameters which are not part of the -payload have changed? By default, lambda will return the same result as it returned before, which may be misleading. -Payload validation provides a solution to that. You can provide another JMESpath expression to the persistence store -with the `payload_validation_jmespath` to specify which part of the event body should be validated against previous -idempotent invocations. + +!!! question "What if your function is invoked with the same payload except some outer parameters have changed?" + Example: A payment transaction for a given productID was requested twice for the same customer, **however the amount to be paid has changed in the second transaction**. + +By default, we will return the same result as it returned before, however in this instance it may be misleading - We provide a fail fast payload validation to address this edge case. + +With **`payload_validation_jmespath`**, you can provide an additional JMESPath expression to specify which part of the event body should be validated against previous idempotent invocations === "app.py" - ```python hl_lines="7" + ```python hl_lines="7 11 18 25" from aws_lambda_powertools.utilities.idempotency import ( IdempotencyConfig, DynamoDBPersistenceLayer, idempotent ) @@ -238,17 +358,22 @@ idempotent invocations. # Creating a subscription payment is a side # effect of calling this function! payment = create_subscription_payment( - user=event['userDetail']['username'], - product=event['product_id'], - amount=event['amount'] + user=event['userDetail']['username'], + product=event['product_id'], + amount=event['amount'] ) ... - return {"message": "success", "statusCode": 200, - "payment_id": payment.id, "amount": payment.amount} + return { + "message": "success", + "statusCode": 200, + "payment_id": payment.id, + "amount": payment.amount + } ``` -=== "Example Event" - ```json +=== "Example Event 1" + + ```json hl_lines="8" { "userDetail": { "username": "User1", @@ -260,104 +385,101 @@ idempotent invocations. } ``` -In this example, the "userDetail" and "productId" keys are used as the payload to generate the idempotency key. If -we try to send the same request but with a different amount, we will raise `IdempotencyValidationError`. Without -payload validation, we would have returned the same result as we did for the initial request. Since we're also -returning an amount in the response, this could be quite confusing for the client. By using payload validation on the -amount field, we prevent this potentially confusing behaviour and instead raise an Exception. +=== "Example Event 2" + + ```json hl_lines="8" + { + "userDetail": { + "username": "User1", + "user_email": "user@example.com" + }, + "productId": 1500, + "charge_type": "subscription", + "amount": 1 + } + ``` + +In this example, the **`userDetail`** and **`productId`** keys are used as the payload to generate the idempotency key, as per **`event_key_jmespath`** parameter. + +!!! note + If we try to send the same request but with a different amount, we will raise **`IdempotencyValidationError`**. + +Without payload validation, we would have returned the same result as we did for the initial request. Since we're also returning an amount in the response, this could be quite confusing for the client. + +By using **`payload_validation_jmespath="amount"`**, we prevent this potentially confusing behavior and instead raise an Exception. ### Making idempotency key required -If you want to enforce that an idempotency key is required, you can set `raise_on_no_idempotency_key` to `True`, -and we will raise `IdempotencyKeyError` if none was found. +If you want to enforce that an idempotency key is required, you can set **`raise_on_no_idempotency_key`** to `True`. + +This means that we will raise **`IdempotencyKeyError`** if the evaluation of **`event_key_jmespath`** is `None`. === "app.py" - ```python hl_lines="8" + ```python hl_lines="9-10 13" from aws_lambda_powertools.utilities.idempotency import ( IdempotencyConfig, DynamoDBPersistenceLayer, idempotent ) - # Requires "user"."uid" and from the "body" json parsed "order_id" to be present + persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") + + # Requires "user"."uid" and "order_id" to be present config = IdempotencyConfig( - event_key_jmespath="[user.uid, powertools_json(body).order_id]", + event_key_jmespath="[user.uid, order_id]", raise_on_no_idempotency_key=True, ) - persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") @idempotent(config=config, persistence_store=persistence_layer) def handler(event, context): pass ``` + === "Success Event" - ```json + ```json hl_lines="3 6" { "user": { "uid": "BB0D045C-8878-40C8-889E-38B3CB0A61B1", "name": "Foo" }, - "body": "{\"order_id\": 10000}" + "order_id": 10000 } ``` + === "Failure Event" - ```json + Notice that `order_id` is now accidentally within `user` key + + ```json hl_lines="3 5" { "user": { - "name": "Joe Bloggs" + "uid": "DE0D000E-1234-10D1-991E-EAC1DD1D52C8", + "name": "Joe Bloggs", + "order_id": 10000 }, - "body": "{\"total_amount\": 10000}" } ``` -### Changing dynamoDB attribute names -If you want to use an existing DynamoDB table, or wish to change the name of the attributes used to store items in the -table, you can do so when you construct the `DynamoDBPersistenceLayer` instance. - - -Parameter | Default value | Description -------------------- |--------------- | ------------ -key_attr | "id" | Primary key of the table. Hashed representation of the payload -expiry_attr | "expiration" | Unix timestamp of when record expires -status_attr | "status" | Stores status of the lambda execution during and after invocation -data_attr | "data" | Stores results of successfully executed Lambda handlers -validation_key_attr | "validation" | Hashed representation of the parts of the event used for validation - -This example demonstrates changing the attribute names to custom values: - -=== "app.py" - - ```python hl_lines="3-7" - persistence_layer = DynamoDBPersistenceLayer( - table_name="IdempotencyTable", - key_attr="idempotency_key", - expiry_attr="expires_at", - status_attr="current_status", - data_attr="result_data", - validation_key_attr="validation_key" - ) - ``` - ### Customizing boto configuration -You can provide custom boto configuration or event bring your own boto3 session if required by using the `boto_config` -or `boto3_session` parameters when constructing the persistence store. + +You can provide a custom boto configuration via **`boto_config`**, or an existing boto session via **`boto3_session`** parameters, when constructing the persistence store. === "Custom session" - ```python hl_lines="1 7 10" + ```python hl_lines="1 6 9 14" import boto3 from aws_lambda_powertools.utilities.idempotency import ( IdempotencyConfig, DynamoDBPersistenceLayer, idempotent ) - config = IdempotencyConfig(event_key_jmespath="body") boto3_session = boto3.session.Session() persistence_layer = DynamoDBPersistenceLayer( table_name="IdempotencyTable", boto3_session=boto3_session ) + config = IdempotencyConfig(event_key_jmespath="body") + @idempotent(config=config, persistence_store=persistence_layer) def handler(event, context): ... @@ -384,11 +506,146 @@ or `boto3_session` parameters when constructing the persistence store. ### Bring your own persistent store -The utility provides an abstract base class which can be used to implement your choice of persistent storage layers. +This utility provides an abstract base class (ABC), so that you can implement your choice of persistent storage layer. + You can inherit from the `BasePersistenceLayer` class and implement the abstract methods `_get_record`, `_put_record`, -`_update_record` and `_delete_record`. Pay attention to the documentation for each - you may need to perform additional -checks inside these methods to ensure the idempotency guarantees remain intact. For example, the `_put_record` method -needs to raise an exception if a non-expired record already exists in the data store with a matching key. +`_update_record` and `_delete_record`. + +=== "DynamoDB persistence layer implementation excerpt" + + ```python hl_lines="8-13 57 65 74 96 124" + import datetime + import logging + from typing import Any, Dict, Optional + + import boto3 + from botocore.config import Config + + from aws_lambda_powertools.utilities.idempotency import BasePersistenceLayer + from aws_lambda_powertools.utilities.idempotency.exceptions import ( + IdempotencyItemAlreadyExistsError, + IdempotencyItemNotFoundError, + ) + from aws_lambda_powertools.utilities.idempotency.persistence.base import DataRecord + + logger = logging.getLogger(__name__) + + + class DynamoDBPersistenceLayer(BasePersistenceLayer): + def __init__( + self, + table_name: str, + key_attr: str = "id", + expiry_attr: str = "expiration", + status_attr: str = "status", + data_attr: str = "data", + validation_key_attr: str = "validation", + boto_config: Optional[Config] = None, + boto3_session: Optional[boto3.session.Session] = None, + ): + boto_config = boto_config or Config() + session = boto3_session or boto3.session.Session() + self._ddb_resource = session.resource("dynamodb", config=boto_config) + self.table_name = table_name + self.table = self._ddb_resource.Table(self.table_name) + self.key_attr = key_attr + self.expiry_attr = expiry_attr + self.status_attr = status_attr + self.data_attr = data_attr + self.validation_key_attr = validation_key_attr + super(DynamoDBPersistenceLayer, self).__init__() + + def _item_to_data_record(self, item: Dict[str, Any]) -> DataRecord: + """ + Translate raw item records from DynamoDB to DataRecord + + Parameters + ---------- + item: Dict[str, Union[str, int]] + Item format from dynamodb response + + Returns + ------- + DataRecord + representation of item + + """ + return DataRecord( + idempotency_key=item[self.key_attr], + status=item[self.status_attr], + expiry_timestamp=item[self.expiry_attr], + response_data=item.get(self.data_attr), + payload_hash=item.get(self.validation_key_attr), + ) + + def _get_record(self, idempotency_key) -> DataRecord: + response = self.table.get_item(Key={self.key_attr: idempotency_key}, ConsistentRead=True) + + try: + item = response["Item"] + except KeyError: + raise IdempotencyItemNotFoundError + return self._item_to_data_record(item) + + def _put_record(self, data_record: DataRecord) -> None: + item = { + self.key_attr: data_record.idempotency_key, + self.expiry_attr: data_record.expiry_timestamp, + self.status_attr: data_record.status, + } + + if self.payload_validation_enabled: + item[self.validation_key_attr] = data_record.payload_hash + + now = datetime.datetime.now() + try: + logger.debug(f"Putting record for idempotency key: {data_record.idempotency_key}") + self.table.put_item( + Item=item, + ConditionExpression=f"attribute_not_exists({self.key_attr}) OR {self.expiry_attr} < :now", + ExpressionAttributeValues={":now": int(now.timestamp())}, + ) + except self._ddb_resource.meta.client.exceptions.ConditionalCheckFailedException: + logger.debug(f"Failed to put record for already existing idempotency key: {data_record.idempotency_key}") + raise IdempotencyItemAlreadyExistsError + + def _update_record(self, data_record: DataRecord): + logger.debug(f"Updating record for idempotency key: {data_record.idempotency_key}") + update_expression = "SET #response_data = :response_data, #expiry = :expiry, #status = :status" + expression_attr_values = { + ":expiry": data_record.expiry_timestamp, + ":response_data": data_record.response_data, + ":status": data_record.status, + } + expression_attr_names = { + "#response_data": self.data_attr, + "#expiry": self.expiry_attr, + "#status": self.status_attr, + } + + if self.payload_validation_enabled: + update_expression += ", #validation_key = :validation_key" + expression_attr_values[":validation_key"] = data_record.payload_hash + expression_attr_names["#validation_key"] = self.validation_key_attr + + kwargs = { + "Key": {self.key_attr: data_record.idempotency_key}, + "UpdateExpression": update_expression, + "ExpressionAttributeValues": expression_attr_values, + "ExpressionAttributeNames": expression_attr_names, + } + + self.table.update_item(**kwargs) + + def _delete_record(self, data_record: DataRecord) -> None: + logger.debug(f"Deleting record for idempotency key: {data_record.idempotency_key}") + self.table.delete_item(Key={self.key_attr: data_record.idempotency_key},) + ``` + +!!! danger + Pay attention to the documentation for each - you may need to perform additional checks inside these methods to ensure the idempotency guarantees remain intact. + + For example, the `_put_record` method needs to raise an exception if a non-expired record already exists in the data store with a matching key. ## Compatibility with other utilities @@ -419,6 +676,10 @@ The idempotency utility can be used with the `validator` decorator. Ensure that return {"message": event['message'], "statusCode": 200} ``` +!!! tip "JMESPath Powertools functions are also available" + Built-in functions known in the validation utility like `powertools_json`, `powertools_base64`, `powertools_base64_gzip` are also available to use in this utility. + ## Extra resources + If you're interested in a deep dive on how Amazon uses idempotency when building our APIs, check out [this article](https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/). diff --git a/mkdocs.yml b/mkdocs.yml index e4c91ca7917..d8d37830369 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -47,6 +47,7 @@ markdown_extensions: permalink: true toc_depth: 4 - attr_list + - pymdownx.emoji copyright: Copyright © 2021 Amazon Web Services diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index 999b34fc8f6..6def9b4868a 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -345,7 +345,7 @@ def test_idempotent_lambda_already_completed_with_validation_bad_payload( hashed_validation_key, ): """ - Test idempotent decorator where event with matching event key has already been succesfully processed + Test idempotent decorator where event with matching event key has already been successfully processed """ stubber = stub.Stubber(persistence_store.table.meta.client) From 17008f2e69eb301d69ae86b24363c0bc9e38332f Mon Sep 17 00:00:00 2001 From: Heitor Lessa Date: Fri, 5 Mar 2021 16:14:29 +0100 Subject: [PATCH 19/21] docs(dataclasses): new Connect Contact Flow (#310) * docs: upgrade navigation * docs: feat new connect dataclass --- docs/utilities/data_classes.md | 65 +++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/docs/utilities/data_classes.md b/docs/utilities/data_classes.md index 231cdf545f1..d03763ab574 100644 --- a/docs/utilities/data_classes.md +++ b/docs/utilities/data_classes.md @@ -5,7 +5,7 @@ description: Utility The event source data classes utility provides classes describing the schema of common Lambda events triggers. -**Key Features** +## Key Features * Type hinting and code completion for common event types * Helper functions for decoding/deserializing nested fields @@ -17,13 +17,30 @@ When authoring Lambda functions, you often need to understand the schema of the handler. There are several common event types which follow a specific schema, depending on the service triggering the Lambda function. +## Getting started -## Utilizing the data classes +### Utilizing the data classes The classes are initialized by passing in the Lambda event object into the constructor of the appropriate data class. + For example, if your Lambda function is being triggered by an API Gateway proxy integration, you can use the `APIGatewayProxyEvent` class. +=== "app.py" + + ```python hl_lines="1 4" + from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEvent + + def lambda_handler(event, context): + event: APIGatewayProxyEvent = APIGatewayProxyEvent(event) + + if 'helloworld' in event.path && event.http_method == 'GET': + do_something_with(event.body, user) + ``` + +**Autocomplete with self-documented properties and methods** + + ![Utilities Data Classes](../media/utilities_data_classes.png) @@ -49,7 +66,7 @@ Event Source | Data_class documentation inherently (via autocompletion, types and docstrings). -## API Gateway Proxy +### API Gateway Proxy Typically used for API Gateway REST API or HTTP API using v1 proxy event. @@ -68,7 +85,7 @@ Typically used for API Gateway REST API or HTTP API using v1 proxy event. do_something_with(event.body, user) ``` -## API Gateway Proxy v2 +### API Gateway Proxy v2 === "lambda_app.py" @@ -84,7 +101,7 @@ Typically used for API Gateway REST API or HTTP API using v1 proxy event. do_something_with(event.body, query_string_parameters) ``` -## CloudWatch Logs +### CloudWatch Logs CloudWatch Logs events by default are compressed and base64 encoded. You can use the helper function provided to decode, decompress and parse json data from the event. @@ -103,7 +120,7 @@ decompress and parse json data from the event. do_something_with(event.timestamp, event.message) ``` -## Cognito User Pool +### Cognito User Pool Cognito User Pools have several [different Lambda trigger sources](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools-working-with-aws-lambda-triggers.html#cognito-user-identity-pools-working-with-aws-lambda-trigger-sources), all of which map to a different data class, which can be imported from `aws_lambda_powertools.data_classes.cognito_user_pool_event`: @@ -133,7 +150,7 @@ Verify Auth Challenge | `data_classes.cognito_user_pool_event.VerifyAuthChalleng do_something_with(user_attributes) ``` -## DynamoDB Streams +### DynamoDB Streams The DynamoDB data class utility provides the base class for `DynamoDBStreamEvent`, a typed class for attributes values (`AttributeValue`), as well as enums for stream view type (`StreamViewType`) and event type @@ -154,7 +171,7 @@ attributes values (`AttributeValue`), as well as enums for stream view type (`St do_something_with(record.dynamodb.old_image) ``` -## EventBridge +### EventBridge === "lambda_app.py" @@ -167,7 +184,7 @@ attributes values (`AttributeValue`), as well as enums for stream view type (`St ``` -## Kinesis streams +### Kinesis streams Kinesis events by default contain base64 encoded data. You can use the helper function to access the data either as json or plain text, depending on the original payload. @@ -189,7 +206,7 @@ or plain text, depending on the original payload. do_something_with(data) ``` -## S3 +### S3 === "lambda_app.py" @@ -207,7 +224,7 @@ or plain text, depending on the original payload. do_something_with(f'{bucket_name}/{object_key}') ``` -## SES +### SES === "lambda_app.py" @@ -225,7 +242,7 @@ or plain text, depending on the original payload. do_something_with(common_headers.to, common_headers.subject) ``` -## SNS +### SNS === "lambda_app.py" @@ -243,7 +260,7 @@ or plain text, depending on the original payload. do_something_with(subject, message) ``` -## SQS +### SQS === "lambda_app.py" @@ -257,3 +274,25 @@ or plain text, depending on the original payload. for record in event.records: do_something_with(record.body) ``` + +### Connect + +**Connect Contact Flow** + +=== "lambda_app.py" + + ```python + from aws_lambda_powertools.utilities.data_classes.connect_contact_flow_event import ( + ConnectContactFlowChannel, + ConnectContactFlowEndpointType, + ConnectContactFlowEvent, + ConnectContactFlowInitiationMethod, + ) + + def lambda_handler(event, context): + event: ConnectContactFlowEvent = ConnectContactFlowEvent(event) + assert event.contact_data.attributes == {"Language": "en-US"} + assert event.contact_data.channel == ConnectContactFlowChannel.VOICE + assert event.contact_data.customer_endpoint.endpoint_type == ConnectContactFlowEndpointType.TELEPHONE_NUMBER + assert event.contact_data.initiation_method == ConnectContactFlowInitiationMethod.API + ``` From 3b0bfdb7b7b4481325b68c8b1f9146bf592ac1c7 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Fri, 5 Mar 2021 16:16:40 +0100 Subject: [PATCH 20/21] chore: adjusts Metrics SLA for slow py36 interpreters --- tests/performance/test_metrics.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/performance/test_metrics.py b/tests/performance/test_metrics.py index bfbe67b60b1..e01a11a5573 100644 --- a/tests/performance/test_metrics.py +++ b/tests/performance/test_metrics.py @@ -10,8 +10,8 @@ from aws_lambda_powertools.metrics import metrics as metrics_global # adjusted for slower machines in CI too -METRICS_VALIDATION_SLA: float = 0.0013 -METRICS_SERIALIZATION_SLA: float = 0.0013 +METRICS_VALIDATION_SLA: float = 0.0019 +METRICS_SERIALIZATION_SLA: float = 0.0019 @contextmanager From 696f93e28782ad8c3516aa342063dcb879936b43 Mon Sep 17 00:00:00 2001 From: Heitor Lessa Date: Fri, 5 Mar 2021 17:31:28 +0100 Subject: [PATCH 21/21] chore: update changelog (#311) --- CHANGELOG.md | 12 ++++++++++-- pyproject.toml | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e77d594c30..bf37163bc4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,18 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) fo ## [Unreleased] +## [1.11.0] - 2021-03-05 ### Fixed -* **Tracer**: Lazy loads X-Ray SDK to improve import perf for those not instantiating Tracer -* **Metrics**: Convert EMF JSON Schema as Dictionary to reduce I/O and improve import perf +* **Tracer**: Lazy loads X-Ray SDK to increase perf by 75% for those not instantiating Tracer +* **Metrics**: Optimize validation and serialization to increase perf by nearly 50% for large operations (<1ms) + +### Added + +* **Dataclass**: Add new Amazon Connect contact flow event +* **Idempotency**: New Idempotency utility +* **Docs**: Add example on how to integrate Batch utility with Sentry.io +* **Internal**: Added performance SLA tests for high level imports and Metrics validation/serialization ## [1.10.5] - 2021-02-17 diff --git a/pyproject.toml b/pyproject.toml index eb45c8418c8..5ee64ed567a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "aws_lambda_powertools" -version = "1.10.5" +version = "1.11.0" description = "Python utilities for AWS Lambda functions including but not limited to tracing, logging and custom metric" authors = ["Amazon Web Services"] include = ["aws_lambda_powertools/py.typed"]