diff --git a/Makefile b/Makefile index 6dcedfd8b6f..7ea1d8c29f1 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ target: dev: pip install --upgrade pip poetry pre-commit - poetry install + poetry install --extras "pydantic" pre-commit install dev-docs: diff --git a/aws_lambda_powertools/utilities/advanced_parser/__init__.py b/aws_lambda_powertools/utilities/advanced_parser/__init__.py new file mode 100644 index 00000000000..017b5086bb0 --- /dev/null +++ b/aws_lambda_powertools/utilities/advanced_parser/__init__.py @@ -0,0 +1,6 @@ +"""Advanced parser utility +""" +from .envelopes import Envelope, InvalidEnvelopeError, parse_envelope +from .parser import parser + +__all__ = ["InvalidEnvelopeError", "Envelope", "parse_envelope", "parser"] diff --git a/aws_lambda_powertools/utilities/advanced_parser/envelopes/__init__.py b/aws_lambda_powertools/utilities/advanced_parser/envelopes/__init__.py new file mode 100644 index 00000000000..5fa4c396ba1 --- /dev/null +++ b/aws_lambda_powertools/utilities/advanced_parser/envelopes/__init__.py @@ -0,0 +1,3 @@ +from .envelopes import Envelope, InvalidEnvelopeError, parse_envelope + +__all__ = ["InvalidEnvelopeError", "Envelope", "parse_envelope"] diff --git a/aws_lambda_powertools/utilities/advanced_parser/envelopes/base.py b/aws_lambda_powertools/utilities/advanced_parser/envelopes/base.py new file mode 100644 index 00000000000..0b909312a0e --- /dev/null +++ b/aws_lambda_powertools/utilities/advanced_parser/envelopes/base.py @@ -0,0 +1,38 @@ +import logging +from abc import ABC, abstractmethod +from typing import Any, Dict + +from pydantic import BaseModel, ValidationError + +logger = logging.getLogger(__name__) + + +class BaseEnvelope(ABC): + def _parse_user_dict_schema(self, user_event: Dict[str, Any], schema: BaseModel) -> Any: + if user_event is None: + return None + logger.debug("parsing user dictionary schema") + try: + return schema(**user_event) + except (ValidationError, TypeError): + logger.exception("Validation exception while extracting user custom schema") + raise + + def _parse_user_json_string_schema(self, user_event: str, schema: BaseModel) -> Any: + if user_event is None: + return None + # this is used in cases where the underlying schema is not a Dict that can be parsed as baseModel + # but a plain string i.e SQS has plain string payload + if schema == str: + logger.debug("input is string, returning") + return user_event + logger.debug("trying to parse as json encoded string") + try: + return schema.parse_raw(user_event) + except (ValidationError, TypeError): + logger.exception("Validation exception while extracting user custom schema") + raise + + @abstractmethod + def parse(self, event: Dict[str, Any], schema: BaseModel): + return NotImplemented diff --git a/aws_lambda_powertools/utilities/advanced_parser/envelopes/dynamodb.py b/aws_lambda_powertools/utilities/advanced_parser/envelopes/dynamodb.py new file mode 100644 index 00000000000..27f1177ef1b --- /dev/null +++ b/aws_lambda_powertools/utilities/advanced_parser/envelopes/dynamodb.py @@ -0,0 +1,31 @@ +import logging +from typing import Any, Dict, List +from typing_extensions import Literal + +from pydantic import BaseModel, ValidationError + +from aws_lambda_powertools.utilities.advanced_parser.envelopes.base import BaseEnvelope +from aws_lambda_powertools.utilities.advanced_parser.schemas import DynamoDBSchema + +logger = logging.getLogger(__name__) + + +# returns a List of dictionaries which each contains two keys, "NewImage" and "OldImage". +# The values are the parsed schema models. The images' values can also be None. +# Length of the list is the record's amount in the original event. +class DynamoDBEnvelope(BaseEnvelope): + def parse(self, event: Dict[str, Any], schema: BaseModel) -> List[Dict[Literal["NewImage", "OldImage"], BaseModel]]: + try: + parsed_envelope = DynamoDBSchema(**event) + except (ValidationError, TypeError): + logger.exception("Validation exception received from input dynamodb stream event") + raise + output = [] + for record in parsed_envelope.Records: + output.append( + { + "NewImage": self._parse_user_dict_schema(record.dynamodb.NewImage, schema), + "OldImage": self._parse_user_dict_schema(record.dynamodb.OldImage, schema), + } + ) + return output diff --git a/aws_lambda_powertools/utilities/advanced_parser/envelopes/envelopes.py b/aws_lambda_powertools/utilities/advanced_parser/envelopes/envelopes.py new file mode 100644 index 00000000000..332c1eadef0 --- /dev/null +++ b/aws_lambda_powertools/utilities/advanced_parser/envelopes/envelopes.py @@ -0,0 +1,42 @@ +import logging +from enum import Enum +from typing import Any, Dict + +from pydantic import BaseModel + +from aws_lambda_powertools.utilities.advanced_parser.envelopes.base import BaseEnvelope +from aws_lambda_powertools.utilities.advanced_parser.envelopes.dynamodb import DynamoDBEnvelope +from aws_lambda_powertools.utilities.advanced_parser.envelopes.event_bridge import EventBridgeEnvelope +from aws_lambda_powertools.utilities.advanced_parser.envelopes.sqs import SqsEnvelope + +logger = logging.getLogger(__name__) + + +"""Built-in envelopes""" + + +class Envelope(str, Enum): + SQS = "sqs" + EVENTBRIDGE = "eventbridge" + DYNAMODB_STREAM = "dynamodb_stream" + + +class InvalidEnvelopeError(Exception): + """Input envelope is not one of the Envelope enum values""" + + +# enum to BaseEnvelope handler class +__ENVELOPE_MAPPING = { + Envelope.SQS: SqsEnvelope, + Envelope.DYNAMODB_STREAM: DynamoDBEnvelope, + Envelope.EVENTBRIDGE: EventBridgeEnvelope, +} + + +def parse_envelope(event: Dict[str, Any], envelope: Envelope, schema: BaseModel): + envelope_handler: BaseEnvelope = __ENVELOPE_MAPPING.get(envelope) + if envelope_handler is None: + logger.exception("envelope must be an instance of Envelope enum") + raise InvalidEnvelopeError("envelope must be an instance of Envelope enum") + logger.debug(f"Parsing and validating event schema, envelope={str(envelope.value)}") + return envelope_handler().parse(event=event, schema=schema) diff --git a/aws_lambda_powertools/utilities/advanced_parser/envelopes/event_bridge.py b/aws_lambda_powertools/utilities/advanced_parser/envelopes/event_bridge.py new file mode 100644 index 00000000000..00052d41da0 --- /dev/null +++ b/aws_lambda_powertools/utilities/advanced_parser/envelopes/event_bridge.py @@ -0,0 +1,20 @@ +import logging +from typing import Any, Dict + +from pydantic import BaseModel, ValidationError + +from aws_lambda_powertools.utilities.advanced_parser.envelopes.base import BaseEnvelope +from aws_lambda_powertools.utilities.advanced_parser.schemas import EventBridgeSchema + +logger = logging.getLogger(__name__) + + +# returns a parsed BaseModel object according to schema type +class EventBridgeEnvelope(BaseEnvelope): + def parse(self, event: Dict[str, Any], schema: BaseModel) -> BaseModel: + try: + parsed_envelope = EventBridgeSchema(**event) + except (ValidationError, TypeError): + logger.exception("Validation exception received from input eventbridge event") + raise + return self._parse_user_dict_schema(parsed_envelope.detail, schema) diff --git a/aws_lambda_powertools/utilities/advanced_parser/envelopes/sqs.py b/aws_lambda_powertools/utilities/advanced_parser/envelopes/sqs.py new file mode 100644 index 00000000000..8ef2e685c4f --- /dev/null +++ b/aws_lambda_powertools/utilities/advanced_parser/envelopes/sqs.py @@ -0,0 +1,27 @@ +import logging +from typing import Any, Dict, List, Union + +from pydantic import BaseModel, ValidationError + +from aws_lambda_powertools.utilities.advanced_parser.envelopes.base import BaseEnvelope +from aws_lambda_powertools.utilities.advanced_parser.schemas import SqsSchema + +logger = logging.getLogger(__name__) + + +# returns a list of parsed schemas of type BaseModel or plain string. +# The record's body parameter is a string. However, it can also be a JSON encoded string which +# can then be parsed into a BaseModel object. +# Note that all records will be parsed the same way so if schema is str, +# all the items in the list will be parsed as str and npt as JSON (and vice versa). +class SqsEnvelope(BaseEnvelope): + def parse(self, event: Dict[str, Any], schema: Union[BaseModel, str]) -> List[Union[BaseModel, str]]: + try: + parsed_envelope = SqsSchema(**event) + except (ValidationError, TypeError): + logger.exception("Validation exception received from input sqs event") + raise + output = [] + for record in parsed_envelope.Records: + output.append(self._parse_user_json_string_schema(record.body, schema)) + return output diff --git a/aws_lambda_powertools/utilities/advanced_parser/parser.py b/aws_lambda_powertools/utilities/advanced_parser/parser.py new file mode 100644 index 00000000000..b501d0a5146 --- /dev/null +++ b/aws_lambda_powertools/utilities/advanced_parser/parser.py @@ -0,0 +1,68 @@ +import logging +from typing import Any, Callable, Dict, Optional + +from pydantic import BaseModel, ValidationError + +from aws_lambda_powertools.middleware_factory import lambda_handler_decorator +from aws_lambda_powertools.utilities.advanced_parser.envelopes import Envelope, parse_envelope + +logger = logging.getLogger(__name__) + + +@lambda_handler_decorator +def parser( + handler: Callable[[Dict, Any], Any], + event: Dict[str, Any], + context: Dict[str, Any], + schema: BaseModel, + envelope: Optional[Envelope] = None, +) -> Any: + """Decorator to conduct advanced parsing & validation for lambda handlers events + + As Lambda follows (event, context) signature we can remove some of the boilerplate + and also capture any exception any Lambda function throws as metadata. + event will be the parsed and passed as a BaseModel pydantic class of the input type "schema" + to the lambda handler. + event will be extracted from the envelope in case envelope is not None. + In case envelope is None, the complete event is parsed to match the schema parameter BaseModel definition. + In case envelope is not None, first the event is parsed as the envelope's schema definition, and the user + message is extracted and parsed again as the schema parameter's definition. + + Example + ------- + **Lambda function using validation decorator** + + @parser(schema=MyBusiness, envelope=envelopes.EVENTBRIDGE) + def handler(event: MyBusiness , context: LambdaContext): + ... + + Parameters + ---------- + handler: input for lambda_handler_decorator, wraps the handler lambda + event: AWS event dictionary + context: AWS lambda context + schema: pydantic BaseModel class. This is the user data schema that will replace the event. + event parameter will be parsed and a new schema object will be created from it. + envelope: what envelope to extract the schema from, can be any AWS service that is currently + supported in the envelopes module. Can be None. + + Raises + ------ + err + TypeError - in case event is None + pydantic.ValidationError - event fails validation, either of the envelope + """ + lambda_handler_name = handler.__name__ + parsed_event = None + if envelope is None: + try: + logger.debug("Parsing and validating event schema, no envelope is used") + parsed_event = schema(**event) + except (ValidationError, TypeError): + logger.exception("Validation exception received from input event") + raise + else: + parsed_event = parse_envelope(event, envelope, schema) + + logger.debug(f"Calling handler {lambda_handler_name}") + return handler(parsed_event, context) diff --git a/aws_lambda_powertools/utilities/advanced_parser/schemas/__init__.py b/aws_lambda_powertools/utilities/advanced_parser/schemas/__init__.py new file mode 100644 index 00000000000..ac470a16c94 --- /dev/null +++ b/aws_lambda_powertools/utilities/advanced_parser/schemas/__init__.py @@ -0,0 +1,12 @@ +from .dynamodb import DynamoDBSchema, DynamoRecordSchema, DynamoScheme +from .event_bridge import EventBridgeSchema +from .sqs import SqsRecordSchema, SqsSchema + +__all__ = [ + "DynamoDBSchema", + "EventBridgeSchema", + "DynamoScheme", + "DynamoRecordSchema", + "SqsSchema", + "SqsRecordSchema", +] diff --git a/aws_lambda_powertools/utilities/advanced_parser/schemas/dynamodb.py b/aws_lambda_powertools/utilities/advanced_parser/schemas/dynamodb.py new file mode 100644 index 00000000000..3fc9c9ecae4 --- /dev/null +++ b/aws_lambda_powertools/utilities/advanced_parser/schemas/dynamodb.py @@ -0,0 +1,45 @@ +from datetime import date +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, root_validator +from typing_extensions import Literal + + +class DynamoScheme(BaseModel): + ApproximateCreationDateTime: Optional[date] + Keys: Dict[str, Dict[str, Any]] + NewImage: Optional[Dict[str, Any]] + OldImage: Optional[Dict[str, Any]] + SequenceNumber: str + SizeBytes: int + StreamViewType: Literal["NEW_AND_OLD_IMAGES", "KEYS_ONLY", "NEW_IMAGE", "OLD_IMAGE"] + + # since both images are optional, they can both be None. However, at least one must + # exist in a legal schema of NEW_AND_OLD_IMAGES type + @root_validator + def check_one_image_exists(cls, values): + newimg, oldimg = values.get("NewImage"), values.get("OldImage") + stream_type = values.get("StreamViewType") + if stream_type == "NEW_AND_OLD_IMAGES" and not newimg and not oldimg: + raise TypeError("DynamoDB streams schema failed validation, missing both new & old stream images") + return values + + +class UserIdentity(BaseModel): + type: Literal["Service"] # noqa: VNE003, A003 + principalId: Literal["dynamodb.amazonaws.com"] + + +class DynamoRecordSchema(BaseModel): + eventID: str + eventName: Literal["INSERT", "MODIFY", "REMOVE"] + eventVersion: float + eventSource: Literal["aws:dynamodb"] + awsRegion: str + eventSourceARN: str + dynamodb: DynamoScheme + userIdentity: Optional[UserIdentity] + + +class DynamoDBSchema(BaseModel): + Records: List[DynamoRecordSchema] diff --git a/aws_lambda_powertools/utilities/advanced_parser/schemas/event_bridge.py b/aws_lambda_powertools/utilities/advanced_parser/schemas/event_bridge.py new file mode 100644 index 00000000000..c5e319ac28e --- /dev/null +++ b/aws_lambda_powertools/utilities/advanced_parser/schemas/event_bridge.py @@ -0,0 +1,16 @@ +from datetime import datetime +from typing import Any, Dict, List + +from pydantic import BaseModel, Field + + +class EventBridgeSchema(BaseModel): + version: str + id: str # noqa: A003,VNE003 + source: str + account: str + time: datetime + region: str + resources: List[str] + detailtype: str = Field(None, alias="detail-type") + detail: Dict[str, Any] diff --git a/aws_lambda_powertools/utilities/advanced_parser/schemas/sqs.py b/aws_lambda_powertools/utilities/advanced_parser/schemas/sqs.py new file mode 100644 index 00000000000..621738eaab0 --- /dev/null +++ b/aws_lambda_powertools/utilities/advanced_parser/schemas/sqs.py @@ -0,0 +1,65 @@ +import re +from datetime import datetime +from typing import Dict, List, Optional + +from pydantic import BaseModel, root_validator, validator +from typing_extensions import Literal + + +class SqsAttributesSchema(BaseModel): + ApproximateReceiveCount: str + ApproximateFirstReceiveTimestamp: datetime + MessageDeduplicationId: Optional[str] + MessageGroupId: Optional[str] + SenderId: str + SentTimestamp: datetime + SequenceNumber: Optional[str] + AWSTraceHeader: Optional[str] + + +class SqsMsgAttributeSchema(BaseModel): + stringValue: Optional[str] + binaryValue: Optional[str] + stringListValues: List[str] = [] + binaryListValues: List[str] = [] + dataType: str + + # Amazon SQS supports the logical data types String, Number, and Binary with optional custom data type + # labels with the format .custom-data-type. + # https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-message-metadata.html#sqs-message-attributes + @validator("dataType") + def valid_type(cls, v): # noqa: VNE001 + pattern = re.compile("Number.*|String.*|Binary.*") + if not pattern.match(v): + raise TypeError("data type is invalid") + return v + + # validate that dataType and value are not None and match + @root_validator + def check_str_and_binary_values(cls, values): + binary_val, str_val = values.get("binaryValue", ""), values.get("stringValue", "") + dataType = values.get("dataType") + if not str_val and not binary_val: + raise TypeError("both binaryValue and stringValue are missing") + if dataType.startswith("Binary") and not binary_val: + raise TypeError("binaryValue is missing") + if (dataType.startswith("String") or dataType.startswith("Number")) and not str_val: + raise TypeError("stringValue is missing") + return values + + +class SqsRecordSchema(BaseModel): + messageId: str + receiptHandle: str + body: str + attributes: SqsAttributesSchema + messageAttributes: Dict[str, SqsMsgAttributeSchema] + md5OfBody: str + md5OfMessageAttributes: Optional[str] + eventSource: Literal["aws:sqs"] + eventSourceARN: str + awsRegion: str + + +class SqsSchema(BaseModel): + Records: List[SqsRecordSchema] diff --git a/poetry.lock b/poetry.lock index f1917844a4f..94bf8b20b7c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -21,12 +21,13 @@ description = "Classes Without Boilerplate" name = "attrs" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "20.1.0" +version = "20.2.0" [package.extras] dev = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "sphinx-rtd-theme", "pre-commit"] docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] tests = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] +tests_no_zope = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] [[package]] category = "main" @@ -83,10 +84,10 @@ description = "The AWS SDK for Python" name = "boto3" optional = false python-versions = "*" -version = "1.12.32" +version = "1.15.5" [package.dependencies] -botocore = ">=1.15.32,<1.16.0" +botocore = ">=1.18.5,<1.19.0" jmespath = ">=0.7.1,<1.0.0" s3transfer = ">=0.3.0,<0.4.0" @@ -96,10 +97,9 @@ description = "Low-level, data-driven core of boto 3." name = "botocore" optional = false python-versions = "*" -version = "1.15.32" +version = "1.18.5" [package.dependencies] -docutils = ">=0.10,<0.16" jmespath = ">=0.7.1,<1.0.0" python-dateutil = ">=2.1,<3.0.0" @@ -146,7 +146,7 @@ description = "Code coverage measurement for Python" name = "coverage" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -version = "5.2.1" +version = "5.3" [package.dependencies] [package.dependencies.toml] @@ -158,11 +158,12 @@ toml = ["toml"] [[package]] category = "main" -description = "Docutils -- Python Documentation Utilities" -name = "docutils" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -version = "0.15.2" +description = "A backport of the dataclasses module for Python 3.6" +marker = "python_version < \"3.7\"" +name = "dataclasses" +optional = true +python-versions = "*" +version = "0.6" [[package]] category = "dev" @@ -352,7 +353,7 @@ description = "Python Git Library" name = "gitpython" optional = false python-versions = ">=3.4" -version = "3.1.7" +version = "3.1.8" [package.dependencies] gitdb = ">=4.0.1,<5" @@ -371,7 +372,7 @@ description = "Read metadata from Python packages" name = "importlib-metadata" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -version = "1.7.0" +version = "2.0.0" [package.dependencies] zipp = ">=0.5" @@ -512,8 +513,8 @@ category = "dev" description = "Python Build Reasonableness" name = "pbr" optional = false -python-versions = "*" -version = "5.4.5" +python-versions = ">=2.6" +version = "5.5.0" [[package]] category = "dev" @@ -559,6 +560,24 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "2.6.0" +[[package]] +category = "main" +description = "Data validation and settings management using python 3.6 type hinting" +name = "pydantic" +optional = true +python-versions = ">=3.6" +version = "1.6.1" + +[package.dependencies] +[package.dependencies.dataclasses] +python = "<3.7" +version = ">=0.6" + +[package.extras] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] +typing_extensions = ["typing-extensions (>=3.7.2)"] + [[package]] category = "dev" description = "passive checker of Python programs" @@ -669,10 +688,9 @@ description = "Code Metrics in Python" name = "radon" optional = false python-versions = "*" -version = "4.2.0" +version = "4.3.2" [package.dependencies] -flake8-polyfill = "*" future = "*" mando = ">=0.6,<0.7" @@ -680,6 +698,9 @@ mando = ">=0.6,<0.7" python = ">=3.5" version = ">=0.4.1" +[package.extras] +flake8 = ["flake8-polyfill"] + [[package]] category = "dev" description = "Alternative regular expression module, to replace re." @@ -739,7 +760,7 @@ description = "Manage dynamic plugins for Python applications" name = "stevedore" optional = false python-versions = ">=3.6" -version = "3.2.0" +version = "3.2.2" [package.dependencies] pbr = ">=2.0.0,<2.1.0 || >2.1.0" @@ -754,7 +775,7 @@ description = "A collection of helpers and mock objects for unit tests and doc t name = "testfixtures" optional = false python-versions = "*" -version = "6.14.1" +version = "6.14.2" [package.extras] build = ["setuptools-git", "wheel", "twine"] @@ -777,6 +798,14 @@ optional = false python-versions = "*" version = "1.4.1" +[[package]] +category = "main" +description = "Backported and Experimental Type Hints for Python 3.5+" +name = "typing-extensions" +optional = true +python-versions = "*" +version = "3.7.4.3" + [[package]] category = "main" description = "HTTP library with thread-safe connection pooling, file post, and more." @@ -812,27 +841,34 @@ description = "Monitor code metrics for Python on your CI server" name = "xenon" optional = false python-versions = "*" -version = "0.7.0" +version = "0.7.1" [package.dependencies] PyYAML = ">=4.2b1,<6.0" -radon = ">=4,<5" requests = ">=2.0,<3.0" +[package.dependencies.radon] +extras = ["flake8"] +version = ">=4,<5" + [[package]] category = "main" description = "Backport of pathlib-compatible object wrapper for zip files" +marker = "python_version < \"3.8\"" name = "zipp" optional = false python-versions = ">=3.6" -version = "3.1.0" +version = "3.2.0" [package.extras] docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] -testing = ["jaraco.itertools", "func-timeout"] +testing = ["pytest (>=3.5,<3.7.3 || >3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] + +[extras] +pydantic = ["pydantic", "typing_extensions"] [metadata] -content-hash = "a2036c75a7509f15f2273d2d907fa4d1418f56e76a948994ae01ce0e26a0175a" +content-hash = "f2207b4e243108a8b2b2eee5a56f648519d2ce8cb893f4e3c8fb346a44374eaa" lock-version = "1.0" python-versions = "^3.6" @@ -846,8 +882,8 @@ atomicwrites = [ {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, ] attrs = [ - {file = "attrs-20.1.0-py2.py3-none-any.whl", hash = "sha256:2867b7b9f8326499ab5b0e2d12801fa5c98842d2cbd22b35112ae04bf85b4dff"}, - {file = "attrs-20.1.0.tar.gz", hash = "sha256:0ef97238856430dcf9228e07f316aefc17e8939fc8507e18c6501b761ef1a42a"}, + {file = "attrs-20.2.0-py2.py3-none-any.whl", hash = "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"}, + {file = "attrs-20.2.0.tar.gz", hash = "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594"}, ] aws-xray-sdk = [ {file = "aws-xray-sdk-2.6.0.tar.gz", hash = "sha256:abf5b90f740e1f402e23414c9670e59cb9772e235e271fef2bce62b9100cbc77"}, @@ -862,12 +898,12 @@ black = [ {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, ] boto3 = [ - {file = "boto3-1.12.32-py2.py3-none-any.whl", hash = "sha256:57398de1b5e074e715c866441e69f90c9468959d5743a021d8aeed04fbaa1078"}, - {file = "boto3-1.12.32.tar.gz", hash = "sha256:60ac1124597231ed36a7320547cd0d16a001bb92333ab30ad20514f77e585225"}, + {file = "boto3-1.15.5-py2.py3-none-any.whl", hash = "sha256:0c464a7de522f88b581ca0d41ffa71e9be5e17fbb0456c275421f65b7c5f6a55"}, + {file = "boto3-1.15.5.tar.gz", hash = "sha256:0fce548e19d6db8e11fd0e2ae7809e1e3282080636b4062b2452bfa20e4f0233"}, ] botocore = [ - {file = "botocore-1.15.32-py2.py3-none-any.whl", hash = "sha256:a963af564d94107787ff3d2c534e8b7aed7f12e014cdd609f8fcb17bf9d9b19a"}, - {file = "botocore-1.15.32.tar.gz", hash = "sha256:3ea89601ee452b65084005278bd832be854cfde5166685dcb14b6c8f19d3fc6d"}, + {file = "botocore-1.18.5-py2.py3-none-any.whl", hash = "sha256:e3bf44fba058f6df16006b94a67650418a080a525c82521abb3cb516a4cba362"}, + {file = "botocore-1.18.5.tar.gz", hash = "sha256:7ce7a05b98ffb3170396960273383e8aade9be6026d5a762f5f40969d5d6b761"}, ] certifi = [ {file = "certifi-2020.6.20-py2.py3-none-any.whl", hash = "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"}, @@ -886,45 +922,44 @@ colorama = [ {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, ] coverage = [ - {file = "coverage-5.2.1-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:40f70f81be4d34f8d491e55936904db5c527b0711b2a46513641a5729783c2e4"}, - {file = "coverage-5.2.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:675192fca634f0df69af3493a48224f211f8db4e84452b08d5fcebb9167adb01"}, - {file = "coverage-5.2.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:2fcc8b58953d74d199a1a4d633df8146f0ac36c4e720b4a1997e9b6327af43a8"}, - {file = "coverage-5.2.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:64c4f340338c68c463f1b56e3f2f0423f7b17ba6c3febae80b81f0e093077f59"}, - {file = "coverage-5.2.1-cp27-cp27m-win32.whl", hash = "sha256:52f185ffd3291196dc1aae506b42e178a592b0b60a8610b108e6ad892cfc1bb3"}, - {file = "coverage-5.2.1-cp27-cp27m-win_amd64.whl", hash = "sha256:30bc103587e0d3df9e52cd9da1dd915265a22fad0b72afe54daf840c984b564f"}, - {file = "coverage-5.2.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:9ea749fd447ce7fb1ac71f7616371f04054d969d412d37611716721931e36efd"}, - {file = "coverage-5.2.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ce7866f29d3025b5b34c2e944e66ebef0d92e4a4f2463f7266daa03a1332a651"}, - {file = "coverage-5.2.1-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:4869ab1c1ed33953bb2433ce7b894a28d724b7aa76c19b11e2878034a4e4680b"}, - {file = "coverage-5.2.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a3ee9c793ffefe2944d3a2bd928a0e436cd0ac2d9e3723152d6fd5398838ce7d"}, - {file = "coverage-5.2.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:28f42dc5172ebdc32622a2c3f7ead1b836cdbf253569ae5673f499e35db0bac3"}, - {file = "coverage-5.2.1-cp35-cp35m-win32.whl", hash = "sha256:e26c993bd4b220429d4ec8c1468eca445a4064a61c74ca08da7429af9bc53bb0"}, - {file = "coverage-5.2.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4186fc95c9febeab5681bc3248553d5ec8c2999b8424d4fc3a39c9cba5796962"}, - {file = "coverage-5.2.1-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:b360d8fd88d2bad01cb953d81fd2edd4be539df7bfec41e8753fe9f4456a5082"}, - {file = "coverage-5.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:1adb6be0dcef0cf9434619d3b892772fdb48e793300f9d762e480e043bd8e716"}, - {file = "coverage-5.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:098a703d913be6fbd146a8c50cc76513d726b022d170e5e98dc56d958fd592fb"}, - {file = "coverage-5.2.1-cp36-cp36m-win32.whl", hash = "sha256:962c44070c281d86398aeb8f64e1bf37816a4dfc6f4c0f114756b14fc575621d"}, - {file = "coverage-5.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1ed2bdb27b4c9fc87058a1cb751c4df8752002143ed393899edb82b131e0546"}, - {file = "coverage-5.2.1-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:c890728a93fffd0407d7d37c1e6083ff3f9f211c83b4316fae3778417eab9811"}, - {file = "coverage-5.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:538f2fd5eb64366f37c97fdb3077d665fa946d2b6d95447622292f38407f9258"}, - {file = "coverage-5.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:27ca5a2bc04d68f0776f2cdcb8bbd508bbe430a7bf9c02315cd05fb1d86d0034"}, - {file = "coverage-5.2.1-cp37-cp37m-win32.whl", hash = "sha256:aab75d99f3f2874733946a7648ce87a50019eb90baef931698f96b76b6769a46"}, - {file = "coverage-5.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:c2ff24df02a125b7b346c4c9078c8936da06964cc2d276292c357d64378158f8"}, - {file = "coverage-5.2.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:304fbe451698373dc6653772c72c5d5e883a4aadaf20343592a7abb2e643dae0"}, - {file = "coverage-5.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:c96472b8ca5dc135fb0aa62f79b033f02aa434fb03a8b190600a5ae4102df1fd"}, - {file = "coverage-5.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8505e614c983834239f865da2dd336dcf9d72776b951d5dfa5ac36b987726e1b"}, - {file = "coverage-5.2.1-cp38-cp38-win32.whl", hash = "sha256:700997b77cfab016533b3e7dbc03b71d33ee4df1d79f2463a318ca0263fc29dd"}, - {file = "coverage-5.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:46794c815e56f1431c66d81943fa90721bb858375fb36e5903697d5eef88627d"}, - {file = "coverage-5.2.1-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:16042dc7f8e632e0dcd5206a5095ebd18cb1d005f4c89694f7f8aafd96dd43a3"}, - {file = "coverage-5.2.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:c1bbb628ed5192124889b51204de27c575b3ffc05a5a91307e7640eff1d48da4"}, - {file = "coverage-5.2.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:4f6428b55d2916a69f8d6453e48a505c07b2245653b0aa9f0dee38785939f5e4"}, - {file = "coverage-5.2.1-cp39-cp39-win32.whl", hash = "sha256:9e536783a5acee79a9b308be97d3952b662748c4037b6a24cbb339dc7ed8eb89"}, - {file = "coverage-5.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:b8f58c7db64d8f27078cbf2a4391af6aa4e4767cc08b37555c4ae064b8558d9b"}, - {file = "coverage-5.2.1.tar.gz", hash = "sha256:a34cb28e0747ea15e82d13e14de606747e9e484fb28d63c999483f5d5188e89b"}, -] -docutils = [ - {file = "docutils-0.15.2-py2-none-any.whl", hash = "sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827"}, - {file = "docutils-0.15.2-py3-none-any.whl", hash = "sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0"}, - {file = "docutils-0.15.2.tar.gz", hash = "sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99"}, + {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"}, +] +dataclasses = [ + {file = "dataclasses-0.6-py3-none-any.whl", hash = "sha256:454a69d788c7fda44efd71e259be79577822f5e3f53f029a22d08004e951dc9f"}, + {file = "dataclasses-0.6.tar.gz", hash = "sha256:6988bd2b895eef432d562370bb707d540f32f7360ab13da45340101bc2307d84"}, ] eradicate = [ {file = "eradicate-1.0.tar.gz", hash = "sha256:4ffda82aae6fd49dfffa777a857cb758d77502a1f2e0f54c9ac5155a39d2d01a"}, @@ -982,16 +1017,16 @@ gitdb = [ {file = "gitdb-4.0.5.tar.gz", hash = "sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9"}, ] gitpython = [ - {file = "GitPython-3.1.7-py3-none-any.whl", hash = "sha256:fa3b92da728a457dd75d62bb5f3eb2816d99a7fe6c67398e260637a40e3fafb5"}, - {file = "GitPython-3.1.7.tar.gz", hash = "sha256:2db287d71a284e22e5c2846042d0602465c7434d910406990d5b74df4afb0858"}, + {file = "GitPython-3.1.8-py3-none-any.whl", hash = "sha256:1858f4fd089abe92ae465f01d5aaaf55e937eca565fb2c1fce35a51b5f85c910"}, + {file = "GitPython-3.1.8.tar.gz", hash = "sha256:080bf8e2cf1a2b907634761c2eaefbe83b69930c94c66ad11b65a8252959f912"}, ] idna = [ {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, ] importlib-metadata = [ - {file = "importlib_metadata-1.7.0-py2.py3-none-any.whl", hash = "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"}, - {file = "importlib_metadata-1.7.0.tar.gz", hash = "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83"}, + {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"}, ] isort = [ {file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"}, @@ -1069,8 +1104,8 @@ pathspec = [ {file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"}, ] pbr = [ - {file = "pbr-5.4.5-py2.py3-none-any.whl", hash = "sha256:579170e23f8e0c2f24b0de612f71f648eccb79fb1322c814ae6b3c07b5ba23e8"}, - {file = "pbr-5.4.5.tar.gz", hash = "sha256:07f558fece33b05caf857474a366dfcc00562bca13dd8b47b2b3e22d9f9bf55c"}, + {file = "pbr-5.5.0-py2.py3-none-any.whl", hash = "sha256:5adc0f9fc64319d8df5ca1e4e06eea674c26b80e6f00c530b18ce6a6592ead15"}, + {file = "pbr-5.5.0.tar.gz", hash = "sha256:14bfd98f51c78a3dd22a1ef45cf194ad79eee4a19e8e1a0d5c7f8e81ffe182ea"}, ] pdoc3 = [ {file = "pdoc3-0.7.5.tar.gz", hash = "sha256:ebca75b7fcf23f3b4320abe23339834d3f08c28517718e9d29e555fc38eeb33c"}, @@ -1087,6 +1122,25 @@ 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.6.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:418b84654b60e44c0cdd5384294b0e4bc1ebf42d6e873819424f3b78b8690614"}, + {file = "pydantic-1.6.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:4900b8820b687c9a3ed753684337979574df20e6ebe4227381d04b3c3c628f99"}, + {file = "pydantic-1.6.1-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:b49c86aecde15cde33835d5d6360e55f5e0067bb7143a8303bf03b872935c75b"}, + {file = "pydantic-1.6.1-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:2de562a456c4ecdc80cf1a8c3e70c666625f7d02d89a6174ecf63754c734592e"}, + {file = "pydantic-1.6.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f769141ab0abfadf3305d4fcf36660e5cf568a666dd3efab7c3d4782f70946b1"}, + {file = "pydantic-1.6.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2dc946b07cf24bee4737ced0ae77e2ea6bc97489ba5a035b603bd1b40ad81f7e"}, + {file = "pydantic-1.6.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:36dbf6f1be212ab37b5fda07667461a9219c956181aa5570a00edfb0acdfe4a1"}, + {file = "pydantic-1.6.1-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:1783c1d927f9e1366e0e0609ae324039b2479a1a282a98ed6a6836c9ed02002c"}, + {file = "pydantic-1.6.1-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:cf3933c98cb5e808b62fae509f74f209730b180b1e3c3954ee3f7949e083a7df"}, + {file = "pydantic-1.6.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f8af9b840a9074e08c0e6dc93101de84ba95df89b267bf7151d74c553d66833b"}, + {file = "pydantic-1.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:40d765fa2d31d5be8e29c1794657ad46f5ee583a565c83cea56630d3ae5878b9"}, + {file = "pydantic-1.6.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:3fa799f3cfff3e5f536cbd389368fc96a44bb30308f258c94ee76b73bd60531d"}, + {file = "pydantic-1.6.1-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:6c3f162ba175678218629f446a947e3356415b6b09122dcb364e58c442c645a7"}, + {file = "pydantic-1.6.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:eb75dc1809875d5738df14b6566ccf9fd9c0bcde4f36b72870f318f16b9f5c20"}, + {file = "pydantic-1.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:530d7222a2786a97bc59ee0e0ebbe23728f82974b1f1ad9a11cd966143410633"}, + {file = "pydantic-1.6.1-py36.py37.py38-none-any.whl", hash = "sha256:b5b3489cb303d0f41ad4a7390cf606a5f2c7a94dcba20c051cd1c653694cb14d"}, + {file = "pydantic-1.6.1.tar.gz", hash = "sha256:54122a8ed6b75fe1dd80797f8251ad2063ea348a03b77218d73ea9fe19bd4e73"}, +] pyflakes = [ {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, @@ -1128,8 +1182,8 @@ pyyaml = [ {file = "PyYAML-5.3.1.tar.gz", hash = "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d"}, ] radon = [ - {file = "radon-4.2.0-py2.py3-none-any.whl", hash = "sha256:215e42c8748b5ca8ddf7c061831600b9e73e9c48770a81eeaaeeb066697aee15"}, - {file = "radon-4.2.0.tar.gz", hash = "sha256:b73f6f469c15c9616e0f7ce12080a9ecdee9f2335bdbb5ccea1f2bae26e8d20d"}, + {file = "radon-4.3.2-py2.py3-none-any.whl", hash = "sha256:b991de491eb2edbc2aac8f5f7ebf02b799852f076fa5a73fedf79d144d85e37e"}, + {file = "radon-4.3.2.tar.gz", hash = "sha256:758b3ab345aa86e95f642713612a57da7c7da6d552c4dbfbe397a67601ace7dd"}, ] regex = [ {file = "regex-2020.7.14-cp27-cp27m-win32.whl", hash = "sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7"}, @@ -1171,12 +1225,12 @@ smmap = [ {file = "smmap-3.0.4.tar.gz", hash = "sha256:9c98bbd1f9786d22f14b3d4126894d56befb835ec90cef151af566c7e19b5d24"}, ] stevedore = [ - {file = "stevedore-3.2.0-py3-none-any.whl", hash = "sha256:c8f4f0ebbc394e52ddf49de8bcc3cf8ad2b4425ebac494106bbc5e3661ac7633"}, - {file = "stevedore-3.2.0.tar.gz", hash = "sha256:38791aa5bed922b0a844513c5f9ed37774b68edc609e5ab8ab8d8fe0ce4315e5"}, + {file = "stevedore-3.2.2-py3-none-any.whl", hash = "sha256:5e1ab03eaae06ef6ce23859402de785f08d97780ed774948ef16c4652c41bc62"}, + {file = "stevedore-3.2.2.tar.gz", hash = "sha256:f845868b3a3a77a2489d226568abe7328b5c2d4f6a011cc759dfa99144a521f0"}, ] testfixtures = [ - {file = "testfixtures-6.14.1-py2.py3-none-any.whl", hash = "sha256:30566e24a1b34e4d3f8c13abf62557d01eeb4480bcb8f1745467bfb0d415a7d9"}, - {file = "testfixtures-6.14.1.tar.gz", hash = "sha256:58d2b3146d93bc5ddb0cd24e0ccacb13e29bdb61e5c81235c58f7b8ee4470366"}, + {file = "testfixtures-6.14.2-py2.py3-none-any.whl", hash = "sha256:816557888877f498081c1b5c572049b4a2ddffedb77401308ff4cdc1bb9147b7"}, + {file = "testfixtures-6.14.2.tar.gz", hash = "sha256:14d9907390f5f9c7189b3d511b64f34f1072d07cc13b604a57e1bb79029376e3"}, ] toml = [ {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, @@ -1205,6 +1259,11 @@ typed-ast = [ {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, ] +typing-extensions = [ + {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, + {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, + {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, +] urllib3 = [ {file = "urllib3-1.25.10-py2.py3-none-any.whl", hash = "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"}, {file = "urllib3-1.25.10.tar.gz", hash = "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a"}, @@ -1217,10 +1276,10 @@ wrapt = [ {file = "wrapt-1.12.1.tar.gz", hash = "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"}, ] xenon = [ - {file = "xenon-0.7.0-py2.py3-none-any.whl", hash = "sha256:83e98f67b7077c95c25c3402aea6203dd2ed6256708b76ed9751e9dbf1aba125"}, - {file = "xenon-0.7.0.tar.gz", hash = "sha256:5e6433c9297d965bf666256a0a030b6e13660ab87680220c4eb07241f101625b"}, + {file = "xenon-0.7.1-py2.py3-none-any.whl", hash = "sha256:33d807ef805a2ed854adfcc7cc998398d5c0626a5ad443e52684b998a4dd4aa3"}, + {file = "xenon-0.7.1.tar.gz", hash = "sha256:38bf283135f0636355ecf6054b6f37226af12faab152161bda1a4f9e4dc5b701"}, ] zipp = [ - {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, - {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"}, + {file = "zipp-3.2.0-py3-none-any.whl", hash = "sha256:43f4fa8d8bb313e65d8323a3952ef8756bf40f9a5c3ea7334be23ee4ec8278b6"}, + {file = "zipp-3.2.0.tar.gz", hash = "sha256:b52f22895f4cfce194bc8172f3819ee8de7540aa6d873535a8668b730b8b411f"}, ] diff --git a/pyproject.toml b/pyproject.toml index bec06b2af3e..61bc92ab26a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,8 @@ aws-xray-sdk = "^2.5.0" fastjsonschema = "^2.14.5" boto3 = "^1.12" jmespath = "^0.10.0" +pydantic = {version = "^1.6.0", optional = true } +typing_extensions = {version = "^3.7.4.2", optional = true } [tool.poetry.dev-dependencies] coverage = {extras = ["toml"], version = "^5.0.3"} @@ -36,6 +38,7 @@ flake8-debugger = "^3.2.1" flake8-fixme = "^1.1.1" flake8-isort = "^2.8.0" flake8-variables-names = "^0.0.3" +flake8_polyfill = "^1.0.2" isort = "^4.3.21" pytest-cov = "^2.8.1" pytest-mock = "^2.0.0" @@ -47,6 +50,10 @@ xenon = "^0.7.0" flake8-eradicate = "^0.3.0" flake8-bugbear = "^20.1.4" + +[tool.poetry.extras] +pydantic = ["pydantic", "typing_extensions"] + [tool.coverage.run] source = ["aws_lambda_powertools"] omit = ["tests/*"] diff --git a/tests/events/eventBridgeEvent.json b/tests/events/eventBridgeEvent.json index e8d949001c9..4f059b41492 100644 --- a/tests/events/eventBridgeEvent.json +++ b/tests/events/eventBridgeEvent.json @@ -7,10 +7,10 @@ "time": "2017-12-22T18:43:48Z", "region": "us-west-1", "resources": [ - "arn:aws:ec2:us-west-1:123456789012:instance/ i-1234567890abcdef0" + "arn:aws:ec2:us-west-1:123456789012:instance/i-1234567890abcdef0" ], "detail": { - "instance-id": " i-1234567890abcdef0", + "instance_id": "i-1234567890abcdef0", "state": "terminated" } -} +} \ No newline at end of file diff --git a/tests/events/sqsEvent.json b/tests/events/sqsEvent.json index 7201068d60c..ef03b128943 100644 --- a/tests/events/sqsEvent.json +++ b/tests/events/sqsEvent.json @@ -25,7 +25,7 @@ { "messageId": "2e1424d4-f796-459a-8184-9c92662be6da", "receiptHandle": "AQEBzWwaftRI0KuVm4tP+/7q1rGgNqicHq...", - "body": "Test message.", + "body": "Test message2.", "attributes": { "ApproximateReceiveCount": "1", "SentTimestamp": "1545082650636", @@ -39,4 +39,4 @@ "awsRegion": "us-east-2" } ] -} +} \ No newline at end of file diff --git a/tests/functional/parser/__init__.py b/tests/functional/parser/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/functional/parser/schemas.py b/tests/functional/parser/schemas.py new file mode 100644 index 00000000000..3667601e630 --- /dev/null +++ b/tests/functional/parser/schemas.py @@ -0,0 +1,53 @@ +from typing import Dict, List, Optional + +from pydantic import BaseModel +from typing_extensions import Literal + +from aws_lambda_powertools.utilities.advanced_parser.schemas import ( + DynamoDBSchema, + DynamoRecordSchema, + DynamoScheme, + EventBridgeSchema, + SqsRecordSchema, + SqsSchema, +) + + +class MyDynamoBusiness(BaseModel): + Message: Dict[Literal["S"], str] + Id: Dict[Literal["N"], int] + + +class MyDynamoScheme(DynamoScheme): + NewImage: Optional[MyDynamoBusiness] + OldImage: Optional[MyDynamoBusiness] + + +class MyDynamoRecordSchema(DynamoRecordSchema): + dynamodb: MyDynamoScheme + + +class MyAdvancedDynamoBusiness(DynamoDBSchema): + Records: List[MyDynamoRecordSchema] + + +class MyEventbridgeBusiness(BaseModel): + instance_id: str + state: str + + +class MyAdvancedEventbridgeBusiness(EventBridgeSchema): + detail: MyEventbridgeBusiness + + +class MySqsBusiness(BaseModel): + message: str + username: str + + +class MyAdvancedSqsRecordSchema(SqsRecordSchema): + body: str + + +class MyAdvancedSqsBusiness(SqsSchema): + Records: List[MyAdvancedSqsRecordSchema] diff --git a/tests/functional/parser/test_dynamodb.py b/tests/functional/parser/test_dynamodb.py new file mode 100644 index 00000000000..0cca48db752 --- /dev/null +++ b/tests/functional/parser/test_dynamodb.py @@ -0,0 +1,71 @@ +from typing import Dict, List + +import pytest +from pydantic.error_wrappers import ValidationError + +from aws_lambda_powertools.utilities.advanced_parser.envelopes.envelopes import Envelope +from aws_lambda_powertools.utilities.advanced_parser.parser import parser +from aws_lambda_powertools.utilities.typing import LambdaContext +from tests.functional.parser.schemas import MyAdvancedDynamoBusiness, MyDynamoBusiness +from tests.functional.parser.utils import load_event + + +@parser(schema=MyDynamoBusiness, envelope=Envelope.DYNAMODB_STREAM) +def handle_dynamodb(event: List[Dict[str, MyDynamoBusiness]], context: LambdaContext): + assert len(event) == 2 + assert event[0]["OldImage"] is None + assert event[0]["NewImage"].Message["S"] == "New item!" + assert event[0]["NewImage"].Id["N"] == 101 + assert event[1]["OldImage"].Message["S"] == "New item!" + assert event[1]["OldImage"].Id["N"] == 101 + assert event[1]["NewImage"].Message["S"] == "This item has changed" + assert event[1]["NewImage"].Id["N"] == 101 + + +@parser(schema=MyAdvancedDynamoBusiness) +def handle_dynamodb_no_envelope(event: MyAdvancedDynamoBusiness, context: LambdaContext): + records = event.Records + record = records[0] + assert record.awsRegion == "us-west-2" + dynamodb = record.dynamodb + assert dynamodb is not None + assert dynamodb.ApproximateCreationDateTime is None + keys = dynamodb.Keys + assert keys is not None + id_key = keys["Id"] + assert id_key["N"] == "101" + message_key = dynamodb.NewImage.Message + assert message_key is not None + assert message_key["S"] == "New item!" + assert dynamodb.OldImage is None + assert dynamodb.SequenceNumber == "111" + assert dynamodb.SizeBytes == 26 + assert dynamodb.StreamViewType == "NEW_AND_OLD_IMAGES" + assert record.eventID == "1" + assert record.eventName == "INSERT" + assert record.eventSource == "aws:dynamodb" + assert record.eventSourceARN == "eventsource_arn" + assert record.eventVersion == 1.0 + assert record.userIdentity is None + + +def test_dynamo_db_stream_trigger_event(): + event_dict = load_event("dynamoStreamEvent.json") + handle_dynamodb(event_dict, LambdaContext()) + + +def test_dynamo_db_stream_trigger_event_no_envelope(): + event_dict = load_event("dynamoStreamEvent.json") + handle_dynamodb_no_envelope(event_dict, LambdaContext()) + + +def test_validate_event_does_not_conform_with_schema_no_envelope(): + event_dict = {"hello": "s"} + with pytest.raises(ValidationError): + handle_dynamodb_no_envelope(event_dict, LambdaContext()) + + +def test_validate_event_does_not_conform_with_schema(): + event_dict = {"hello": "s"} + with pytest.raises(ValidationError): + handle_dynamodb(event_dict, LambdaContext()) diff --git a/tests/functional/parser/test_eventbridge.py b/tests/functional/parser/test_eventbridge.py new file mode 100644 index 00000000000..97ddcee1c8a --- /dev/null +++ b/tests/functional/parser/test_eventbridge.py @@ -0,0 +1,36 @@ +from aws_lambda_powertools.utilities.advanced_parser.envelopes.envelopes import Envelope +from aws_lambda_powertools.utilities.advanced_parser.parser import parser +from aws_lambda_powertools.utilities.typing import LambdaContext +from tests.functional.parser.schemas import MyAdvancedEventbridgeBusiness, MyEventbridgeBusiness +from tests.functional.parser.utils import load_event + + +@parser(schema=MyEventbridgeBusiness, envelope=Envelope.EVENTBRIDGE) +def handle_eventbridge(event: MyEventbridgeBusiness, context: LambdaContext): + assert event.instance_id == "i-1234567890abcdef0" + assert event.state == "terminated" + + +@parser(schema=MyAdvancedEventbridgeBusiness) +def handle_eventbridge_no_envelope(event: MyAdvancedEventbridgeBusiness, context: LambdaContext): + assert event.detail.instance_id == "i-1234567890abcdef0" + assert event.detail.state == "terminated" + assert event.id == "6a7e8feb-b491-4cf7-a9f1-bf3703467718" + assert event.version == "0" + assert event.account == "111122223333" + time_str = event.time.strftime("%Y-%m-%dT%H:%M:%SZ") + assert time_str == "2017-12-22T18:43:48Z" + assert event.region == "us-west-1" + assert event.resources == ["arn:aws:ec2:us-west-1:123456789012:instance/i-1234567890abcdef0"] + assert event.source == "aws.ec2" + assert event.detailtype == "EC2 Instance State-change Notification" + + +def test_handle_eventbridge_trigger_event(): + event_dict = load_event("eventBridgeEvent.json") + handle_eventbridge(event_dict, LambdaContext()) + + +def test_handle_eventbridge_trigger_event_no_envelope(): + event_dict = load_event("eventBridgeEvent.json") + handle_eventbridge_no_envelope(event_dict, LambdaContext()) diff --git a/tests/functional/parser/test_sqs.py b/tests/functional/parser/test_sqs.py new file mode 100644 index 00000000000..7b7a91890d4 --- /dev/null +++ b/tests/functional/parser/test_sqs.py @@ -0,0 +1,68 @@ +from typing import List + +from aws_lambda_powertools.utilities.advanced_parser.envelopes.envelopes import Envelope +from aws_lambda_powertools.utilities.advanced_parser.parser import parser +from aws_lambda_powertools.utilities.typing import LambdaContext +from tests.functional.parser.schemas import MyAdvancedSqsBusiness, MySqsBusiness +from tests.functional.parser.utils import load_event +from tests.functional.validator.conftest import sqs_event # noqa: F401 + + +@parser(schema=str, envelope=Envelope.SQS) +def handle_sqs_str_body(event: List[str], context: LambdaContext): + assert len(event) == 2 + assert event[0] == "Test message." + assert event[1] == "Test message2." + + +def test_handle_sqs_trigger_event_str_body(): + event_dict = load_event("sqsEvent.json") + handle_sqs_str_body(event_dict, LambdaContext()) + + +@parser(schema=MySqsBusiness, envelope=Envelope.SQS) +def handle_sqs_json_body(event: List[MySqsBusiness], context: LambdaContext): + assert len(event) == 1 + assert event[0].message == "hello world" + assert event[0].username == "lessa" + + +def test_handle_sqs_trigger_evemt_json_body(sqs_event): # noqa: F811 + handle_sqs_json_body(sqs_event, LambdaContext()) + + +@parser(schema=MyAdvancedSqsBusiness) +def handle_sqs_no_envelope(event: MyAdvancedSqsBusiness, context: LambdaContext): + records = event.Records + record = records[0] + attributes = record.attributes + message_attributes = record.messageAttributes + test_attr = message_attributes["testAttr"] + + assert len(records) == 2 + assert record.messageId == "059f36b4-87a3-44ab-83d2-661975830a7d" + assert record.receiptHandle == "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a..." + assert record.body == "Test message." + assert attributes.AWSTraceHeader is None + assert attributes.ApproximateReceiveCount == "1" + convert_time = int(round(attributes.SentTimestamp.timestamp() * 1000)) + assert convert_time == 1545082649183 + assert attributes.SenderId == "AIDAIENQZJOLO23YVJ4VO" + convert_time = int(round(attributes.ApproximateFirstReceiveTimestamp.timestamp() * 1000)) + assert convert_time == 1545082649185 + assert attributes.SequenceNumber is None + assert attributes.MessageGroupId is None + assert attributes.MessageDeduplicationId is None + assert message_attributes.get("NotFound") is None + assert test_attr.stringValue == "100" + assert test_attr.binaryValue == "base64Str" + assert test_attr.dataType == "Number" + assert record.md5OfBody == "e4e68fb7bd0e697a0ae8f1bb342846b3" + assert record.eventSource == "aws:sqs" + assert record.eventSourceARN == "arn:aws:sqs:us-east-2:123456789012:my-queue" + assert record.awsRegion == "us-east-2" + + +def test_handle_sqs_trigger_event_no_envelope(): + event_dict = load_event("sqsEvent.json") + handle_sqs_no_envelope(event_dict, LambdaContext()) diff --git a/tests/functional/parser/utils.py b/tests/functional/parser/utils.py new file mode 100644 index 00000000000..a9e9641735c --- /dev/null +++ b/tests/functional/parser/utils.py @@ -0,0 +1,12 @@ +import json +import os + + +def get_event_file_path(file_name: str) -> dict: + return os.path.dirname(os.path.realpath(__file__)) + "/../../events/" + file_name + + +def load_event(file_name: str) -> dict: + full_file_name = os.path.dirname(os.path.realpath(__file__)) + "/../../events/" + file_name + with open(full_file_name) as fp: + return json.load(fp)