diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 69416cb52fe..5a7c1a3110c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -30,6 +30,14 @@ name: Publish to PyPi # 2. Use the version released under Releases e.g. v1.13.0 # +# +# === Documentation hotfix === +# +# 1. Trigger "Publish to PyPi" workflow manually: https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow +# 2. Use the latest version released under Releases e.g. v1.21.1 +# 3. Set `Build and publish docs only` field to `true` + + on: release: types: [published] @@ -38,6 +46,10 @@ on: publish_version: description: 'Version to publish, e.g. v1.13.0' required: true + publish_docs_only: + description: 'Build and publish docs only' + required: false + default: 'false' jobs: release: @@ -54,29 +66,35 @@ jobs: run: | RELEASE_TAG_VERSION=${{ github.event.release.tag_name }} # Replace publishing version if the workflow was triggered manually - test -n $RELEASE_TAG_VERSION && RELEASE_TAG_VERSION=${{ github.event.inputs.publish_version }} + # test -n ${RELEASE_TAG_VERSION} && RELEASE_TAG_VERSION=${{ github.event.inputs.publish_version }} echo "RELEASE_TAG_VERSION=${RELEASE_TAG_VERSION:1}" >> $GITHUB_ENV - name: Ensure new version is also set in pyproject and CHANGELOG + if: ${{ github.event.inputs.publish_docs_only == false }} run: | grep --regexp "${RELEASE_TAG_VERSION}" CHANGELOG.md grep --regexp "version \= \"${RELEASE_TAG_VERSION}\"" pyproject.toml - name: Install dependencies run: make dev - name: Run all tests, linting and baselines + if: ${{ github.event.inputs.publish_docs_only == false }} run: make pr - name: Build python package and wheel + if: ${{ github.event.inputs.publish_docs_only == false }} run: poetry build - name: Upload to PyPi test + if: ${{ github.event.inputs.publish_docs_only == false }} run: make release-test env: PYPI_USERNAME: __token__ PYPI_TEST_TOKEN: ${{ secrets.PYPI_TEST_TOKEN }} - name: Upload to PyPi prod + if: ${{ github.event.inputs.publish_docs_only == false }} run: make release-prod env: PYPI_USERNAME: __token__ PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} - name: publish lambda layer in SAR by triggering the internal codepipeline + if: ${{ github.event.inputs.publish_docs_only == false }} run: | aws ssm put-parameter --name "powertools-python-release-version" --value $RELEASE_TAG_VERSION --overwrite aws codepipeline start-pipeline-execution --name ${{ secrets.CODEPIPELINE_NAME }} @@ -88,7 +106,7 @@ jobs: - name: Setup doc deploy run: | git config --global user.name Docs deploy - git config --global user.email docs@dummy.bot.com + git config --global user.email aws-devax-open-source@amazon.com - name: Build docs website and API reference run: | make release-docs VERSION=${RELEASE_TAG_VERSION} ALIAS="latest" @@ -111,6 +129,7 @@ jobs: sync_master: needs: release runs-on: ubuntu-latest + if: ${{ github.event.inputs.publish_docs_only == false }} steps: - uses: actions/checkout@v2 - name: Sync master from detached head diff --git a/CHANGELOG.md b/CHANGELOG.md index 41065b6c722..adc3a14aad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,62 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) fo ## [Unreleased] +## 1.22.0 - 2021-11-17 + +Tenet update! We've updated **Idiomatic** tenet to **Progressive** to reflect the new Router feature in Event Handler, and more importantly the new wave of customers coming from SRE, Data Analysis, and Data Science background. + +* BEFORE: **Idiomatic**. Utilities follow programming language idioms and language-specific best practices. +* AFTER: **Progressive**. Utilities are designed to be incrementally adoptable for customers at any stage of their Serverless journey. They follow language idioms and their community’s common practices. +### Bug Fixes + +* **ci:** change supported python version from 3.6.1 to 3.6.2, bump black ([#807](https://github.com/awslabs/aws-lambda-powertools-python/issues/807)) +* **ci:** skip sync master on docs hotfix +* **parser:** body and query strings can be null or omitted in ApiGatewayProxyEventModel and ApiGatewayProxyEventV2Model ([#820](https://github.com/awslabs/aws-lambda-powertools-python/issues/820)) + +### Code Refactoring + +* **apigateway:** Add BaseRouter and duplicate route check ([#757](https://github.com/awslabs/aws-lambda-powertools-python/issues/757)) + +### Documentation + +* **docs:** updated Lambda Layers definition & limitations. ([#775](https://github.com/awslabs/aws-lambda-powertools-python/issues/775)) +* **docs:** Idiomatic tenet updated to Progressive +* **docs:** use higher contrast font to improve accessibility ([#822](https://github.com/awslabs/aws-lambda-powertools-python/issues/822)) +* **docs:** fix indentation of SAM snippets in install section ([#778](https://github.com/awslabs/aws-lambda-powertools-python/issues/778)) +* **docs:** improve public lambda layer wording, add clipboard buttons to improve UX ([#762](https://github.com/awslabs/aws-lambda-powertools-python/issues/762)) +* **docs:** add amplify-cli instructions for public layer ([#754](https://github.com/awslabs/aws-lambda-powertools-python/issues/754)) +* **api-gateway:** add new router feature to allow route splitting in API Gateway and ALB ([#767](https://github.com/awslabs/aws-lambda-powertools-python/issues/767)) +* **apigateway:** re-add sample layout, add considerations ([#826](https://github.com/awslabs/aws-lambda-powertools-python/issues/826)) +* **appsync:** add new router feature to allow GraphQL Resolver composition ([#821](https://github.com/awslabs/aws-lambda-powertools-python/issues/821)) +* **idempotency:** add support for DynamoDB composite keys ([#808](https://github.com/awslabs/aws-lambda-powertools-python/issues/808)) +* **tenets:** update Idiomatic tenet to Progressive ([#823](https://github.com/awslabs/aws-lambda-powertools-python/issues/823)) +* **docs:** remove Lambda Layer version tag +### Features + +* **apigateway:** add Router to allow large routing composition ([#645](https://github.com/awslabs/aws-lambda-powertools-python/issues/645)) +* **appsync:** add Router to allow large resolver composition ([#776](https://github.com/awslabs/aws-lambda-powertools-python/issues/776)) +* **data-classes:** ActiveMQ and RabbitMQ support ([#770](https://github.com/awslabs/aws-lambda-powertools-python/issues/770)) +* **logger:** add ALB correlation ID support ([#816](https://github.com/awslabs/aws-lambda-powertools-python/issues/816)) + +### Maintenance + +* **deps:** bump boto3 from 1.19.6 to 1.20.3 ([#809](https://github.com/awslabs/aws-lambda-powertools-python/issues/809)) +* **deps:** bump boto3 from 1.18.58 to 1.18.59 ([#760](https://github.com/awslabs/aws-lambda-powertools-python/issues/760)) +* **deps:** bump urllib3 from 1.26.4 to 1.26.5 ([#787](https://github.com/awslabs/aws-lambda-powertools-python/issues/787)) +* **deps:** bump boto3 from 1.18.61 to 1.19.6 ([#783](https://github.com/awslabs/aws-lambda-powertools-python/issues/783)) +* **deps:** bump boto3 from 1.18.56 to 1.18.58 ([#755](https://github.com/awslabs/aws-lambda-powertools-python/issues/755)) +* **deps:** bump boto3 from 1.18.59 to 1.18.61 ([#766](https://github.com/awslabs/aws-lambda-powertools-python/issues/766)) +* **deps:** bump boto3 from 1.20.3 to 1.20.5 ([#817](https://github.com/awslabs/aws-lambda-powertools-python/issues/817)) +* **deps-dev:** bump coverage from 6.0.1 to 6.0.2 ([#764](https://github.com/awslabs/aws-lambda-powertools-python/issues/764)) +* **deps-dev:** bump pytest-asyncio from 0.15.1 to 0.16.0 ([#782](https://github.com/awslabs/aws-lambda-powertools-python/issues/782)) +* **deps-dev:** bump flake8-eradicate from 1.1.0 to 1.2.0 ([#784](https://github.com/awslabs/aws-lambda-powertools-python/issues/784)) +* **deps-dev:** bump flake8-comprehensions from 3.6.1 to 3.7.0 ([#759](https://github.com/awslabs/aws-lambda-powertools-python/issues/759)) +* **deps-dev:** bump flake8-isort from 4.0.0 to 4.1.1 ([#785](https://github.com/awslabs/aws-lambda-powertools-python/issues/785)) +* **deps-dev:** bump coverage from 6.0 to 6.0.1 ([#751](https://github.com/awslabs/aws-lambda-powertools-python/issues/751)) +* **deps-dev:** bump mkdocs-material from 7.3.3 to 7.3.5 ([#781](https://github.com/awslabs/aws-lambda-powertools-python/issues/781)) +* **deps-dev:** bump mkdocs-material from 7.3.5 to 7.3.6 ([#791](https://github.com/awslabs/aws-lambda-powertools-python/issues/791)) +* **deps-dev:** bump mkdocs-material from 7.3.2 to 7.3.3 ([#758](https://github.com/awslabs/aws-lambda-powertools-python/issues/758)) + ## 1.21.1 - 2021-10-07 ### Regression diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 754cc24710d..dce520c147d 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -4,7 +4,9 @@ import os import re import traceback +import warnings import zlib +from abc import ABC, abstractmethod from enum import Enum from functools import partial from http import HTTPStatus @@ -227,78 +229,20 @@ def build(self, event: BaseProxyEvent, cors: Optional[CORSConfig] = None) -> Dic } -class ApiGatewayResolver: - """API Gateway and ALB proxy resolver - - Examples - -------- - Simple example with a custom lambda handler using the Tracer capture_lambda_handler decorator - - ```python - from aws_lambda_powertools import Tracer - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver - - tracer = Tracer() - app = ApiGatewayResolver() - - @app.get("/get-call") - def simple_get(): - return {"message": "Foo"} - - @app.post("/post-call") - def simple_post(): - post_data: dict = app.current_event.json_body - return {"message": post_data["value"]} - - @tracer.capture_lambda_handler - def lambda_handler(event, context): - return app.resolve(event, context) - ``` - """ - +class BaseRouter(ABC): current_event: BaseProxyEvent lambda_context: LambdaContext - def __init__( + @abstractmethod + def route( self, - proxy_type: Enum = ProxyEventType.APIGatewayProxyEvent, - cors: Optional[CORSConfig] = None, - debug: Optional[bool] = None, - serializer: Optional[Callable[[Dict], str]] = None, - strip_prefixes: Optional[List[str]] = None, + rule: str, + method: Any, + cors: Optional[bool] = None, + compress: bool = False, + cache_control: Optional[str] = None, ): - """ - Parameters - ---------- - proxy_type: ProxyEventType - Proxy request type, defaults to API Gateway V1 - cors: CORSConfig - Optionally configure and enabled CORS. Not each route will need to have to cors=True - debug: Optional[bool] - Enables debug mode, by default False. Can be also be enabled by "POWERTOOLS_EVENT_HANDLER_DEBUG" - environment variable - serializer : Callable, optional - function to serialize `obj` to a JSON formatted `str`, by default json.dumps - strip_prefixes: List[str], optional - optional list of prefixes to be removed from the request path before doing the routing. This is often used - with api gateways with multiple custom mappings. - """ - self._proxy_type = proxy_type - self._routes: List[Route] = [] - self._cors = cors - self._cors_enabled: bool = cors is not None - self._cors_methods: Set[str] = {"OPTIONS"} - self._debug = resolve_truthy_env_var_choice( - env=os.getenv(constants.EVENT_HANDLER_DEBUG_ENV, "false"), choice=debug - ) - self._strip_prefixes = strip_prefixes - - # Allow for a custom serializer or a concise json serialization - self._serializer = serializer or partial(json.dumps, separators=(",", ":"), cls=Encoder) - - if self._debug: - # Always does a pretty print when in debug mode - self._serializer = partial(json.dumps, indent=4, cls=Encoder) + raise NotImplementedError() def get(self, rule: str, cors: Optional[bool] = None, compress: bool = False, cache_control: Optional[str] = None): """Get route decorator with GET `method` @@ -434,6 +378,78 @@ def lambda_handler(event, context): """ return self.route(rule, "PATCH", cors, compress, cache_control) + +class ApiGatewayResolver(BaseRouter): + """API Gateway and ALB proxy resolver + + Examples + -------- + Simple example with a custom lambda handler using the Tracer capture_lambda_handler decorator + + ```python + from aws_lambda_powertools import Tracer + from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + + tracer = Tracer() + app = ApiGatewayResolver() + + @app.get("/get-call") + def simple_get(): + return {"message": "Foo"} + + @app.post("/post-call") + def simple_post(): + post_data: dict = app.current_event.json_body + return {"message": post_data["value"]} + + @tracer.capture_lambda_handler + def lambda_handler(event, context): + return app.resolve(event, context) + ``` + """ + + def __init__( + self, + proxy_type: Enum = ProxyEventType.APIGatewayProxyEvent, + cors: Optional[CORSConfig] = None, + debug: Optional[bool] = None, + serializer: Optional[Callable[[Dict], str]] = None, + strip_prefixes: Optional[List[str]] = None, + ): + """ + Parameters + ---------- + proxy_type: ProxyEventType + Proxy request type, defaults to API Gateway V1 + cors: CORSConfig + Optionally configure and enabled CORS. Not each route will need to have to cors=True + debug: Optional[bool] + Enables debug mode, by default False. Can be also be enabled by "POWERTOOLS_EVENT_HANDLER_DEBUG" + environment variable + serializer : Callable, optional + function to serialize `obj` to a JSON formatted `str`, by default json.dumps + strip_prefixes: List[str], optional + optional list of prefixes to be removed from the request path before doing the routing. This is often used + with api gateways with multiple custom mappings. + """ + self._proxy_type = proxy_type + self._routes: List[Route] = [] + self._route_keys: List[str] = [] + self._cors = cors + self._cors_enabled: bool = cors is not None + self._cors_methods: Set[str] = {"OPTIONS"} + self._debug = resolve_truthy_env_var_choice( + env=os.getenv(constants.EVENT_HANDLER_DEBUG_ENV, "false"), choice=debug + ) + self._strip_prefixes = strip_prefixes + + # Allow for a custom serializer or a concise json serialization + self._serializer = serializer or partial(json.dumps, separators=(",", ":"), cls=Encoder) + + if self._debug: + # Always does a pretty print when in debug mode + self._serializer = partial(json.dumps, indent=4, cls=Encoder) + def route( self, rule: str, @@ -451,6 +467,10 @@ def register_resolver(func: Callable): else: cors_enabled = cors self._routes.append(Route(method, self._compile_regex(rule), func, cors_enabled, compress, cache_control)) + route_key = method + rule + if route_key in self._route_keys: + warnings.warn(f"A route like this was already registered. method: '{method}' rule: '{rule}'") + self._route_keys.append(route_key) if cors_enabled: logger.debug(f"Registering method {method.upper()} to Allow Methods in CORS") self._cors_methods.add(method.upper()) @@ -474,8 +494,8 @@ def resolve(self, event, context) -> Dict[str, Any]: """ if self._debug: print(self._json_dump(event)) - self.current_event = self._to_proxy_event(event) - self.lambda_context = context + BaseRouter.current_event = self._to_proxy_event(event) + BaseRouter.lambda_context = context return self._resolve().build(self.current_event, self._cors) def __call__(self, event, context) -> Any: @@ -630,3 +650,43 @@ def _to_response(self, result: Union[Dict, Response]) -> Response: def _json_dump(self, obj: Any) -> str: return self._serializer(obj) + + def include_router(self, router: "Router", prefix: Optional[str] = None) -> None: + """Adds all routes defined in a router + + Parameters + ---------- + router : Router + The Router containing a list of routes to be registered after the existing routes + prefix : str, optional + An optional prefix to be added to the originally defined rule + """ + for route, func in router._routes.items(): + if prefix: + rule = route[0] + rule = prefix if rule == "/" else f"{prefix}{rule}" + route = (rule, *route[1:]) + + self.route(*route)(func) + + +class Router(BaseRouter): + """Router helper class to allow splitting ApiGatewayResolver into multiple files""" + + def __init__(self): + self._routes: Dict[tuple, Callable] = {} + + def route( + self, + rule: str, + method: Union[str, List[str]], + cors: Optional[bool] = None, + compress: bool = False, + cache_control: Optional[str] = None, + ): + def register_route(func: Callable): + methods = method if isinstance(method, list) else [method] + for item in methods: + self._routes[(rule, item, cors, compress, cache_control)] = func + + return register_route diff --git a/aws_lambda_powertools/event_handler/appsync.py b/aws_lambda_powertools/event_handler/appsync.py index 69b90c4cbb6..6a4bf989169 100644 --- a/aws_lambda_powertools/event_handler/appsync.py +++ b/aws_lambda_powertools/event_handler/appsync.py @@ -1,4 +1,5 @@ import logging +from abc import ABC from typing import Any, Callable, Optional, Type, TypeVar from aws_lambda_powertools.utilities.data_classes import AppSyncResolverEvent @@ -9,7 +10,33 @@ AppSyncResolverEventT = TypeVar("AppSyncResolverEventT", bound=AppSyncResolverEvent) -class AppSyncResolver: +class BaseRouter(ABC): + current_event: AppSyncResolverEventT # type: ignore[valid-type] + lambda_context: LambdaContext + + def __init__(self): + self._resolvers: dict = {} + + def resolver(self, type_name: str = "*", field_name: Optional[str] = None): + """Registers the resolver for field_name + + Parameters + ---------- + type_name : str + Type name + field_name : str + Field name + """ + + def register_resolver(func): + logger.debug(f"Adding resolver `{func.__name__}` for field `{type_name}.{field_name}`") + self._resolvers[f"{type_name}.{field_name}"] = {"func": func} + return func + + return register_resolver + + +class AppSyncResolver(BaseRouter): """ AppSync resolver decorator @@ -40,29 +67,8 @@ def common_field() -> str: return str(uuid.uuid4()) """ - current_event: AppSyncResolverEventT # type: ignore[valid-type] - lambda_context: LambdaContext - def __init__(self): - self._resolvers: dict = {} - - def resolver(self, type_name: str = "*", field_name: Optional[str] = None): - """Registers the resolver for field_name - - Parameters - ---------- - type_name : str - Type name - field_name : str - Field name - """ - - def register_resolver(func): - logger.debug(f"Adding resolver `{func.__name__}` for field `{type_name}.{field_name}`") - self._resolvers[f"{type_name}.{field_name}"] = {"func": func} - return func - - return register_resolver + super().__init__() def resolve( self, event: dict, context: LambdaContext, data_model: Type[AppSyncResolverEvent] = AppSyncResolverEvent @@ -136,10 +142,10 @@ def lambda_handler(event, context): ValueError If we could not find a field resolver """ - self.current_event = data_model(event) - self.lambda_context = context - resolver = self._get_resolver(self.current_event.type_name, self.current_event.field_name) - return resolver(**self.current_event.arguments) + BaseRouter.current_event = data_model(event) + BaseRouter.lambda_context = context + resolver = self._get_resolver(BaseRouter.current_event.type_name, BaseRouter.current_event.field_name) + return resolver(**BaseRouter.current_event.arguments) def _get_resolver(self, type_name: str, field_name: str) -> Callable: """Get resolver for field_name @@ -167,3 +173,18 @@ def __call__( ) -> Any: """Implicit lambda handler which internally calls `resolve`""" return self.resolve(event, context, data_model) + + def include_router(self, router: "Router") -> None: + """Adds all resolvers defined in a router + + Parameters + ---------- + router : Router + A router containing a dict of field resolvers + """ + self._resolvers.update(router._resolvers) + + +class Router(BaseRouter): + def __init__(self): + super().__init__() diff --git a/aws_lambda_powertools/utilities/data_classes/active_mq_event.py b/aws_lambda_powertools/utilities/data_classes/active_mq_event.py new file mode 100644 index 00000000000..058a6a6ecf4 --- /dev/null +++ b/aws_lambda_powertools/utilities/data_classes/active_mq_event.py @@ -0,0 +1,125 @@ +import base64 +import json +from typing import Any, Iterator, Optional + +from aws_lambda_powertools.utilities.data_classes.common import DictWrapper + + +class ActiveMQMessage(DictWrapper): + @property + def message_id(self) -> str: + """Unique identifier for the message""" + return self["messageID"] + + @property + def message_type(self) -> str: + return self["messageType"] + + @property + def data(self) -> str: + return self["data"] + + @property + def decoded_data(self) -> str: + """Decodes the data as a str""" + return base64.b64decode(self.data.encode()).decode() + + @property + def json_data(self) -> Any: + """Parses the data as json""" + return json.loads(self.decoded_data) + + @property + def connection_id(self) -> str: + return self["connectionId"] + + @property + def redelivered(self) -> bool: + """true if the message is being resent to the consumer""" + return self["redelivered"] + + @property + def timestamp(self) -> int: + """Time in milliseconds.""" + return self["timestamp"] + + @property + def broker_in_time(self) -> int: + """Time stamp (in milliseconds) for when the message arrived at the broker.""" + return self["brokerInTime"] + + @property + def broker_out_time(self) -> int: + """Time stamp (in milliseconds) for when the message left the broker.""" + return self["brokerOutTime"] + + @property + def destination_physicalname(self) -> str: + return self["destination"]["physicalname"] + + @property + def delivery_mode(self) -> Optional[int]: + """persistent or non-persistent delivery""" + return self.get("deliveryMode") + + @property + def correlation_id(self) -> Optional[str]: + """User defined correlation id""" + return self.get("correlationID") + + @property + def reply_to(self) -> Optional[str]: + """User defined reply to""" + return self.get("replyTo") + + @property + def get_type(self) -> Optional[str]: + """User defined message type""" + return self.get("type") + + @property + def expiration(self) -> Optional[int]: + """Expiration attribute whose value is given in milliseconds""" + return self.get("expiration") + + @property + def priority(self) -> Optional[int]: + """ + JMS defines a ten-level priority value, with 0 as the lowest priority and 9 + as the highest. In addition, clients should consider priorities 0-4 as + gradations of normal priority and priorities 5-9 as gradations of expedited + priority. + + JMS does not require that a provider strictly implement priority ordering + of messages; however, it should do its best to deliver expedited messages + ahead of normal messages. + """ + return self.get("priority") + + +class ActiveMQEvent(DictWrapper): + """Represents an Active MQ event sent to Lambda + + Documentation: + -------------- + - https://docs.aws.amazon.com/lambda/latest/dg/with-mq.html + - https://aws.amazon.com/blogs/compute/using-amazon-mq-as-an-event-source-for-aws-lambda/ + """ + + @property + def event_source(self) -> str: + return self["eventSource"] + + @property + def event_source_arn(self) -> str: + """The Amazon Resource Name (ARN) of the event source""" + return self["eventSourceArn"] + + @property + def messages(self) -> Iterator[ActiveMQMessage]: + for record in self["messages"]: + yield ActiveMQMessage(record) + + @property + def message(self) -> ActiveMQMessage: + return next(self.messages) diff --git a/aws_lambda_powertools/utilities/data_classes/rabbit_mq_event.py b/aws_lambda_powertools/utilities/data_classes/rabbit_mq_event.py new file mode 100644 index 00000000000..7676e6ff9b5 --- /dev/null +++ b/aws_lambda_powertools/utilities/data_classes/rabbit_mq_event.py @@ -0,0 +1,121 @@ +import base64 +import json +from typing import Any, Dict, List + +from aws_lambda_powertools.utilities.data_classes.common import DictWrapper + + +class BasicProperties(DictWrapper): + @property + def content_type(self) -> str: + return self["contentType"] + + @property + def content_encoding(self) -> str: + return self["contentEncoding"] + + @property + def headers(self) -> Dict[str, Any]: + return self["headers"] + + @property + def delivery_mode(self) -> int: + return self["deliveryMode"] + + @property + def priority(self) -> int: + return self["priority"] + + @property + def correlation_id(self) -> str: + return self["correlationId"] + + @property + def reply_to(self) -> str: + return self["replyTo"] + + @property + def expiration(self) -> str: + return self["expiration"] + + @property + def message_id(self) -> str: + return self["messageId"] + + @property + def timestamp(self) -> str: + return self["timestamp"] + + @property + def get_type(self) -> str: + return self["type"] + + @property + def user_id(self) -> str: + return self["userId"] + + @property + def app_id(self) -> str: + return self["appId"] + + @property + def cluster_id(self) -> str: + return self["clusterId"] + + @property + def body_size(self) -> int: + return self["bodySize"] + + +class RabbitMessage(DictWrapper): + @property + def basic_properties(self) -> BasicProperties: + return BasicProperties(self["basicProperties"]) + + @property + def redelivered(self) -> bool: + return self["redelivered"] + + @property + def data(self) -> str: + return self["data"] + + @property + def decoded_data(self) -> str: + """Decodes the data as a str""" + return base64.b64decode(self.data.encode()).decode() + + @property + def json_data(self) -> Any: + """Parses the data as json""" + return json.loads(self.decoded_data) + + +class RabbitMQEvent(DictWrapper): + """Represents a Rabbit MQ event sent to Lambda + + Documentation: + -------------- + - https://docs.aws.amazon.com/lambda/latest/dg/with-mq.html + - https://aws.amazon.com/blogs/compute/using-amazon-mq-for-rabbitmq-as-an-event-source-for-lambda/ + """ + + def __init__(self, data: Dict[str, Any]): + super().__init__(data) + self._rmq_messages_by_queue = { + key: [RabbitMessage(message) for message in messages] + for key, messages in self["rmqMessagesByQueue"].items() + } + + @property + def event_source(self) -> str: + return self["eventSource"] + + @property + def event_source_arn(self) -> str: + """The Amazon Resource Name (ARN) of the event source""" + return self["eventSourceArn"] + + @property + def rmq_messages_by_queue(self) -> Dict[str, List[RabbitMessage]]: + return self._rmq_messages_by_queue diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py index 0ce307ab503..8a470c0f910 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py @@ -1,10 +1,12 @@ import datetime import logging +import os from typing import Any, Dict, Optional import boto3 from botocore.config import Config +from aws_lambda_powertools.shared import constants from aws_lambda_powertools.utilities.idempotency import BasePersistenceLayer from aws_lambda_powertools.utilities.idempotency.exceptions import ( IdempotencyItemAlreadyExistsError, @@ -20,6 +22,8 @@ def __init__( self, table_name: str, key_attr: str = "id", + static_pk_value: str = f"idempotency#{os.getenv(constants.LAMBDA_FUNCTION_NAME_ENV, '')}", + sort_key_attr: Optional[str] = None, expiry_attr: str = "expiration", status_attr: str = "status", data_attr: str = "data", @@ -35,7 +39,12 @@ def __init__( 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" + DynamoDB attribute name for partition key, by default "id" + static_pk_value: str, optional + DynamoDB attribute value for partition key, by default "idempotency#". + This will be used if the sort_key_attr is set. + sort_key_attr: str, optional + DynamoDB attribute name for the sort key expiry_attr: str, optional DynamoDB attribute name for expiry timestamp, by default "expiration" status_attr: str, optional @@ -64,10 +73,14 @@ def __init__( self._boto_config = boto_config or Config() self._boto3_session = boto3_session or boto3.session.Session() + if sort_key_attr == key_attr: + raise ValueError(f"key_attr [{key_attr}] and sort_key_attr [{sort_key_attr}] cannot be the same!") self._table = None self.table_name = table_name self.key_attr = key_attr + self.static_pk_value = static_pk_value + self.sort_key_attr = sort_key_attr self.expiry_attr = expiry_attr self.status_attr = status_attr self.data_attr = data_attr @@ -93,6 +106,11 @@ def table(self, table): """ self._table = table + def _get_key(self, idempotency_key: str) -> dict: + if self.sort_key_attr: + return {self.key_attr: self.static_pk_value, self.sort_key_attr: idempotency_key} + return {self.key_attr: idempotency_key} + def _item_to_data_record(self, item: Dict[str, Any]) -> DataRecord: """ Translate raw item records from DynamoDB to DataRecord @@ -117,7 +135,7 @@ def _item_to_data_record(self, item: Dict[str, Any]) -> DataRecord: ) def _get_record(self, idempotency_key) -> DataRecord: - response = self.table.get_item(Key={self.key_attr: idempotency_key}, ConsistentRead=True) + response = self.table.get_item(Key=self._get_key(idempotency_key), ConsistentRead=True) try: item = response["Item"] @@ -127,7 +145,7 @@ def _get_record(self, idempotency_key) -> DataRecord: def _put_record(self, data_record: DataRecord) -> None: item = { - self.key_attr: data_record.idempotency_key, + **self._get_key(data_record.idempotency_key), self.expiry_attr: data_record.expiry_timestamp, self.status_attr: data_record.status, } @@ -168,7 +186,7 @@ def _update_record(self, data_record: DataRecord): expression_attr_names["#validation_key"] = self.validation_key_attr kwargs = { - "Key": {self.key_attr: data_record.idempotency_key}, + "Key": self._get_key(data_record.idempotency_key), "UpdateExpression": update_expression, "ExpressionAttributeValues": expression_attr_values, "ExpressionAttributeNames": expression_attr_names, @@ -178,4 +196,4 @@ def _update_record(self, data_record: DataRecord): 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}) + self.table.delete_item(Key=self._get_key(data_record.idempotency_key)) diff --git a/aws_lambda_powertools/utilities/parser/models/apigw.py b/aws_lambda_powertools/utilities/parser/models/apigw.py index 44ddda6e4f1..283a73da9c3 100644 --- a/aws_lambda_powertools/utilities/parser/models/apigw.py +++ b/aws_lambda_powertools/utilities/parser/models/apigw.py @@ -89,4 +89,4 @@ class APIGatewayProxyEventModel(BaseModel): pathParameters: Optional[Dict[str, str]] stageVariables: Optional[Dict[str, str]] isBase64Encoded: bool - body: str + body: Optional[str] diff --git a/aws_lambda_powertools/utilities/parser/models/apigwv2.py b/aws_lambda_powertools/utilities/parser/models/apigwv2.py index 4243315bb21..36dd85b907e 100644 --- a/aws_lambda_powertools/utilities/parser/models/apigwv2.py +++ b/aws_lambda_powertools/utilities/parser/models/apigwv2.py @@ -63,9 +63,9 @@ class APIGatewayProxyEventV2Model(BaseModel): rawQueryString: str cookies: Optional[List[str]] headers: Dict[str, str] - queryStringParameters: Dict[str, str] + queryStringParameters: Optional[Dict[str, str]] pathParameters: Optional[Dict[str, str]] stageVariables: Optional[Dict[str, str]] requestContext: RequestContextV2 - body: str + body: Optional[str] isBase64Encoded: bool diff --git a/aws_lambda_powertools/utilities/validation/exceptions.py b/aws_lambda_powertools/utilities/validation/exceptions.py index 7c719ca3119..2f13ff64188 100644 --- a/aws_lambda_powertools/utilities/validation/exceptions.py +++ b/aws_lambda_powertools/utilities/validation/exceptions.py @@ -8,7 +8,7 @@ class SchemaValidationError(Exception): def __init__( self, - message: str, + message: Optional[str] = None, validation_message: Optional[str] = None, name: Optional[str] = None, path: Optional[List] = None, @@ -21,7 +21,7 @@ def __init__( Parameters ---------- - message : str + message : str, optional Powertools formatted error message validation_message : str, optional Containing human-readable information what is wrong diff --git a/docs/core/event_handler/api_gateway.md b/docs/core/event_handler/api_gateway.md index aeaa75e0d2a..f9482edaacf 100644 --- a/docs/core/event_handler/api_gateway.md +++ b/docs/core/event_handler/api_gateway.md @@ -12,6 +12,7 @@ Event handler for Amazon API Gateway REST/HTTP APIs and Application Loader Balan * Integrates with [Data classes utilities](../../utilities/data_classes.md){target="_blank"} to easily access event and identity information * Built-in support for Decimals JSON encoding * Support for dynamic path expressions +* Router to allow for splitting up the handler accross multiple files ## Getting started @@ -75,12 +76,11 @@ This is the sample infrastructure for API Gateway we are using for the examples Outputs: HelloWorldApigwURL: - Description: "API Gateway endpoint URL for Prod environment for Hello World Function" - Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello" - - HelloWorldFunction: - Description: "Hello World Lambda Function ARN" - Value: !GetAtt HelloWorldFunction.Arn + Description: "API Gateway endpoint URL for Prod environment for Hello World Function" + Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello" + HelloWorldFunction: + Description: "Hello World Lambda Function ARN" + Value: !GetAtt HelloWorldFunction.Arn ``` ### API Gateway decorator @@ -853,6 +853,330 @@ You can instruct API Gateway handler to use a custom serializer to best suit you } ``` +### Split routes with Router + +As you grow the number of routes a given Lambda function should handle, it is natural to split routes into separate files to ease maintenance - That's where the `Router` feature is useful. + +Let's assume you have `app.py` as your Lambda function entrypoint and routes in `users.py`, this is how you'd use the `Router` feature. + +=== "users.py" + + We import **Router** instead of **ApiGatewayResolver**; syntax wise is exactly the same. + + ```python hl_lines="4 8 12 15 21" + import itertools + from typing import Dict + + from aws_lambda_powertools import Logger + from aws_lambda_powertools.event_handler.api_gateway import Router + + logger = Logger(child=True) + router = Router() + USERS = {"user1": "details_here", "user2": "details_here", "user3": "details_here"} + + + @router.get("/users") + def get_users() -> Dict: + # /users?limit=1 + pagination_limit = router.current_event.get_query_string_value(name="limit", default_value=10) + + logger.info(f"Fetching the first {pagination_limit} users...") + ret = dict(itertools.islice(USERS.items(), int(pagination_limit))) + return {"items": [ret]} + + @router.get("/users/") + def get_user(username: str) -> Dict: + logger.info(f"Fetching username {username}") + return {"details": USERS.get(username, {})} + + # many other related /users routing + ``` + +=== "app.py" + + We use `include_router` method and include all user routers registered in the `router` global object. + + ```python hl_lines="7 10-11" + from typing import Dict + + from aws_lambda_powertools import Logger + from aws_lambda_powertools.event_handler import ApiGatewayResolver + from aws_lambda_powertools.utilities.typing import LambdaContext + + import users + + logger = Logger() + app = ApiGatewayResolver() + app.include_router(users.router) + + + def lambda_handler(event: Dict, context: LambdaContext): + return app.resolve(event, context) + ``` + +#### Route prefix + +In the previous example, `users.py` routes had a `/users` prefix. This might grow over time and become repetitive. + +When necessary, you can set a prefix when including a router object. This means you could remove `/users` prefix in `users.py` altogether. + +=== "app.py" + + ```python hl_lines="9" + from typing import Dict + + from aws_lambda_powertools.event_handler import ApiGatewayResolver + from aws_lambda_powertools.utilities.typing import LambdaContext + + import users + + app = ApiGatewayResolver() + app.include_router(users.router, prefix="/users") # prefix '/users' to any route in `users.router` + + + def lambda_handler(event: Dict, context: LambdaContext): + return app.resolve(event, context) + ``` + +=== "users.py" + + ```python hl_lines="11 15" + from typing import Dict + + from aws_lambda_powertools import Logger + from aws_lambda_powertools.event_handler.api_gateway import Router + + logger = Logger(child=True) + router = Router() + USERS = {"user1": "details", "user2": "details", "user3": "details"} + + + @router.get("/") # /users, when we set the prefix in app.py + def get_users() -> Dict: + ... + + @router.get("/") + def get_user(username: str) -> Dict: + ... + + # many other related /users routing + ``` + +#### Sample layout + +!!! info "We use ALB to demonstrate that the UX remains the same" + +This sample project contains an Users function with two distinct set of routes, `/users` and `/health`. The layout optimizes for code sharing, no custom build tooling, and it uses [Lambda Layers](../../index.md#lambda-layer) to install Lambda Powertools. + +=== "Project layout" + + + ```python hl_lines="6 8 10-13" + . + ├── Pipfile # project app & dev dependencies; poetry, pipenv, etc. + ├── Pipfile.lock + ├── mypy.ini # namespace_packages = True + ├── .env # VSCode only. PYTHONPATH="users:${PYTHONPATH}" + ├── users + │ ├── requirements.txt # sam build detect it automatically due to CodeUri: users, e.g. pipenv lock -r > users/requirements.txt + │ ├── lambda_function.py # this will be our users Lambda fn; it could be split in folders if we want separate fns same code base + │ ├── constants.py + │ └── routers # routers module + │ ├── __init__.py + │ ├── users.py # /users routes, e.g. from routers import users; users.router + │ ├── health.py # /health routes, e.g. from routers import health; health.router + ├── template.yaml # SAM template.yml, CodeUri: users, Handler: users.main.lambda_handler + └── tests + ├── __init__.py + ├── unit + │ ├── __init__.py + │ └── test_users.py # unit tests for the users router + │ └── test_health.py # unit tests for the health router + └── functional + ├── __init__.py + ├── conftest.py # pytest fixtures for the functional tests + └── test_lambda_function.py # functional tests for the main lambda handler + ``` + +=== "template.yml" + + ```yaml hl_lines="20-21" + AWSTemplateFormatVersion: '2010-09-09' + Transform: AWS::Serverless-2016-10-31 + Description: Example service with multiple routes + Globals: + Function: + Timeout: 10 + MemorySize: 512 + Runtime: python3.9 + Tracing: Active + Environment: + Variables: + LOG_LEVEL: INFO + POWERTOOLS_LOGGER_LOG_EVENT: true + POWERTOOLS_METRICS_NAMESPACE: MyServerlessApplication + POWERTOOLS_SERVICE_NAME: users + Resources: + UsersService: + Type: AWS::Serverless::Function + Properties: + Handler: lambda_function.lambda_handler + CodeUri: users + Layers: + # Latest version: https://awslabs.github.io/aws-lambda-powertools-python/latest/#lambda-layer + - !Sub arn:aws:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPython:3 + Events: + ByUser: + Type: Api + Properties: + Path: /users/{name} + Method: GET + AllUsers: + Type: Api + Properties: + Path: /users + Method: GET + HealthCheck: + Type: Api + Properties: + Path: /status + Method: GET + Outputs: + UsersApiEndpoint: + Description: "API Gateway endpoint URL for Prod environment for Users Function" + Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod" + AllUsersURL: + Description: "URL to fetch all registered users" + Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/users" + ByUserURL: + Description: "URL to retrieve details by user" + Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/users/test" + UsersServiceFunctionArn: + Description: "Users Lambda Function ARN" + Value: !GetAtt UsersService.Arn + ``` + +=== "users/lambda_function.py" + + ```python hl_lines="9 15-16" + from typing import Dict + + from aws_lambda_powertools import Logger, Tracer + from aws_lambda_powertools.event_handler import ApiGatewayResolver + from aws_lambda_powertools.event_handler.api_gateway import ProxyEventType + from aws_lambda_powertools.logging.correlation_paths import APPLICATION_LOAD_BALANCER + from aws_lambda_powertools.utilities.typing import LambdaContext + + from routers import health, users + + tracer = Tracer() + logger = Logger() + app = ApiGatewayResolver(proxy_type=ProxyEventType.ALBEvent) + + app.include_router(health.router) + app.include_router(users.router) + + + @logger.inject_lambda_context(correlation_id_path=APPLICATION_LOAD_BALANCER) + @tracer.capture_lambda_handler + def lambda_handler(event: Dict, context: LambdaContext): + return app.resolve(event, context) + ``` + +=== "users/routers/health.py" + + ```python hl_lines="4 6-7 10" + from typing import Dict + + from aws_lambda_powertools import Logger + from aws_lambda_powertools.event_handler.api_gateway import Router + + router = Router() + logger = Logger(child=True) + + + @router.get("/status") + def health() -> Dict: + logger.debug("Health check called") + return {"status": "OK"} + ``` + +=== "tests/functional/test_users.py" + + ```python hl_lines="3" + import json + + from users import main # follows namespace package from root + + + def test_lambda_handler(apigw_event, lambda_context): + ret = main.lambda_handler(apigw_event, lambda_context) + expected = json.dumps({"message": "hello universe"}, separators=(",", ":")) + + assert ret["statusCode"] == 200 + assert ret["body"] == expected + ``` + +=== ".env" + + > Note: It is not needed for PyCharm (select folder as source). + + This is necessary for Visual Studio Code, so integrated tooling works without failing import. + + ```bash + PYTHONPATH="users:${PYTHONPATH}" + ``` + +### Considerations + +This utility is optimized for fast startup, minimal feature set, and to quickly on-board customers familiar with frameworks like Flask — it's not meant to be a fully fledged framework. + +Event Handler naturally leads to a single Lambda function handling multiple routes for a given service, which can be eventually broken into multiple functions. + +Both single (monolithic) and multiple functions (micro) offer different set of trade-offs worth knowing. + +!!! tip "TL;DR. Start with a monolithic function, add additional functions with new handlers, and possibly break into micro functions if necessary." + +#### Monolithic function + +![Monolithic function sample](./../../media/monolithic-function.png) + +A monolithic function means that your final code artifact will be deployed to a single function. This is generally the best approach to start. + +**Benefits** + +* **Code reuse**. It's easier to reason about your service, modularize it and reuse code as it grows. Eventually, it can be turned into a standalone library. +* **No custom tooling**. Monolithic functions are treated just like normal Python packages; no upfront investment in tooling. +* **Faster deployment and debugging**. Whether you use all-at-once, linear, or canary deployments, a monolithic function is a single deployable unit. IDEs like PyCharm and VSCode have tooling to quickly profile, visualize, and step through debug any Python package. + +**Downsides** + +* **Cold starts**. Frequent deployments and/or high load can diminish the benefit of monolithic functions depending on your latency requirements, due to [Lambda scaling model](https://docs.aws.amazon.com/lambda/latest/dg/invocation-scaling.html){target="_blank"}. Always load test to pragmatically balance between your customer experience and development cognitive load. +* **Granular security permissions**. The micro function approach enables you to use fine-grained permissions & access controls, separate external dependencies & code signing at the function level. Conversely, you could have multiple functions while duplicating the final code artifact in a monolithic approach. + - Regardless, least privilege can be applied to either approaches. +* **Higher risk per deployment**. A misconfiguration or invalid import can cause disruption if not caught earlier in automated testing. Multiple functions can mitigate misconfigurations but they would still share the same code artifact. You can further minimize risks with multiple environments in your CI/CD pipeline. + +#### Micro function + +![Micro function sample](./../../media/micro-function.png) + +A micro function means that your final code artifact will be different to each function deployed. This is generally the approach to start if you're looking for fine-grain control and/or high load on certain parts of your service. + +**Benefits** + +* **Granular scaling**. A micro function can benefit from the [Lambda scaling model](https://docs.aws.amazon.com/lambda/latest/dg/invocation-scaling.html){target="_blank"} to scale differently depending on each part of your application. Concurrency controls and provisioned concurrency can also be used at a granular level for capacity management. +* **Discoverability**. Micro functions are easier do visualize when using distributed tracing. Their high-level architectures can be self-explanatory, and complexity is highly visible — assuming each function is named to the business purpose it serves. +* **Package size**. An independent function can be significant smaller (KB vs MB) depending on external dependencies it require to perform its purpose. Conversely, a monolithic approach can benefit from [Lambda Layers](https://docs.aws.amazon.com/lambda/latest/dg/invocation-layers.html){target="_blank"} to optimize builds for external dependencies. + +**Downsides** + +* **Upfront investment**. Python ecosystem doesn't use a bundler — you need a custom build tooling to ensure each function only has what it needs and account for [C bindings for runtime compatibility](https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html){target="_blank"}. Operations become more elaborate — you need to standardize tracing labels/annotations, structured logging, and metrics to pinpoint root causes. + - Engineering discipline is necessary for both approaches. Micro-function approach however requires further attention in consistency as the number of functions grow, just like any distributed system. +* **Harder to share code**. Shared code must be carefully evaluated to avoid unnecessary deployments when that changes. Equally, if shared code isn't a library, +your development, building, deployment tooling need to accommodate the distinct layout. +* **Slower safe deployments**. Safely deploying multiple functions require coordination — AWS CodeDeploy deploys and verifies each function sequentially. This increases lead time substantially (minutes to hours) depending on the deployment strategy you choose. You can mitigate it by selectively enabling it in prod-like environments only, and where the risk profile is applicable. + - Automated testing, operational and security reviews are essential to stability in either approaches. + ## Testing your code You can test your routes by passing a proxy event request where `path` and `httpMethod`. diff --git a/docs/core/event_handler/appsync.md b/docs/core/event_handler/appsync.md index 93bb7bf69a5..ce9150113b6 100644 --- a/docs/core/event_handler/appsync.md +++ b/docs/core/event_handler/appsync.md @@ -711,6 +711,66 @@ You can subclass `AppSyncResolverEvent` to bring your own set of methods to hand } ``` +### Split operations with Router + +!!! tip "Read the **[considerations section for trade-offs between monolithic and micro functions](./api_gateway.md#considerations){target="_blank"}**, as it's also applicable here." + +As you grow the number of related GraphQL operations a given Lambda function should handle, it is natural to split them into separate files to ease maintenance - That's where the `Router` feature is useful. + +Let's assume you have `app.py` as your Lambda function entrypoint and routes in `users.py`, this is how you'd use the `Router` feature. + +=== "resolvers/location.py" + + We import **Router** instead of **AppSyncResolver**; syntax wise is exactly the same. + + ```python hl_lines="4 7 10 15" + from typing import Any, Dict, List + + from aws_lambda_powertools import Logger + from aws_lambda_powertools.event_handler.appsync import Router + + logger = Logger(child=True) + router = Router() + + + @router.resolver(type_name="Query", field_name="listLocations") + def list_locations(merchant_id: str) -> List[Dict[str, Any]]: + return [{"name": "Location name", "merchant_id": merchant_id}] + + + @router.resolver(type_name="Location", field_name="status") + def resolve_status(merchant_id: str) -> str: + logger.debug(f"Resolve status for merchant_id: {merchant_id}") + return "FOO" + ``` + +=== "app.py" + + We use `include_router` method and include all location operations registered in the `router` global object. + + ```python hl_lines="8 13" + from typing import Dict + + from aws_lambda_powertools import Logger, Tracer + from aws_lambda_powertools.event_handler import AppSyncResolver + from aws_lambda_powertools.logging.correlation_paths import APPSYNC_RESOLVER + from aws_lambda_powertools.utilities.typing import LambdaContext + + from resolvers import location + + tracer = Tracer() + logger = Logger() + app = AppSyncResolver() + app.include_router(location.router) + + + @tracer.capture_lambda_handler + @logger.inject_lambda_context(correlation_id_path=APPSYNC_RESOLVER) + def lambda_handler(event: Dict, context: LambdaContext): + app.resolve(event, context) + ``` + + ## Testing your code You can test your resolvers by passing a mocked or actual AppSync Lambda event that you're expecting. diff --git a/docs/index.md b/docs/index.md index 7f58de4fe8a..86b91635163 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,31 +17,42 @@ This project separates core utilities that will be available in other runtimes v * **Keep it lean**. Additional dependencies are carefully considered for security and ease of maintenance, and prevent negatively impacting startup time. * **We strive for backwards compatibility**. New features and changes should keep backwards compatibility. If a breaking change cannot be avoided, the deprecation and migration process should be clearly defined. * **We work backwards from the community**. We aim to strike a balance of what would work best for 80% of customers. Emerging practices are considered and discussed via Requests for Comment (RFCs) -* **Idiomatic**. Utilities follow programming language idioms and language-specific best practices. +* **Progressive**. Utilities are designed to be incrementally adoptable for customers at any stage of their Serverless journey. They follow language idioms and their community’s common practices. ## Install -Powertools is available in PyPi. You can use your favourite dependency management tool to install it +Powertools is available in the following formats: -* [poetry](https://python-poetry.org/): `poetry add aws-lambda-powertools` -* [pip](https://pip.pypa.io/en/latest/index.html): `pip install aws-lambda-powertools` - -**Quick hello world example using SAM CLI** - -=== "shell" - - ```bash - sam init --location https://github.com/aws-samples/cookiecutter-aws-sam-python - ``` +* **Lambda Layer**: [**arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPython:3**](#){: .copyMe} :clipboard: +* **PyPi**: **`pip install aws-lambda-powertools`** ### Lambda Layer -Powertools is also available as a Lambda Layer with public ARNs in each region or distributed via the [AWS Serverless Application Repository (SAR)](https://docs.aws.amazon.com/serverlessrepo/latest/devguide/what-is-serverlessrepo.html) to support semantic versioning. - -#### Public ARNs - -We build, release and distribute packaged Lambda Powertools layers for each region. This means you can copy a specific ARN and use it in your Lambda deployment. The layer region must be equal to the region of your lambda function. The public layers do not contain the `pydantic` library that is required for the `parser` utility. - +[Lambda Layer](https://docs.aws.amazon.com/lambda/latest/dg/configuration-layers.html){target="_blank"} is a .zip file archive that can contain additional code, pre-packaged dependencies, data, or configuration files. Layers promote code sharing and separation of responsibilities so that you can iterate faster on writing business logic. + +You can include Lambda Powertools Lambda Layer using [AWS Lambda Console](https://docs.aws.amazon.com/lambda/latest/dg/invocation-layers.html#invocation-layers-using){target="_blank"}, or your preferred deployment framework. + +??? note "Expand to copy any regional Lambda Layer ARN" + + | Region | Layer ARN + |--------------------------- | --------------------------- + | `us-east-1` | [arn:aws:lambda:us-east-1:017000801446:layer:AWSLambdaPowertoolsPython:3](#){: .copyMe} :clipboard: + | `us-east-2` | [arn:aws:lambda:us-east-2:017000801446:layer:AWSLambdaPowertoolsPython:3](#){: .copyMe} :clipboard: + | `us-west-1` | [arn:aws:lambda:us-west-1:017000801446:layer:AWSLambdaPowertoolsPython:3](#){: .copyMe} :clipboard: + | `us-west-2` | [arn:aws:lambda:us-west-2:017000801446:layer:AWSLambdaPowertoolsPython:3](#){: .copyMe} :clipboard: + | `ap-south-1` | [arn:aws:lambda:ap-south-1:017000801446:layer:AWSLambdaPowertoolsPython:3](#){: .copyMe} :clipboard: + | `ap-northeast-1` | [arn:aws:lambda:ap-northeast-1:017000801446:layer:AWSLambdaPowertoolsPython:3](#){: .copyMe} :clipboard: + | `ap-northeast-2` | [arn:aws:lambda:ap-northeast-2:017000801446:layer:AWSLambdaPowertoolsPython:3](#){: .copyMe} :clipboard: + | `ap-northeast-3` | [arn:aws:lambda:ap-northeast-3:017000801446:layer:AWSLambdaPowertoolsPython:3](#){: .copyMe} :clipboard: + | `ap-southeast-1` | [arn:aws:lambda:ap-southeast-1:017000801446:layer:AWSLambdaPowertoolsPython:3](#){: .copyMe} :clipboard: + | `ap-southeast-2` | [arn:aws:lambda:ap-southeast-2:017000801446:layer:AWSLambdaPowertoolsPython:3](#){: .copyMe} :clipboard: + | `eu-central-1` | [arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPython:3](#){: .copyMe} :clipboard: + | `eu-west-1` | [arn:aws:lambda:eu-west-1:017000801446:layer:AWSLambdaPowertoolsPython:3](#){: .copyMe} :clipboard: + | `eu-west-2` | [arn:aws:lambda:eu-west-2:017000801446:layer:AWSLambdaPowertoolsPython:3](#){: .copyMe} :clipboard: + | `eu-west-3` | [arn:aws:lambda:eu-west-3:017000801446:layer:AWSLambdaPowertoolsPython:3](#){: .copyMe} :clipboard: + | `eu-north-1` | [arn:aws:lambda:eu-north-1:017000801446:layer:AWSLambdaPowertoolsPython:3](#){: .copyMe} :clipboard: + | `ca-central-1` | [arn:aws:lambda:ca-central-1:017000801446:layer:AWSLambdaPowertoolsPython:3](#){: .copyMe} :clipboard: + | `sa-east-1` | [arn:aws:lambda:sa-east-1:017000801446:layer:AWSLambdaPowertoolsPython:3](#){: .copyMe} :clipboard: === "SAM" @@ -49,18 +60,18 @@ We build, release and distribute packaged Lambda Powertools layers for each regi MyLambdaFunction: Type: AWS::Serverless::Function Properties: - Layers: - - arn:aws:lambda:us-east-1:017000801446:layer:AWSLambdaPowertoolsPython:3 + Layers: + - !Sub arn:aws:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPython:3 ``` === "Serverless framework" ```yaml hl_lines="5" - functions: - main: - handler: lambda_function.lambda_handler - layers: - - arn:aws:lambda:us-east-1:017000801446:layer:AWSLambdaPowertoolsPython:3 + functions: + hello: + handler: lambda_function.lambda_handler + layers: + - arn:aws:lambda:${aws:region}:017000801446:layer:AWSLambdaPowertoolsPython:3 ``` === "CDK" @@ -70,16 +81,16 @@ We build, release and distribute packaged Lambda Powertools layers for each regi class SampleApp(core.Construct): - def __init__(self, scope: core.Construct, id_: str) -> None: + def __init__(self, scope: core.Construct, id_: str, env: core.Environment) -> None: super().__init__(scope, id_) aws_lambda.Function(self, 'sample-app-lambda', - runtime=aws_lambda.Runtime.PYTHON_3_8, + runtime=aws_lambda.Runtime.PYTHON_3_9, function_name='sample-lambda', code=aws_lambda.Code.asset('./src'), handler='app.handler', - layers: ["arn:aws:lambda:us-east-1:017000801446:layer:AWSLambdaPowertoolsPython:3"] + layers: [f"arn:aws:lambda:{env.region}:017000801446:layer:AWSLambdaPowertoolsPython:3"] ) ``` @@ -94,7 +105,7 @@ We build, release and distribute packaged Lambda Powertools layers for each regi } provider "aws" { - region = "us-east-1" + region = "{region}" } resource "aws_iam_role" "iam_for_lambda" { @@ -109,21 +120,20 @@ We build, release and distribute packaged Lambda Powertools layers for each regi "Principal": { "Service": "lambda.amazonaws.com" }, - "Effect": "Allow", - "Sid": "" + "Effect": "Allow" } ] } EOF - } + } resource "aws_lambda_function" "test_lambda" { filename = "lambda_function_payload.zip" function_name = "lambda_function_name" role = aws_iam_role.iam_for_lambda.arn handler = "index.test" - runtime = "python3.8" - layers = ["arn:aws:lambda:us-east-1:017000801446:layer:AWSLambdaPowertoolsPython:3"] + runtime = "python3.9" + layers = ["arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPython:3"] source_code_hash = filebase64sha256("lambda_function_payload.zip") } @@ -131,40 +141,60 @@ We build, release and distribute packaged Lambda Powertools layers for each regi ``` -??? note "Layer ARN per region" - - !!! tip "Click to copy to clipboard" - - | Region | Version | Layer ARN - |---------------------------| ---------------------------| --------------------------- - | `us-east-1` | `1.21.0` |[arn:aws:lambda:us-east-1:017000801446:layer:AWSLambdaPowertoolsPython:3](#) {: .copyMe} - | `us-east-2` | `1.21.0` |[arn:aws:lambda:us-east-2:017000801446:layer:AWSLambdaPowertoolsPython:3](#) {: .copyMe} - | `us-west-1` | `1.21.0` |[arn:aws:lambda:us-west-1:017000801446:layer:AWSLambdaPowertoolsPython:3](#) {: .copyMe} - | `us-west-2` | `1.21.0` |[arn:aws:lambda:us-west-2:017000801446:layer:AWSLambdaPowertoolsPython:3](#) {: .copyMe} - | `ap-south-1` | `1.21.0` |[arn:aws:lambda:ap-south-1:017000801446:layer:AWSLambdaPowertoolsPython:3](#) {: .copyMe} - | `ap-northeast-1` | `1.21.0` |[arn:aws:lambda:ap-northeast-1:017000801446:layer:AWSLambdaPowertoolsPython:3](#) {: .copyMe} - | `ap-northeast-2` | `1.21.0` |[arn:aws:lambda:ap-northeast-2:017000801446:layer:AWSLambdaPowertoolsPython:3](#) {: .copyMe} - | `ap-northeast-3` | `1.21.0` |[arn:aws:lambda:ap-northeast-3:017000801446:layer:AWSLambdaPowertoolsPython:3](#) {: .copyMe} - | `ap-southeast-1` | `1.21.0` |[arn:aws:lambda:ap-southeast-1:017000801446:layer:AWSLambdaPowertoolsPython:3](#) {: .copyMe} - | `ap-southeast-2` | `1.21.0` |[arn:aws:lambda:ap-southeast-2:017000801446:layer:AWSLambdaPowertoolsPython:3](#) {: .copyMe} - | `eu-central-1` | `1.21.0` |[arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPython:3](#) {: .copyMe} - | `eu-west-1` | `1.21.0` |[arn:aws:lambda:eu-west-1:017000801446:layer:AWSLambdaPowertoolsPython:3](#) {: .copyMe} - | `eu-west-2` | `1.21.0` |[arn:aws:lambda:eu-west-2:017000801446:layer:AWSLambdaPowertoolsPython:3](#) {: .copyMe} - | `eu-west-3` | `1.21.0` |[arn:aws:lambda:eu-west-3:017000801446:layer:AWSLambdaPowertoolsPython:3](#) {: .copyMe} - | `eu-north-1` | `1.21.0` |[arn:aws:lambda:eu-north-1:017000801446:layer:AWSLambdaPowertoolsPython:3](#) {: .copyMe} - | `ca-central-1` | `1.21.0` |[arn:aws:lambda:ca-central-1:017000801446:layer:AWSLambdaPowertoolsPython:3](#) {: .copyMe} - | `sa-east-1` | `1.21.0` |[arn:aws:lambda:sa-east-1:017000801446:layer:AWSLambdaPowertoolsPython:3](#) {: .copyMe} +=== "Amplify" + + ```zsh + # Create a new one with the layer + ❯ amplify add function + ? Select which capability you want to add: Lambda function (serverless function) + ? Provide an AWS Lambda function name: + ? Choose the runtime that you want to use: Python + ? Do you want to configure advanced settings? Yes + ... + ? Do you want to enable Lambda layers for this function? Yes + ? Enter up to 5 existing Lambda layer ARNs (comma-separated): arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPython:3 + ❯ amplify push -y + + + # Updating an existing function and add the layer + ❯ amplify update function + ? Select the Lambda function you want to update test2 + General information + - Name: + ? Which setting do you want to update? Lambda layers configuration + ? Do you want to enable Lambda layers for this function? Yes + ? Enter up to 5 existing Lambda layer ARNs (comma-separated): arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPython:3 + ? Do you want to edit the local lambda function now? No + ``` + +=== "Get the Layer .zip contents" + Change {region} to your AWS region, e.g. `eu-west-1` + + **`aws lambda get-layer-version-by-arn --arn arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPython:3 --region {region}`** + +!!! warning "Limitations" + + Container Image deployment (OCI) or inline Lambda functions do not support Lambda Layers. + + Lambda Powertools Lambda Layer do not include `pydantic` library - required dependency for the `parser` utility. See [SAR](#sar) option instead. + #### SAR +Serverless Application Repository (SAR) App deploys a CloudFormation stack with a copy of our Lambda Layer in your AWS account and region. + +Despite having more steps compared to the [public Layer ARN](#lambda-layer) option, the benefit is that you can specify a semantic version you want to use. + | App | ARN | Description |----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- -| [aws-lambda-powertools-python-layer](https://serverlessrepo.aws.amazon.com/applications/eu-west-1/057560766410/aws-lambda-powertools-python-layer) | arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer | Core dependencies only; sufficient for nearly all utilities. -| [aws-lambda-powertools-python-layer-extras](https://serverlessrepo.aws.amazon.com/applications/eu-west-1/057560766410/aws-lambda-powertools-python-layer-extras) | arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer-extras | Core plus extra dependencies such as `pydantic` that is required by `parser` utility. +| [aws-lambda-powertools-python-layer](https://serverlessrepo.aws.amazon.com/applications/eu-west-1/057560766410/aws-lambda-powertools-python-layer) | [arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer](#){: .copyMe} :clipboard: | Core dependencies only; sufficient for nearly all utilities. +| [aws-lambda-powertools-python-layer-extras](https://serverlessrepo.aws.amazon.com/applications/eu-west-1/057560766410/aws-lambda-powertools-python-layer-extras) | [arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer-extras](#){: .copyMe} :clipboard: | Core plus extra dependencies such as `pydantic` that is required by `parser` utility. !!! warning **Layer-extras** does not support Python 3.6 runtime. This layer also includes all extra dependencies: `22.4MB zipped`, `~155MB unzipped`. +!!! tip "You can create a shared Lambda Layers stack and make this along with other account level layers stack." + If using SAM, you can include this SAR App as part of your shared Layers stack, and lock to a specific semantic version. Once deployed, it'll be available across the account this is deployed to. === "SAM" @@ -173,16 +203,16 @@ If using SAM, you can include this SAR App as part of your shared Layers stack, AwsLambdaPowertoolsPythonLayer: Type: AWS::Serverless::Application Properties: - Location: - ApplicationId: arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer - SemanticVersion: 1.17.0 # change to latest semantic version available in SAR + Location: + ApplicationId: arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer + SemanticVersion: 1.21.1 # change to latest semantic version available in SAR MyLambdaFunction: Type: AWS::Serverless::Function Properties: - Layers: - # fetch Layer ARN from SAR App stack output - - !GetAtt AwsLambdaPowertoolsPythonLayer.Outputs.LayerVersionArn + Layers: + # fetch Layer ARN from SAR App stack output + - !GetAtt AwsLambdaPowertoolsPythonLayer.Outputs.LayerVersionArn ``` === "Serverless framework" @@ -196,14 +226,14 @@ If using SAM, you can include this SAR App as part of your shared Layers stack, resources: Transform: AWS::Serverless-2016-10-31 - Resources: + Resources:**** AwsLambdaPowertoolsPythonLayer: Type: AWS::Serverless::Application Properties: - Location: - ApplicationId: arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer - # Find latest from github.com/awslabs/aws-lambda-powertools-python/releases - SemanticVersion: 1.17.0 + Location: + ApplicationId: arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer + # Find latest from github.com/awslabs/aws-lambda-powertools-python/releases + SemanticVersion: 1.21.1 ``` === "CDK" @@ -213,7 +243,7 @@ If using SAM, you can include this SAR App as part of your shared Layers stack, POWERTOOLS_BASE_NAME = 'AWSLambdaPowertools' # Find latest from github.com/awslabs/aws-lambda-powertools-python/releases - POWERTOOLS_VER = '1.17.0' + POWERTOOLS_VER = '1.21.1' POWERTOOLS_ARN = 'arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer' class SampleApp(core.Construct): @@ -365,6 +395,16 @@ You can fetch available versions via SAR API with: --application-id arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer ``` +## Quick getting started + +**Quick hello world example using SAM CLI** + +=== "shell" + + ```bash + sam init --location https://github.com/aws-samples/cookiecutter-aws-sam-python + ``` + ## Features | Utility | Description diff --git a/docs/media/micro-function.png b/docs/media/micro-function.png new file mode 100644 index 00000000000..74887bc7726 Binary files /dev/null and b/docs/media/micro-function.png differ diff --git a/docs/media/monolithic-function.png b/docs/media/monolithic-function.png new file mode 100644 index 00000000000..38a16600a4c Binary files /dev/null and b/docs/media/monolithic-function.png differ diff --git a/docs/utilities/data_classes.md b/docs/utilities/data_classes.md index e05193c7702..cbe874d4b94 100644 --- a/docs/utilities/data_classes.md +++ b/docs/utilities/data_classes.md @@ -58,6 +58,7 @@ Same example as above, but using the `event_source` decorator Event Source | Data_class ------------------------------------------------- | --------------------------------------------------------------------------------- +[Active MQ](#active-mq) | `ActiveMQEvent` [API Gateway Authorizer](#api-gateway-authorizer) | `APIGatewayAuthorizerRequestEvent` [API Gateway Authorizer V2](#api-gateway-authorizer-v2) | `APIGatewayAuthorizerEventV2` [API Gateway Proxy](#api-gateway-proxy) | `APIGatewayProxyEvent` @@ -72,6 +73,7 @@ Event Source | Data_class [DynamoDB streams](#dynamodb-streams) | `DynamoDBStreamEvent`, `DynamoDBRecordEventName` [EventBridge](#eventbridge) | `EventBridgeEvent` [Kinesis Data Stream](#kinesis-streams) | `KinesisStreamEvent` +[Rabbit MQ](#rabbit-mq) | `RabbitMQEvent` [S3](#s3) | `S3Event` [S3 Object Lambda](#s3-object-lambda) | `S3ObjectLambdaEvent` [SES](#ses) | `SESEvent` @@ -82,6 +84,31 @@ Event Source | Data_class The examples provided below are far from exhaustive - the data classes themselves are designed to provide a form of documentation inherently (via autocompletion, types and docstrings). +### Active MQ + +It is used for [Active MQ payloads](https://docs.aws.amazon.com/lambda/latest/dg/with-mq.html){target="_blank"}, also see +the [AWS blog post](https://aws.amazon.com/blogs/compute/using-amazon-mq-as-an-event-source-for-aws-lambda/){target="_blank"} +for more details. + +=== "app.py" + + ```python hl_lines="4-5 9-10" + from typing import Dict + + from aws_lambda_powertools import Logger + from aws_lambda_powertools.utilities.data_classes import event_source + from aws_lambda_powertools.utilities.data_classes.active_mq_event import ActiveMQEvent + + logger = Logger() + + @event_source(data_class=ActiveMQEvent) + def lambda_handler(event: ActiveMQEvent, context): + for message in event.messages: + logger.debug(f"MessageID: {message.message_id}") + data: Dict = message.json_data + logger.debug("Process json in base64 encoded data str", data) + ``` + ### API Gateway Authorizer > New in 1.20.0 @@ -810,6 +837,33 @@ or plain text, depending on the original payload. do_something_with(data) ``` +### Rabbit MQ + +It is used for [Rabbit MQ payloads](https://docs.aws.amazon.com/lambda/latest/dg/with-mq.html){target="_blank"}, also see +the [blog post](https://aws.amazon.com/blogs/compute/using-amazon-mq-for-rabbitmq-as-an-event-source-for-lambda/){target="_blank"} +for more details. + +=== "app.py" + + ```python hl_lines="4-5 9-10" + from typing import Dict + + from aws_lambda_powertools import Logger + from aws_lambda_powertools.utilities.data_classes import event_source + from aws_lambda_powertools.utilities.data_classes.rabbit_mq_event import RabbitMQEvent + + logger = Logger() + + @event_source(data_class=RabbitMQEvent) + def lambda_handler(event: RabbitMQEvent, context): + for queue_name, messages in event.rmq_messages_by_queue.items(): + logger.debug(f"Messages for queue: {queue_name}") + for message in messages: + logger.debug(f"MessageID: {message.basic_properties.message_id}") + data: Dict = message.json_data + logger.debug("Process json in base64 encoded data str", data) + ``` + ### S3 === "app.py" diff --git a/docs/utilities/idempotency.md b/docs/utilities/idempotency.md index 43eb1ac3a0b..18a99b53999 100644 --- a/docs/utilities/idempotency.md +++ b/docs/utilities/idempotency.md @@ -289,16 +289,40 @@ The client was successful in receiving the result after the retry. Since the Lam ### Handling exceptions -**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 are using the `idempotent` decorator on your Lambda handler, any unhandled exceptions that are raised during the code execution will cause **the record in the persistence layer to be deleted**. +This means that new invocations will execute your code again despite having the same payload. If you don't want the record to be deleted, you need to catch exceptions within the idempotent function and return a successful response. -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) +If you are using `idempotent_function`, any unhandled exceptions that are raised _inside_ the decorated function will cause the record in the persistence layer to be deleted, and allow the function to be executed again if retried. +If an Exception is raised _outside_ the scope of the decorated function and after your function has been called, the persistent record will not be affected. In this case, idempotency will be maintained for your decorated function. Example: + +=== "app.py" + +```python hl_lines="2-4 8-10" +def lambda_handler(event, context): + # If an exception is raised here, no idempotent record will ever get created as the + # idempotent function does not get called + do_some_stuff() + + result = call_external_service(data={"user": "user1", "id": 5}) + + # This exception will not cause the idempotent record to be deleted, since it + # happens after the decorated function has been successfully called + raise Exception + + +@idempotent_function(data_keyword_argument="data", config=config, persistence_store=dynamodb) +def call_external_service(data: dict, **kwargs): + result = requests.post('http://example.com', json={"user": data['user'], "transaction_id": data['id']} + return result.json() +``` + !!! warning - **We will raise `IdempotencyPersistenceLayerError`** if any of the calls to the persistence layer fail unexpectedly. + **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. + As this happens outside the scope of your decorated function, you are not able to catch it if you're using the `idempotent` decorator on your Lambda handler. ### Persistence layers @@ -321,16 +345,18 @@ This persistence layer is built-in, and you can either use an existing DynamoDB ) ``` -These are knobs you can use when using DynamoDB as a persistence layer: +When using DynamoDB as a persistence layer, you can alter the attribute names by passing these parameters when initializing the 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 +**key_attr** | | `id` | Partition key of the table. Hashed representation of the payload (unless **sort_key_attr** is specified) **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 +**sort_key_attr** | | | Sort key of the table (if table is configured with a sort key). +**static_pk_value** | | `idempotency#{LAMBDA_FUNCTION_NAME}` | Static value to use as the partition key. Only used when **sort_key_attr** is set. ## Advanced @@ -590,6 +616,36 @@ The **`boto_config`** and **`boto3_session`** parameters enable you to pass in a ... ``` +### Using a DynamoDB table with a composite primary key + +If you wish to use this utility with a DynamoDB table that is configured with a composite primary key (uses both partition key and sort key), you +should set the `sort_key_attr` parameter when initializing your persistence layer. When this parameter is set, the partition key value for all idempotency entries +will be the same, with the idempotency key being saved as the sort key instead of the partition key. You can optionally set a static value for the partition +key using the `static_pk_value` parameter. If not specified, it will default to `idempotency#{LAMBDA_FUNCTION_NAME}`. + +=== "MyLambdaFunction" + + ```python hl_lines="5" + from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent + + persistence_layer = DynamoDBPersistenceLayer( + table_name="IdempotencyTable", + sort_key_attr='sort_key') + + + @idempotent(persistence_store=persistence_layer) + def handler(event, context): + return {"message": "success": "id": event['body']['id]} + ``` + +The example function above would cause data to be stored in DynamoDB like this: + +| id | sort_key | expiration | status | data | +|------------------------------|----------------------------------|------------|-------------|-------------------------------------| +| idempotency#MyLambdaFunction | 1e956ef7da78d0cb890be999aecc0c9e | 1636549553 | COMPLETED | {"id": 12391, "message": "success"} | +| idempotency#MyLambdaFunction | 2b2cdb5f86361e97b4383087c1ffdf27 | 1636549571 | COMPLETED | {"id": 527212, "message": "success"}| +| idempotency#MyLambdaFunction | f091d2527ad1c78f05d54cc3f363be80 | 1636549585 | IN_PROGRESS | | + ### Bring your own persistent store This utility provides an abstract base class (ABC), so that you can implement your choice of persistent storage layer. diff --git a/docs/utilities/middleware_factory.md b/docs/utilities/middleware_factory.md index 366ae7eda66..253bf6157c3 100644 --- a/docs/utilities/middleware_factory.md +++ b/docs/utilities/middleware_factory.md @@ -47,9 +47,8 @@ You can also have your own keyword arguments after the mandatory arguments. # Obfuscate email before calling Lambda handler if fields: for field in fields: - field = event.get(field, "") if field in event: - event[field] = obfuscate(field) + event[field] = obfuscate(event[field]) return handler(event, context) diff --git a/docs/utilities/parser.md b/docs/utilities/parser.md index 9f1bed3c0cb..7c9af95896f 100644 --- a/docs/utilities/parser.md +++ b/docs/utilities/parser.md @@ -57,8 +57,9 @@ Use the decorator for fail fast scenarios where you want your Lambda function to === "event_parser_decorator.py" ```python hl_lines="18" - from aws_lambda_powertools.utilities.parser import event_parser, BaseModel, ValidationError + from aws_lambda_powertools.utilities.parser import event_parser, BaseModel from aws_lambda_powertools.utilities.typing import LambdaContext + from typing import List, Optional import json @@ -80,7 +81,7 @@ Use the decorator for fail fast scenarios where you want your Lambda function to print(event.description) print(event.items) - order_items = [items for item in event.items] + order_items = [item for item in event.items] ... payload = { @@ -107,6 +108,7 @@ Use this standalone function when you want more control over the data validation ```python hl_lines="21 30" from aws_lambda_powertools.utilities.parser import parse, BaseModel, ValidationError + from typing import List, Optional class OrderItem(BaseModel): id: int diff --git a/mkdocs.yml b/mkdocs.yml index fc51acb8b47..54a0fa50a67 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -30,6 +30,8 @@ nav: theme: name: material + font: + text: Ubuntu palette: - scheme: default primary: deep purple diff --git a/poetry.lock b/poetry.lock index 1d1e5366e12..fea9831cd5f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,11 +1,3 @@ -[[package]] -name = "appdirs" -version = "1.4.4" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" -optional = false -python-versions = "*" - [[package]] name = "atomicwrites" version = "1.4.0" @@ -58,37 +50,43 @@ stevedore = ">=1.20.0" [[package]] name = "black" -version = "20.8b1" +version = "21.10b0" description = "The uncompromising code formatter." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.6.2" [package.dependencies] -appdirs = "*" click = ">=7.1.2" dataclasses = {version = ">=0.6", markers = "python_version < \"3.7\""} mypy-extensions = ">=0.4.3" -pathspec = ">=0.6,<1" +pathspec = ">=0.9.0,<1" +platformdirs = ">=2" regex = ">=2020.1.8" -toml = ">=0.10.1" -typed-ast = ">=1.4.0" -typing-extensions = ">=3.7.4" +tomli = ">=0.2.6,<2.0.0" +typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\""} +typing-extensions = [ + {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}, + {version = "!=3.10.0.1", markers = "python_version >= \"3.10\""}, +] [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +python2 = ["typed-ast (>=1.4.3)"] +uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "boto3" -version = "1.18.56" +version = "1.20.5" description = "The AWS SDK for Python" category = "main" optional = false python-versions = ">= 3.6" [package.dependencies] -botocore = ">=1.21.56,<1.22.0" +botocore = ">=1.23.5,<1.24.0" jmespath = ">=0.7.1,<1.0.0" s3transfer = ">=0.5.0,<0.6.0" @@ -97,7 +95,7 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.21.56" +version = "1.23.5" description = "Low-level, data-driven core of boto 3." category = "main" optional = false @@ -109,7 +107,7 @@ python-dateutil = ">=2.1,<3.0.0" urllib3 = ">=1.25.4,<1.27" [package.extras] -crt = ["awscrt (==0.11.24)"] +crt = ["awscrt (==0.12.5)"] [[package]] name = "certifi" @@ -149,7 +147,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "coverage" -version = "6.0" +version = "6.1.2" description = "Code coverage measurement for Python" category = "dev" optional = false @@ -273,14 +271,14 @@ test = ["coverage", "coveralls", "mock", "pytest", "pytest-cov"] [[package]] name = "flake8-comprehensions" -version = "3.6.1" +version = "3.7.0" description = "A flake8 plugin to help you write better list/set/dict comprehensions." category = "dev" optional = false python-versions = ">=3.6" [package.dependencies] -flake8 = ">=3.0,<3.2.0 || >3.2.0,<4" +flake8 = ">=3.0,<3.2.0 || >3.2.0,<5" importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [[package]] @@ -298,7 +296,7 @@ six = "*" [[package]] name = "flake8-eradicate" -version = "1.1.0" +version = "1.2.0" description = "Flake8 plugin to find commented out code" category = "dev" optional = false @@ -307,7 +305,7 @@ python-versions = ">=3.6,<4.0" [package.dependencies] attrs = "*" eradicate = ">=2.0,<3.0" -flake8 = ">=3.5,<4.0" +flake8 = ">=3.5,<5" [[package]] name = "flake8-fixme" @@ -319,19 +317,19 @@ python-versions = "*" [[package]] name = "flake8-isort" -version = "4.0.0" +version = "4.1.1" description = "flake8 plugin that integrates isort ." category = "dev" optional = false python-versions = "*" [package.dependencies] -flake8 = ">=3.2.1,<4" +flake8 = ">=3.2.1,<5" isort = ">=4.3.5,<6" testfixtures = ">=6.8.0,<7" [package.extras] -test = ["pytest (>=4.0.2,<6)", "toml"] +test = ["pytest-cov"] [[package]] name = "flake8-variables-names" @@ -420,7 +418,7 @@ python-versions = "*" [[package]] name = "isort" -version = "5.9.3" +version = "5.10.1" description = "A Python utility / library to sort Python imports." category = "dev" optional = false @@ -541,7 +539,7 @@ test = ["coverage", "flake8 (>=3.0)"] [[package]] name = "mkdocs" -version = "1.2.2" +version = "1.2.3" description = "Project documentation with Markdown." category = "dev" optional = false @@ -577,7 +575,7 @@ mkdocs = ">=0.17" [[package]] name = "mkdocs-material" -version = "7.3.2" +version = "7.3.6" description = "A Material Design theme for MkDocs" category = "dev" optional = false @@ -586,9 +584,9 @@ python-versions = "*" [package.dependencies] jinja2 = ">=2.11.1" markdown = ">=3.2" -mkdocs = ">=1.2.2" +mkdocs = ">=1.2.3" mkdocs-material-extensions = ">=1.0" -pygments = ">=2.4" +pygments = ">=2.10" pymdown-extensions = ">=9.0" [[package]] @@ -641,11 +639,11 @@ pyparsing = ">=2.0.2" [[package]] name = "pathspec" -version = "0.8.1" +version = "0.9.0" description = "Utility library for gitignore style pattern matching of file paths." category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [[package]] name = "pbr" @@ -667,6 +665,18 @@ python-versions = ">= 3.6" mako = "*" markdown = ">=3.0" +[[package]] +name = "platformdirs" +version = "2.4.0" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] +test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] + [[package]] name = "pluggy" version = "0.13.1" @@ -723,7 +733,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pygments" -version = "2.9.0" +version = "2.10.0" description = "Pygments is a syntax highlighting package written in Python." category = "dev" optional = false @@ -772,7 +782,7 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xm [[package]] name = "pytest-asyncio" -version = "0.15.1" +version = "0.16.0" description = "Pytest support for asyncio." category = "dev" optional = false @@ -986,7 +996,7 @@ python-versions = "*" [[package]] name = "typing-extensions" -version = "3.10.0.0" +version = "3.10.0.2" description = "Backported and Experimental Type Hints for Python 3.5+" category = "main" optional = false @@ -994,16 +1004,16 @@ python-versions = "*" [[package]] name = "urllib3" -version = "1.26.4" +version = "1.26.5" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" [package.extras] +brotli = ["brotlipy (>=0.6.0)"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] -brotli = ["brotlipy (>=0.6.0)"] [[package]] name = "watchdog" @@ -1054,14 +1064,10 @@ pydantic = ["pydantic", "email-validator"] [metadata] lock-version = "1.1" -python-versions = "^3.6.1" -content-hash = "829128c92690e9cfa6ed3387bb6927fcca65a5478baadb59db5c489b99f71bfd" +python-versions = "^3.6.2" +content-hash = "2873198da6ba0fc9487a838f4bb5e3f7c7d35fa31cf7a6a412733927cfed5c5f" [metadata.files] -appdirs = [ - {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, - {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, -] atomicwrites = [ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, @@ -1079,15 +1085,16 @@ bandit = [ {file = "bandit-1.7.0.tar.gz", hash = "sha256:8a4c7415254d75df8ff3c3b15cfe9042ecee628a1e40b44c15a98890fbfc2608"}, ] black = [ - {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, + {file = "black-21.10b0-py3-none-any.whl", hash = "sha256:6eb7448da9143ee65b856a5f3676b7dda98ad9abe0f87fce8c59291f15e82a5b"}, + {file = "black-21.10b0.tar.gz", hash = "sha256:a9952229092e325fe5f3dae56d81f639b23f7131eb840781947e4b2886030f33"}, ] boto3 = [ - {file = "boto3-1.18.56-py3-none-any.whl", hash = "sha256:42828d83acddfa2361411b13683eedf7c1c0a15e896b45960ed04a26efe7adfb"}, - {file = "boto3-1.18.56.tar.gz", hash = "sha256:d43e3651ad1b0b5de6f77df82df27e0f1e6cd854f725c808c70a1fb956f0b699"}, + {file = "boto3-1.20.5-py3-none-any.whl", hash = "sha256:81ca80fbb3d551819c35c809cb159fd0bec6701d3d8f0e5906a22da7558d098e"}, + {file = "boto3-1.20.5.tar.gz", hash = "sha256:cc620c289b12d7bf7c2706b517c9f8950f9be4622aacc9e7580b8b4ee0d3bc73"}, ] botocore = [ - {file = "botocore-1.21.56-py3-none-any.whl", hash = "sha256:d712f572022670916bd77fbe421155dcf575398b9dced88035ed3658679883bd"}, - {file = "botocore-1.21.56.tar.gz", hash = "sha256:43fab79905e3dfe56f92a137314ef1afbf040f7c06516a003351c24322cbfd7c"}, + {file = "botocore-1.23.5-py3-none-any.whl", hash = "sha256:c8eaeee0bac356396386aa9165043808fe736fb9e03ac0dedb1dfd82f41ad1a3"}, + {file = "botocore-1.23.5.tar.gz", hash = "sha256:49d1f012dc8467577a5fe603fc87cc13af816dd926b2bc2e28a3b2999ab14d36"}, ] certifi = [ {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, @@ -1106,41 +1113,53 @@ colorama = [ {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] coverage = [ - {file = "coverage-6.0-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:3dfb23cc180b674a11a559183dff9655beb9da03088f3fe3c4f3a6d200c86f05"}, - {file = "coverage-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5dd5ae0a9cd55d71f1335c331e9625382239b8cede818fb62d8d2702336dbf8"}, - {file = "coverage-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8426fec5ad5a6e8217921716b504e9b6e1166dc147e8443b4855e329db686282"}, - {file = "coverage-6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:aa5d4d43fa18cc9d0c6e02a83de0b9729b5451a9066574bd276481474f0a53ab"}, - {file = "coverage-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b78dd3eeb8f5ff26d2113c41836bac04a9ea91be54c346826b54a373133c8c53"}, - {file = "coverage-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:581fddd2f883379bd5af51da9233e0396b6519f3d3eeae4fb88867473be6d56e"}, - {file = "coverage-6.0-cp310-cp310-win32.whl", hash = "sha256:43bada49697a62ffa0283c7f01bbc76aac562c37d4bb6c45d56dd008d841194e"}, - {file = "coverage-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:fa816e97cfe1f691423078dffa39a18106c176f28008db017b3ce3e947c34aa5"}, - {file = "coverage-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:5c191e01b23e760338f19d8ba2470c0dad44c8b45e41ac043b2db84efc62f695"}, - {file = "coverage-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:274a612f67f931307706b60700f1e4cf80e1d79dff6c282fc9301e4565e78724"}, - {file = "coverage-6.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a9dbfcbc56d8de5580483cf2caff6a59c64d3e88836cbe5fb5c20c05c29a8808"}, - {file = "coverage-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e63490e8a6675cee7a71393ee074586f7eeaf0e9341afd006c5d6f7eec7c16d7"}, - {file = "coverage-6.0-cp36-cp36m-win32.whl", hash = "sha256:72f8c99f1527c5a8ee77c890ea810e26b39fd0b4c2dffc062e20a05b2cca60ef"}, - {file = "coverage-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:88f1810eb942e7063d051d87aaaa113eb5fd5a7fd2cda03a972de57695b8bb1a"}, - {file = "coverage-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:befb5ffa9faabef6dadc42622c73de168001425258f0b7e402a2934574e7a04b"}, - {file = "coverage-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7dbda34e8e26bd86606ba8a9c13ccb114802e01758a3d0a75652ffc59a573220"}, - {file = "coverage-6.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:b4ee5815c776dfa3958ba71c7cd4cdd8eb40d79358a18352feb19562fe4408c4"}, - {file = "coverage-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d82cbef1220703ce56822be7fbddb40736fc1a928ac893472df8aff7421ae0aa"}, - {file = "coverage-6.0-cp37-cp37m-win32.whl", hash = "sha256:d795a2c92fe8cb31f6e9cd627ee4f39b64eb66bf47d89d8fcf7cb3d17031c887"}, - {file = "coverage-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:6e216e4021c934246c308fd3e0d739d9fa8a3f4ea414f584ab90ef9c1592f282"}, - {file = "coverage-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8305e14112efb74d0b5fec4df6e41cafde615c2392a7e51c84013cafe945842c"}, - {file = "coverage-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4865dc4a7a566147cbdc2b2f033a6cccc99a7dcc89995137765c384f6c73110b"}, - {file = "coverage-6.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:25df2bc53a954ba2ccf230fa274d1de341f6aa633d857d75e5731365f7181749"}, - {file = "coverage-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:08fd55d2e00dac4c18a2fa26281076035ec86e764acdc198b9185ce749ada58f"}, - {file = "coverage-6.0-cp38-cp38-win32.whl", hash = "sha256:11ce082eb0f7c2bbfe96f6c8bcc3a339daac57de4dc0f3186069ec5c58da911c"}, - {file = "coverage-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:7844a8c6a0fee401edbf578713c2473e020759267c40261b294036f9d3eb6a2d"}, - {file = "coverage-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bea681309bdd88dd1283a8ba834632c43da376d9bce05820826090aad80c0126"}, - {file = "coverage-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e735ab8547d8a1fe8e58dd765d6f27ac539b395f52160d767b7189f379f9be7a"}, - {file = "coverage-6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7593a49300489d064ebb6c58539f52cbbc4a2e6a4385de5e92cae1563f88a425"}, - {file = "coverage-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:adb0f4c3c8ba8104378518a1954cbf3d891a22c13fd0e0bf135391835f44f288"}, - {file = "coverage-6.0-cp39-cp39-win32.whl", hash = "sha256:8da0c4a26a831b392deaba5fdd0cd7838d173b47ce2ec3d0f37be630cb09ef6e"}, - {file = "coverage-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:7af2f8e7bb54ace984de790e897f858e88068d8fbc46c9490b7c19c59cf51822"}, - {file = "coverage-6.0-pp36-none-any.whl", hash = "sha256:82b58d37c47d93a171be9b5744bcc96a0012cbf53d5622b29a49e6be2097edd7"}, - {file = "coverage-6.0-pp37-none-any.whl", hash = "sha256:fff04bfefb879edcf616f1ce5ea6f4a693b5976bdc5e163f8464f349c25b59f0"}, - {file = "coverage-6.0.tar.gz", hash = "sha256:17983f6ccc47f4864fd16d20ff677782b23d1207bf222d10e4d676e4636b0872"}, + {file = "coverage-6.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:675adb3b3380967806b3cbb9c5b00ceb29b1c472692100a338730c1d3e59c8b9"}, + {file = "coverage-6.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95a58336aa111af54baa451c33266a8774780242cab3704b7698d5e514840758"}, + {file = "coverage-6.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d0a595a781f8e186580ff8e3352dd4953b1944289bec7705377c80c7e36c4d6c"}, + {file = "coverage-6.1.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d3c5f49ce6af61154060640ad3b3281dbc46e2e0ef2fe78414d7f8a324f0b649"}, + {file = "coverage-6.1.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:310c40bed6b626fd1f463e5a83dba19a61c4eb74e1ac0d07d454ebbdf9047e9d"}, + {file = "coverage-6.1.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a4d48e42e17d3de212f9af44f81ab73b9378a4b2b8413fd708d0d9023f2bbde4"}, + {file = "coverage-6.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ffa545230ca2ad921ad066bf8fd627e7be43716b6e0fcf8e32af1b8188ccb0ab"}, + {file = "coverage-6.1.2-cp310-cp310-win32.whl", hash = "sha256:cd2d11a59afa5001ff28073ceca24ae4c506da4355aba30d1e7dd2bd0d2206dc"}, + {file = "coverage-6.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:96129e41405887a53a9cc564f960d7f853cc63d178f3a182fdd302e4cab2745b"}, + {file = "coverage-6.1.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1de9c6f5039ee2b1860b7bad2c7bc3651fbeb9368e4c4d93e98a76358cdcb052"}, + {file = "coverage-6.1.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:80cb70264e9a1d04b519cdba3cd0dc42847bf8e982a4d55c769b9b0ee7cdce1e"}, + {file = "coverage-6.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:ba6125d4e55c0b8e913dad27b22722eac7abdcb1f3eab1bd090eee9105660266"}, + {file = "coverage-6.1.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8492d37acdc07a6eac6489f6c1954026f2260a85a4c2bb1e343fe3d35f5ee21a"}, + {file = "coverage-6.1.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:66af99c7f7b64d050d37e795baadf515b4561124f25aae6e1baa482438ecc388"}, + {file = "coverage-6.1.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ebcc03e1acef4ff44f37f3c61df478d6e469a573aa688e5a162f85d7e4c3860d"}, + {file = "coverage-6.1.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98d44a8136eebbf544ad91fef5bd2b20ef0c9b459c65a833c923d9aa4546b204"}, + {file = "coverage-6.1.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:c18725f3cffe96732ef96f3de1939d81215fd6d7d64900dcc4acfe514ea4fcbf"}, + {file = "coverage-6.1.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:c8e9c4bcaaaa932be581b3d8b88b677489975f845f7714efc8cce77568b6711c"}, + {file = "coverage-6.1.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:06d009e8a29483cbc0520665bc46035ffe9ae0e7484a49f9782c2a716e37d0a0"}, + {file = "coverage-6.1.2-cp36-cp36m-win32.whl", hash = "sha256:e5432d9c329b11c27be45ee5f62cf20a33065d482c8dec1941d6670622a6fb8f"}, + {file = "coverage-6.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:82fdcb64bf08aa5db881db061d96db102c77397a570fbc112e21c48a4d9cb31b"}, + {file = "coverage-6.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:94f558f8555e79c48c422045f252ef41eb43becdd945e9c775b45ebfc0cbd78f"}, + {file = "coverage-6.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:046647b96969fda1ae0605f61288635209dd69dcd27ba3ec0bf5148bc157f954"}, + {file = "coverage-6.1.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:cc799916b618ec9fd00135e576424165691fec4f70d7dc12cfaef09268a2478c"}, + {file = "coverage-6.1.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62646d98cf0381ffda301a816d6ac6c35fc97aa81b09c4c52d66a15c4bef9d7c"}, + {file = "coverage-6.1.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:27a3df08a855522dfef8b8635f58bab81341b2fb5f447819bc252da3aa4cf44c"}, + {file = "coverage-6.1.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:610c0ba11da8de3a753dc4b1f71894f9f9debfdde6559599f303286e70aeb0c2"}, + {file = "coverage-6.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:35b246ae3a2c042dc8f410c94bcb9754b18179cdb81ff9477a9089dbc9ecc186"}, + {file = "coverage-6.1.2-cp37-cp37m-win32.whl", hash = "sha256:0cde7d9fe2fb55ff68ebe7fb319ef188e9b88e0a3d1c9c5db7dd829cd93d2193"}, + {file = "coverage-6.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:958ac66272ff20e63d818627216e3d7412fdf68a2d25787b89a5c6f1eb7fdd93"}, + {file = "coverage-6.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a300b39c3d5905686c75a369d2a66e68fd01472ea42e16b38c948bd02b29e5bd"}, + {file = "coverage-6.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d3855d5d26292539861f5ced2ed042fc2aa33a12f80e487053aed3bcb6ced13"}, + {file = "coverage-6.1.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:586d38dfc7da4a87f5816b203ff06dd7c1bb5b16211ccaa0e9788a8da2b93696"}, + {file = "coverage-6.1.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a34fccb45f7b2d890183a263578d60a392a1a218fdc12f5bce1477a6a68d4373"}, + {file = "coverage-6.1.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bc1ee1318f703bc6c971da700d74466e9b86e0c443eb85983fb2a1bd20447263"}, + {file = "coverage-6.1.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3f546f48d5d80a90a266769aa613bc0719cb3e9c2ef3529d53f463996dd15a9d"}, + {file = "coverage-6.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd92ece726055e80d4e3f01fff3b91f54b18c9c357c48fcf6119e87e2461a091"}, + {file = "coverage-6.1.2-cp38-cp38-win32.whl", hash = "sha256:24ed38ec86754c4d5a706fbd5b52b057c3df87901a8610d7e5642a08ec07087e"}, + {file = "coverage-6.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:97ef6e9119bd39d60ef7b9cd5deea2b34869c9f0b9777450a7e3759c1ab09b9b"}, + {file = "coverage-6.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6e5a8c947a2a89c56655ecbb789458a3a8e3b0cbf4c04250331df8f647b3de59"}, + {file = "coverage-6.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a39590d1e6acf6a3c435c5d233f72f5d43b585f5be834cff1f21fec4afda225"}, + {file = "coverage-6.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9d2c2e3ce7b8cc932a2f918186964bd44de8c84e2f9ef72dc616f5bb8be22e71"}, + {file = "coverage-6.1.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3348865798c077c695cae00da0924136bb5cc501f236cfd6b6d9f7a3c94e0ec4"}, + {file = "coverage-6.1.2-cp39-cp39-win32.whl", hash = "sha256:fae3fe111670e51f1ebbc475823899524e3459ea2db2cb88279bbfb2a0b8a3de"}, + {file = "coverage-6.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:af45eea024c0e3a25462fade161afab4f0d9d9e0d5a5d53e86149f74f0a35ecc"}, + {file = "coverage-6.1.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:eab14fdd410500dae50fd14ccc332e65543e7b39f6fc076fe90603a0e5d2f929"}, + {file = "coverage-6.1.2.tar.gz", hash = "sha256:d9a635114b88c0ab462e0355472d00a180a5fbfd8511e7f18e4ac32652e7d972"}, ] dataclasses = [ {file = "dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf"}, @@ -1178,24 +1197,24 @@ flake8-builtins = [ {file = "flake8_builtins-1.5.3-py2.py3-none-any.whl", hash = "sha256:7706babee43879320376861897e5d1468e396a40b8918ed7bccf70e5f90b8687"}, ] flake8-comprehensions = [ - {file = "flake8-comprehensions-3.6.1.tar.gz", hash = "sha256:4888de89248b7f7535159189ff693c77f8354f6d37a02619fa28c9921a913aa0"}, - {file = "flake8_comprehensions-3.6.1-py3-none-any.whl", hash = "sha256:e9a010b99aa90c05790d45281ad9953df44a4a08a1a8f6cd41f98b4fc6a268a0"}, + {file = "flake8-comprehensions-3.7.0.tar.gz", hash = "sha256:6b3218b2dde8ac5959c6476cde8f41a79e823c22feb656be2710cd2a3232cef9"}, + {file = "flake8_comprehensions-3.7.0-py3-none-any.whl", hash = "sha256:a5d7aea6315bbbd6fbcb2b4e80bff6a54d1600155e26236e555d0c6fe1d62522"}, ] flake8-debugger = [ {file = "flake8-debugger-4.0.0.tar.gz", hash = "sha256:e43dc777f7db1481db473210101ec2df2bd39a45b149d7218a618e954177eda6"}, {file = "flake8_debugger-4.0.0-py3-none-any.whl", hash = "sha256:82e64faa72e18d1bdd0000407502ebb8ecffa7bc027c62b9d4110ce27c091032"}, ] flake8-eradicate = [ - {file = "flake8-eradicate-1.1.0.tar.gz", hash = "sha256:f5917d6dbca352efcd10c15fdab9c55c48f0f26f6a8d47898b25d39101f170a8"}, - {file = "flake8_eradicate-1.1.0-py3-none-any.whl", hash = "sha256:d8e39b684a37c257a53cda817d86e2d96c9ba3450ddc292742623a5dfee04d9e"}, + {file = "flake8-eradicate-1.2.0.tar.gz", hash = "sha256:acaa1b6839ff00d284b805c432fdfa6047262bd15a5504ec945797e87b4de1fa"}, + {file = "flake8_eradicate-1.2.0-py3-none-any.whl", hash = "sha256:51dc660d0c1c1ed93af0f813540bbbf72ab2d3466c14e3f3bac371c618b6042f"}, ] flake8-fixme = [ {file = "flake8-fixme-1.1.1.tar.gz", hash = "sha256:50cade07d27a4c30d4f12351478df87339e67640c83041b664724bda6d16f33a"}, {file = "flake8_fixme-1.1.1-py2.py3-none-any.whl", hash = "sha256:226a6f2ef916730899f29ac140bed5d4a17e5aba79f00a0e3ae1eff1997cb1ac"}, ] flake8-isort = [ - {file = "flake8-isort-4.0.0.tar.gz", hash = "sha256:2b91300f4f1926b396c2c90185844eb1a3d5ec39ea6138832d119da0a208f4d9"}, - {file = "flake8_isort-4.0.0-py2.py3-none-any.whl", hash = "sha256:729cd6ef9ba3659512dee337687c05d79c78e1215fdf921ed67e5fe46cce2f3c"}, + {file = "flake8-isort-4.1.1.tar.gz", hash = "sha256:d814304ab70e6e58859bc5c3e221e2e6e71c958e7005239202fee19c24f82717"}, + {file = "flake8_isort-4.1.1-py3-none-any.whl", hash = "sha256:c4e8b6dcb7be9b71a02e6e5d4196cefcef0f3447be51e82730fb336fff164949"}, ] flake8-variables-names = [ {file = "flake8_variables_names-0.0.4.tar.gz", hash = "sha256:d6fa0571a807c72940b5773827c5760421ea6f8206595ff0a8ecfa01e42bf2cf"}, @@ -1228,8 +1247,8 @@ iniconfig = [ {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] isort = [ - {file = "isort-5.9.3-py3-none-any.whl", hash = "sha256:e17d6e2b81095c9db0a03a8025a957f334d6ea30b26f9ec70805411e5c7c81f2"}, - {file = "isort-5.9.3.tar.gz", hash = "sha256:9c2ea1e62d871267b78307fe511c0838ba0da28698c5732d54e2790bf3ba9899"}, + {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, + {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, ] jinja2 = [ {file = "Jinja2-3.0.1-py3-none-any.whl", hash = "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4"}, @@ -1257,6 +1276,9 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"}, {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"}, {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a"}, {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"}, {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, @@ -1268,6 +1290,9 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, @@ -1279,6 +1304,9 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"}, @@ -1291,6 +1319,9 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee"}, {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, @@ -1303,6 +1334,9 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"}, {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"}, {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1"}, {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, @@ -1320,16 +1354,16 @@ mike = [ {file = "mike-0.6.0.tar.gz", hash = "sha256:6d6239de2a60d733da2f34617e9b9a14c4b5437423b47e524f14dc96d6ce5f2f"}, ] mkdocs = [ - {file = "mkdocs-1.2.2-py3-none-any.whl", hash = "sha256:d019ff8e17ec746afeb54eb9eb4112b5e959597aebc971da46a5c9486137f0ff"}, - {file = "mkdocs-1.2.2.tar.gz", hash = "sha256:a334f5bd98ec960638511366eb8c5abc9c99b9083a0ed2401d8791b112d6b078"}, + {file = "mkdocs-1.2.3-py3-none-any.whl", hash = "sha256:a1fa8c2d0c1305d7fc2b9d9f607c71778572a8b110fb26642aa00296c9e6d072"}, + {file = "mkdocs-1.2.3.tar.gz", hash = "sha256:89f5a094764381cda656af4298727c9f53dc3e602983087e1fe96ea1df24f4c1"}, ] mkdocs-git-revision-date-plugin = [ {file = "mkdocs-git-revision-date-plugin-0.3.1.tar.gz", hash = "sha256:4abaef720763a64c952bed6829dcc180f67c97c60dd73914e90715e05d1cfb23"}, {file = "mkdocs_git_revision_date_plugin-0.3.1-py3-none-any.whl", hash = "sha256:8ae50b45eb75d07b150a69726041860801615aae5f4adbd6b1cf4d51abaa03d5"}, ] mkdocs-material = [ - {file = "mkdocs-material-7.3.2.tar.gz", hash = "sha256:02aeb2f9d9826b5c5ba4e320b4008bdc89f7b30ca00ded72ee43385a1690eaa4"}, - {file = "mkdocs_material-7.3.2-py2.py3-none-any.whl", hash = "sha256:a9b7c6432ebadd0e192e3b341b25bd41bd1c1b167061ea629a9887a1e4129176"}, + {file = "mkdocs-material-7.3.6.tar.gz", hash = "sha256:1b1dbd8ef2508b358d93af55a5c5db3f141c95667fad802301ec621c40c7c217"}, + {file = "mkdocs_material-7.3.6-py2.py3-none-any.whl", hash = "sha256:1b6b3e9e09f922c2d7f1160fe15c8f43d4adc0d6fb81aa6ff0cbc7ef5b78ec75"}, ] mkdocs-material-extensions = [ {file = "mkdocs-material-extensions-1.0.1.tar.gz", hash = "sha256:6947fb7f5e4291e3c61405bad3539d81e0b3cd62ae0d66ced018128af509c68f"}, @@ -1369,8 +1403,8 @@ packaging = [ {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, ] pathspec = [ - {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, - {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, + {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, + {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, ] pbr = [ {file = "pbr-5.6.0-py2.py3-none-any.whl", hash = "sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4"}, @@ -1379,6 +1413,10 @@ pbr = [ pdoc3 = [ {file = "pdoc3-0.10.0.tar.gz", hash = "sha256:5f22e7bcb969006738e1aa4219c75a32f34c2d62d46dc9d2fb2d3e0b0287e4b7"}, ] +platformdirs = [ + {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"}, + {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"}, +] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, @@ -1420,8 +1458,8 @@ pyflakes = [ {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, ] pygments = [ - {file = "Pygments-2.9.0-py3-none-any.whl", hash = "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e"}, - {file = "Pygments-2.9.0.tar.gz", hash = "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f"}, + {file = "Pygments-2.10.0-py3-none-any.whl", hash = "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380"}, + {file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"}, ] pymdown-extensions = [ {file = "pymdown-extensions-9.0.tar.gz", hash = "sha256:01e4bec7f4b16beaba0087a74496401cf11afd69e3a11fe95cb593e5c698ef40"}, @@ -1436,8 +1474,8 @@ pytest = [ {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, ] pytest-asyncio = [ - {file = "pytest-asyncio-0.15.1.tar.gz", hash = "sha256:2564ceb9612bbd560d19ca4b41347b54e7835c2f792c504f698e05395ed63f6f"}, - {file = "pytest_asyncio-0.15.1-py3-none-any.whl", hash = "sha256:3042bcdf1c5d978f6b74d96a151c4cfb9dcece65006198389ccd7e6c60eb1eea"}, + {file = "pytest-asyncio-0.16.0.tar.gz", hash = "sha256:7496c5977ce88c34379df64a66459fe395cd05543f0a2f837016e7144391fcfb"}, + {file = "pytest_asyncio-0.16.0-py3-none-any.whl", hash = "sha256:5f2a21273c47b331ae6aa5b36087047b4899e40f03f18397c0e65fa5cca54e9b"}, ] pytest-cov = [ {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, @@ -1635,13 +1673,13 @@ typed-ast = [ {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, ] typing-extensions = [ - {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, - {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, - {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, + {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, + {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, + {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, ] urllib3 = [ - {file = "urllib3-1.26.4-py2.py3-none-any.whl", hash = "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df"}, - {file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"}, + {file = "urllib3-1.26.5-py2.py3-none-any.whl", hash = "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c"}, + {file = "urllib3-1.26.5.tar.gz", hash = "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098"}, ] watchdog = [ {file = "watchdog-2.1.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9628f3f85375a17614a2ab5eac7665f7f7be8b6b0a2a228e6f6a2e91dd4bfe26"}, diff --git a/pyproject.toml b/pyproject.toml index 19797484c8f..cea8a4abbdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "aws_lambda_powertools" -version = "1.21.1" +version = "1.22.0" description = "A suite of utilities for AWS Lambda functions to ease adopting best practices such as tracing, structured logging, custom metrics, batching, idempotency, feature flags, and more." authors = ["Amazon Web Services"] include = ["aws_lambda_powertools/py.typed", "THIRD-PARTY-LICENSES"] @@ -20,7 +20,7 @@ keywords = ["aws_lambda_powertools", "aws", "tracing", "logging", "lambda", "pow license = "MIT-0" [tool.poetry.dependencies] -python = "^3.6.1" +python = "^3.6.2" aws-xray-sdk = "^2.8.0" fastjsonschema = "^2.14.5" boto3 = "^1.18" @@ -29,28 +29,28 @@ pydantic = {version = "^1.8.2", optional = true } email-validator = {version = "*", optional = true } [tool.poetry.dev-dependencies] -coverage = {extras = ["toml"], version = "^6.0"} +coverage = {extras = ["toml"], version = "^6.1"} pytest = "^6.2.5" -black = "^20.8b1" +black = "^21.10.b0" flake8 = "^3.9.0" flake8-black = "^0.2.3" flake8-builtins = "^1.5.3" -flake8-comprehensions = "^3.6.1" +flake8-comprehensions = "^3.7.0" flake8-debugger = "^4.0.0" flake8-fixme = "^1.1.1" -flake8-isort = "^4.0.0" +flake8-isort = "^4.1.1" flake8-variables-names = "^0.0.4" -isort = "^5.9.3" +isort = "^5.10.1" pytest-cov = "^3.0.0" pytest-mock = "^3.5.1" pdoc3 = "^0.10.0" -pytest-asyncio = "^0.15.1" +pytest-asyncio = "^0.16.0" bandit = "^1.7.0" radon = "^5.1.0" xenon = "^0.8.0" -flake8-eradicate = "^1.1.0" +flake8-eradicate = "^1.2.0" flake8-bugbear = "^21.9.2" -mkdocs-material = "^7.3.2" +mkdocs-material = "^7.3.6" mkdocs-git-revision-date-plugin = "^0.3.1" mike = "^0.6.0" mypy = "^0.910" diff --git a/tests/events/activeMQEvent.json b/tests/events/activeMQEvent.json new file mode 100644 index 00000000000..290ada184c9 --- /dev/null +++ b/tests/events/activeMQEvent.json @@ -0,0 +1,45 @@ +{ + "eventSource": "aws:amq", + "eventSourceArn": "arn:aws:mq:us-west-2:112556298976:broker:test:b-9bcfa592-423a-4942-879d-eb284b418fc8", + "messages": [ + { + "messageID": "ID:b-9bcfa592-423a-4942-879d-eb284b418fc8-1.mq.us-west-2.amazonaws.com-37557-1234520418293-4:1:1:1:1", + "messageType": "jms/text-message", + "data": "QUJDOkFBQUE=", + "connectionId": "myJMSCoID", + "redelivered": false, + "destination": { + "physicalname": "testQueue" + }, + "timestamp": 1598827811958, + "brokerInTime": 1598827811958, + "brokerOutTime": 1598827811959 + }, + { + "messageID": "ID:b-9bcfa592-423a-4942-879d-eb284b418fc8-1.mq.us-west-2.amazonaws.com-37557-1234520418293-4:1:1:1:1", + "messageType": "jms/text-message", + "data": "eyJ0aW1lb3V0IjowLCJkYXRhIjoiQ1pybWYwR3c4T3Y0YnFMUXhENEUifQ==", + "connectionId": "myJMSCoID2", + "redelivered": false, + "destination": { + "physicalname": "testQueue" + }, + "timestamp": 1598827811958, + "brokerInTime": 1598827811958, + "brokerOutTime": 1598827811959 + }, + { + "messageID": "ID:b-9bcfa592-423a-4942-879d-eb284b418fc8-1.mq.us-west-2.amazonaws.com-37557-1234520418293-4:1:1:1:1", + "messageType": "jms/bytes-message", + "data": "3DTOOW7crj51prgVLQaGQ82S48k=", + "connectionId": "myJMSCoID1", + "persistent": false, + "destination": { + "physicalname": "testQueue" + }, + "timestamp": 1598827811958, + "brokerInTime": 1598827811958, + "brokerOutTime": 1598827811959 + } + ] +} diff --git a/tests/events/rabbitMQEvent.json b/tests/events/rabbitMQEvent.json new file mode 100644 index 00000000000..e4259555a8b --- /dev/null +++ b/tests/events/rabbitMQEvent.json @@ -0,0 +1,51 @@ +{ + "eventSource": "aws:rmq", + "eventSourceArn": "arn:aws:mq:us-west-2:112556298976:broker:pizzaBroker:b-9bcfa592-423a-4942-879d-eb284b418fc8", + "rmqMessagesByQueue": { + "pizzaQueue::/": [ + { + "basicProperties": { + "contentType": "text/plain", + "contentEncoding": null, + "headers": { + "header1": { + "bytes": [ + 118, + 97, + 108, + 117, + 101, + 49 + ] + }, + "header2": { + "bytes": [ + 118, + 97, + 108, + 117, + 101, + 50 + ] + }, + "numberInHeader": 10 + }, + "deliveryMode": 1, + "priority": 34, + "correlationId": null, + "replyTo": null, + "expiration": "60000", + "messageId": null, + "timestamp": "Jan 1, 1970, 12:33:41 AM", + "type": null, + "userId": "AIDACKCEVSQ6C2EXAMPLE", + "appId": null, + "clusterId": null, + "bodySize": 80 + }, + "redelivered": false, + "data": "eyJ0aW1lb3V0IjowLCJkYXRhIjoiQ1pybWYwR3c4T3Y0YnFMUXhENEUifQ==" + } + ] + } +} diff --git a/tests/functional/data_classes/test_amazon_mq.py b/tests/functional/data_classes/test_amazon_mq.py new file mode 100644 index 00000000000..0f4f5079565 --- /dev/null +++ b/tests/functional/data_classes/test_amazon_mq.py @@ -0,0 +1,69 @@ +from typing import Dict + +from aws_lambda_powertools.utilities.data_classes.active_mq_event import ActiveMQEvent, ActiveMQMessage +from aws_lambda_powertools.utilities.data_classes.rabbit_mq_event import BasicProperties, RabbitMessage, RabbitMQEvent +from tests.functional.utils import load_event + + +def test_active_mq_event(): + event = ActiveMQEvent(load_event("activeMQEvent.json")) + + assert event.event_source == "aws:amq" + assert event.event_source_arn is not None + assert len(list(event.messages)) == 3 + + message = event.message + assert isinstance(message, ActiveMQMessage) + assert message.message_id is not None + assert message.message_type is not None + assert message.data is not None + assert message.decoded_data is not None + assert message.connection_id is not None + assert message.redelivered is False + assert message.timestamp is not None + assert message.broker_in_time is not None + assert message.broker_out_time is not None + assert message.destination_physicalname is not None + assert message.delivery_mode is None + assert message.correlation_id is None + assert message.reply_to is None + assert message.get_type is None + assert message.expiration is None + assert message.priority is None + + messages = list(event.messages) + message = messages[1] + assert message.json_data["timeout"] == 0 + + +def test_rabbit_mq_event(): + event = RabbitMQEvent(load_event("rabbitMQEvent.json")) + + assert event.event_source == "aws:rmq" + assert event.event_source_arn is not None + + message = event.rmq_messages_by_queue["pizzaQueue::/"][0] + assert message.redelivered is False + assert message.data is not None + assert message.decoded_data is not None + assert message.json_data["timeout"] == 0 + + assert isinstance(message, RabbitMessage) + properties = message.basic_properties + assert isinstance(properties, BasicProperties) + assert properties.content_type == "text/plain" + assert properties.content_encoding is None + assert isinstance(properties.headers, Dict) + assert properties.headers["header1"] is not None + assert properties.delivery_mode == 1 + assert properties.priority == 34 + assert properties.correlation_id is None + assert properties.reply_to is None + assert properties.expiration == "60000" + assert properties.message_id is None + assert properties.timestamp is not None + assert properties.get_type is None + assert properties.user_id is not None + assert properties.app_id is None + assert properties.cluster_id is None + assert properties.body_size == 80 diff --git a/tests/functional/event_handler/test_api_gateway.py b/tests/functional/event_handler/test_api_gateway.py index 21700ec09dd..f4543fa300c 100644 --- a/tests/functional/event_handler/test_api_gateway.py +++ b/tests/functional/event_handler/test_api_gateway.py @@ -17,6 +17,7 @@ ProxyEventType, Response, ResponseBuilder, + Router, ) from aws_lambda_powertools.event_handler.exceptions import ( BadRequestError, @@ -860,3 +861,163 @@ def base(): # THEN process event correctly assert result["statusCode"] == 200 assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + + +def test_api_gateway_app_router(): + # GIVEN a Router with registered routes + app = ApiGatewayResolver() + router = Router() + + @router.get("/my/path") + def foo(): + return {} + + app.include_router(router) + # WHEN calling the event handler after applying routes from router object + result = app(LOAD_GW_EVENT, {}) + + # THEN process event correctly + assert result["statusCode"] == 200 + assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + + +def test_api_gateway_app_router_with_params(): + # GIVEN a Router with registered routes + app = ApiGatewayResolver() + router = Router() + req = "foo" + event = deepcopy(LOAD_GW_EVENT) + event["resource"] = "/accounts/{account_id}" + event["path"] = f"/accounts/{req}" + lambda_context = {} + + @router.route(rule="/accounts/", method=["GET", "POST"]) + def foo(account_id): + assert router.current_event.raw_event == event + assert router.lambda_context == lambda_context + assert account_id == f"{req}" + return {} + + app.include_router(router) + # WHEN calling the event handler after applying routes from router object + result = app(event, lambda_context) + + # THEN process event correctly + assert result["statusCode"] == 200 + assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + + +def test_api_gateway_app_router_with_prefix(): + # GIVEN a Router with registered routes + # AND a prefix is defined during the registration + app = ApiGatewayResolver() + router = Router() + + @router.get(rule="/path") + def foo(): + return {} + + app.include_router(router, prefix="/my") + # WHEN calling the event handler after applying routes from router object + result = app(LOAD_GW_EVENT, {}) + + # THEN process event correctly + assert result["statusCode"] == 200 + assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + + +def test_api_gateway_app_router_with_prefix_equals_path(): + # GIVEN a Router with registered routes + # AND a prefix is defined during the registration + app = ApiGatewayResolver() + router = Router() + + @router.get(rule="/") + def foo(): + return {} + + app.include_router(router, prefix="/my/path") + # WHEN calling the event handler after applying routes from router object + # WITH the request path matching the registration prefix + result = app(LOAD_GW_EVENT, {}) + + # THEN process event correctly + assert result["statusCode"] == 200 + assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + + +def test_api_gateway_app_router_with_different_methods(): + # GIVEN a Router with all the possible HTTP methods + app = ApiGatewayResolver() + router = Router() + + @router.get("/not_matching_get") + def get_func(): + raise RuntimeError() + + @router.post("/no_matching_post") + def post_func(): + raise RuntimeError() + + @router.put("/no_matching_put") + def put_func(): + raise RuntimeError() + + @router.delete("/no_matching_delete") + def delete_func(): + raise RuntimeError() + + @router.patch("/no_matching_patch") + def patch_func(): + raise RuntimeError() + + app.include_router(router) + + # Also check check the route configurations + routes = app._routes + assert len(routes) == 5 + for route in routes: + if route.func == get_func: + assert route.method == "GET" + elif route.func == post_func: + assert route.method == "POST" + elif route.func == put_func: + assert route.method == "PUT" + elif route.func == delete_func: + assert route.method == "DELETE" + elif route.func == patch_func: + assert route.method == "PATCH" + + # WHEN calling the handler + # THEN return a 404 + result = app(LOAD_GW_EVENT, None) + assert result["statusCode"] == 404 + # AND cors headers are not returned + assert "Access-Control-Allow-Origin" not in result["headers"] + + +def test_duplicate_routes(): + # GIVEN a duplicate routes + app = ApiGatewayResolver() + router = Router() + + @router.get("/my/path") + def get_func_duplicate(): + raise RuntimeError() + + @app.get("/my/path") + def get_func(): + return {} + + @router.get("/my/path") + def get_func_another_duplicate(): + raise RuntimeError() + + app.include_router(router) + + # WHEN calling the handler + result = app(LOAD_GW_EVENT, None) + + # THEN only execute the first registered route + # AND print warnings + assert result["statusCode"] == 200 diff --git a/tests/functional/event_handler/test_appsync.py b/tests/functional/event_handler/test_appsync.py index 26a3ffdcb1f..79173e55825 100644 --- a/tests/functional/event_handler/test_appsync.py +++ b/tests/functional/event_handler/test_appsync.py @@ -4,6 +4,7 @@ import pytest from aws_lambda_powertools.event_handler import AppSyncResolver +from aws_lambda_powertools.event_handler.appsync import Router from aws_lambda_powertools.utilities.data_classes import AppSyncResolverEvent from aws_lambda_powertools.utilities.typing import LambdaContext from tests.functional.utils import load_event @@ -161,3 +162,29 @@ def create_something(id: str): # noqa AA03 VNE003 assert result == "my identifier" assert app.current_event.country_viewer == "US" + + +def test_resolver_include_resolver(): + # GIVEN + app = AppSyncResolver() + router = Router() + + @router.resolver(type_name="Query", field_name="listLocations") + def get_locations(name: str): + return "get_locations#" + name + + @app.resolver(field_name="listLocations2") + def get_locations2(name: str): + return "get_locations2#" + name + + app.include_router(router) + + # WHEN + mock_event1 = {"typeName": "Query", "fieldName": "listLocations", "arguments": {"name": "value"}} + mock_event2 = {"typeName": "Query", "fieldName": "listLocations2", "arguments": {"name": "value"}} + result1 = app.resolve(mock_event1, LambdaContext()) + result2 = app.resolve(mock_event2, LambdaContext()) + + # THEN + assert result1 == "get_locations#value" + assert result2 == "get_locations2#value" diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index b1d0914d181..043fb06a04a 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -783,11 +783,11 @@ def test_jmespath_with_powertools_json( # GIVEN an event_key_jmespath with powertools_json custom function persistence_store.configure(idempotency_config) sub_attr_value = "cognito_user" - key_attr_value = "some_key" - expected_value = [sub_attr_value, key_attr_value] + static_pk_value = "some_key" + expected_value = [sub_attr_value, static_pk_value] api_gateway_proxy_event = { "requestContext": {"authorizer": {"claims": {"sub": sub_attr_value}}}, - "body": serialize({"id": key_attr_value}), + "body": serialize({"id": static_pk_value}), } # WHEN calling _get_hashed_idempotency_key diff --git a/tests/functional/parser/test_apigw.py b/tests/functional/parser/test_apigw.py index d657a0dbe4d..35b2fdb1926 100644 --- a/tests/functional/parser/test_apigw.py +++ b/tests/functional/parser/test_apigw.py @@ -1,7 +1,7 @@ import pytest from pydantic import ValidationError -from aws_lambda_powertools.utilities.parser import envelopes, event_parser +from aws_lambda_powertools.utilities.parser import envelopes, event_parser, parse from aws_lambda_powertools.utilities.parser.models import APIGatewayProxyEventModel from aws_lambda_powertools.utilities.typing import LambdaContext from tests.functional.parser.schemas import MyApiGatewayBusiness @@ -144,3 +144,9 @@ def test_apigw_event_with_invalid_websocket_request(): expected_msg = "messageId is available only when the `eventType` is `MESSAGE`" assert errors[0]["msg"] == expected_msg assert expected_msg in str(err.value) + + +def test_apigw_event_empty_body(): + event = load_event("apiGatewayProxyEvent.json") + event["body"] = None + parse(event=event, model=APIGatewayProxyEventModel) diff --git a/tests/functional/parser/test_apigwv2.py b/tests/functional/parser/test_apigwv2.py index ee6a4790cd4..d3510b185dd 100644 --- a/tests/functional/parser/test_apigwv2.py +++ b/tests/functional/parser/test_apigwv2.py @@ -1,4 +1,4 @@ -from aws_lambda_powertools.utilities.parser import envelopes, event_parser +from aws_lambda_powertools.utilities.parser import envelopes, event_parser, parse from aws_lambda_powertools.utilities.parser.models import ( APIGatewayProxyEventV2Model, RequestContextV2, @@ -90,3 +90,16 @@ def test_api_gateway_proxy_v2_event_iam_authorizer(): assert iam.principalOrgId == "AwsOrgId" assert iam.userArn == "arn:aws:iam::1234567890:user/Admin" assert iam.userId == "AROA2ZJZYVRE7Y3TUXHH6" + + +def test_apigw_event_empty_body(): + event = load_event("apiGatewayProxyV2Event.json") + event.pop("body") # API GW v2 removes certain keys when no data is passed + parse(event=event, model=APIGatewayProxyEventV2Model) + + +def test_apigw_event_empty_query_strings(): + event = load_event("apiGatewayProxyV2Event.json") + event["rawQueryString"] = "" + event.pop("queryStringParameters") # API GW v2 removes certain keys when no data is passed + parse(event=event, model=APIGatewayProxyEventV2Model) diff --git a/tests/functional/test_logger.py b/tests/functional/test_logger.py index a8d92c05257..3c9a8a54189 100644 --- a/tests/functional/test_logger.py +++ b/tests/functional/test_logger.py @@ -537,11 +537,11 @@ def format(self, record: logging.LogRecord) -> str: # noqa: A003 logger = Logger(service=service_name, stream=stdout, logger_formatter=custom_formatter) # WHEN a lambda function is decorated with logger - @logger.inject_lambda_context + @logger.inject_lambda_context(correlation_id_path="foo") def handler(event, context): logger.info("Hello") - handler({}, lambda_context) + handler({"foo": "value"}, lambda_context) lambda_context_keys = ( "function_name", @@ -554,8 +554,11 @@ def handler(event, context): # THEN custom key should always be present # and lambda contextual info should also be in the logs + # and get_correlation_id should return None assert "my_default_key" in log assert all(k in log for k in lambda_context_keys) + assert log["correlation_id"] == "value" + assert logger.get_correlation_id() is None def test_logger_custom_handler(lambda_context, service_name, tmp_path):