From 719c9eb231e4d7678f7d5d12d835e8be7d16f647 Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Mon, 29 Aug 2022 17:56:24 +0200 Subject: [PATCH 01/30] feat(event_handler): improved support for headers and cookies in v2 (#1455) Co-authored-by: Heitor Lessa --- .gitignore | 3 + aws_lambda_powertools/__init__.py | 6 +- .../event_handler/api_gateway.py | 23 +- .../shared/headers_serializer.py | 111 +++ .../utilities/data_classes/alb_event.py | 16 +- .../data_classes/api_gateway_proxy_event.py | 11 + .../utilities/data_classes/common.py | 5 + docs/core/event_handler/api_gateway.md | 9 +- docs/upgrade.md | 57 ++ .../src/binary_responses_output.json | 4 +- .../src/compressing_responses_output.json | 6 +- .../src/fine_grained_responses.py | 3 +- .../src/fine_grained_responses_output.json | 7 +- ...ting_started_rest_api_resolver_output.json | 4 +- .../src/setting_cors_output.json | 8 +- mkdocs.yml | 1 + poetry.lock | 860 +++++++++--------- pyproject.toml | 38 +- tests/e2e/event_handler/__init__.py | 0 tests/e2e/event_handler/conftest.py | 28 + .../e2e/event_handler/handlers/alb_handler.py | 18 + .../handlers/api_gateway_http_handler.py | 18 + .../handlers/api_gateway_rest_handler.py | 18 + .../handlers/lambda_function_url_handler.py | 18 + tests/e2e/event_handler/infrastructure.py | 81 ++ .../event_handler/test_header_serializer.py | 141 +++ tests/e2e/utils/data_fetcher/__init__.py | 2 +- tests/e2e/utils/data_fetcher/common.py | 12 + tests/e2e/utils/infrastructure.py | 19 +- tests/events/albMultiValueHeadersEvent.json | 35 + .../event_handler/test_api_gateway.py | 170 ++-- .../event_handler/test_lambda_function_url.py | 21 + tests/functional/test_headers_serializer.py | 147 +++ 33 files changed, 1338 insertions(+), 562 deletions(-) create mode 100644 aws_lambda_powertools/shared/headers_serializer.py create mode 100644 docs/upgrade.md create mode 100644 tests/e2e/event_handler/__init__.py create mode 100644 tests/e2e/event_handler/conftest.py create mode 100644 tests/e2e/event_handler/handlers/alb_handler.py create mode 100644 tests/e2e/event_handler/handlers/api_gateway_http_handler.py create mode 100644 tests/e2e/event_handler/handlers/api_gateway_rest_handler.py create mode 100644 tests/e2e/event_handler/handlers/lambda_function_url_handler.py create mode 100644 tests/e2e/event_handler/infrastructure.py create mode 100644 tests/e2e/event_handler/test_header_serializer.py create mode 100644 tests/events/albMultiValueHeadersEvent.json create mode 100644 tests/functional/test_headers_serializer.py diff --git a/.gitignore b/.gitignore index b776e1999c2..cc01240a405 100644 --- a/.gitignore +++ b/.gitignore @@ -305,5 +305,8 @@ site/ !404.html !docs/overrides/*.html +# CDK +.cdk + !.github/workflows/lib examples/**/sam/.aws-sam diff --git a/aws_lambda_powertools/__init__.py b/aws_lambda_powertools/__init__.py index 65b5eb86730..750ae92c4d1 100644 --- a/aws_lambda_powertools/__init__.py +++ b/aws_lambda_powertools/__init__.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- -"""Top-level package for Lambda Python Powertools.""" - +from pathlib import Path +"""Top-level package for Lambda Python Powertools.""" from .logging import Logger # noqa: F401 from .metrics import Metrics, single_metric # noqa: F401 from .package_logger import set_package_logger_handler @@ -10,4 +10,6 @@ __author__ = """Amazon Web Services""" +PACKAGE_PATH = Path(__file__).parent + set_package_logger_handler() diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 0e7b5a87838..cf9963c6b1d 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -136,10 +136,11 @@ def __init__( def to_dict(self) -> Dict[str, str]: """Builds the configured Access-Control http headers""" - headers = { + headers: Dict[str, str] = { "Access-Control-Allow-Origin": self.allow_origin, "Access-Control-Allow-Headers": ",".join(sorted(self.allow_headers)), } + if self.expose_headers: headers["Access-Control-Expose-Headers"] = ",".join(self.expose_headers) if self.max_age is not None: @@ -157,7 +158,8 @@ def __init__( status_code: int, content_type: Optional[str] = None, body: Union[str, bytes, None] = None, - headers: Optional[Dict] = None, + headers: Optional[Dict[str, Union[str, List[str]]]] = None, + cookies: Optional[List[str]] = None, ): """ @@ -170,13 +172,16 @@ def __init__( provided http headers body: Union[str, bytes, None] Optionally set the response body. Note: bytes body will be automatically base64 encoded - headers: dict - Optionally set specific http headers. Setting "Content-Type" hear would override the `content_type` value. + headers: dict[str, Union[str, List[str]]] + Optionally set specific http headers. Setting "Content-Type" here would override the `content_type` value. + cookies: list[str] + Optionally set cookies. """ self.status_code = status_code self.body = body self.base64_encoded = False - self.headers: Dict = headers or {} + self.headers: Dict[str, Union[str, List[str]]] = headers if headers else {} + self.cookies = cookies or [] if content_type: self.headers.setdefault("Content-Type", content_type) @@ -208,7 +213,8 @@ def _add_cors(self, cors: CORSConfig): def _add_cache_control(self, cache_control: str): """Set the specified cache control headers for 200 http responses. For non-200 `no-cache` is used.""" - self.response.headers["Cache-Control"] = cache_control if self.response.status_code == 200 else "no-cache" + cache_control = cache_control if self.response.status_code == 200 else "no-cache" + self.response.headers["Cache-Control"] = cache_control def _compress(self): """Compress the response body, but only if `Accept-Encoding` headers includes gzip.""" @@ -238,11 +244,12 @@ def build(self, event: BaseProxyEvent, cors: Optional[CORSConfig] = None) -> Dic logger.debug("Encoding bytes response with base64") self.response.base64_encoded = True self.response.body = base64.b64encode(self.response.body).decode() + return { "statusCode": self.response.status_code, - "headers": self.response.headers, "body": self.response.body, "isBase64Encoded": self.response.base64_encoded, + **event.header_serializer().serialize(headers=self.response.headers, cookies=self.response.cookies), } @@ -638,7 +645,7 @@ def _path_starts_with(path: str, prefix: str): def _not_found(self, method: str) -> ResponseBuilder: """Called when no matching route was found and includes support for the cors preflight response""" - headers = {} + headers: Dict[str, Union[str, List[str]]] = {} if self._cors: logger.debug("CORS is enabled, updating headers.") headers.update(self._cors.to_dict()) diff --git a/aws_lambda_powertools/shared/headers_serializer.py b/aws_lambda_powertools/shared/headers_serializer.py new file mode 100644 index 00000000000..4db7effe81b --- /dev/null +++ b/aws_lambda_powertools/shared/headers_serializer.py @@ -0,0 +1,111 @@ +import warnings +from collections import defaultdict +from typing import Any, Dict, List, Union + + +class BaseHeadersSerializer: + """ + Helper class to correctly serialize headers and cookies for Amazon API Gateway, + ALB and Lambda Function URL response payload. + """ + + def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str]) -> Dict[str, Any]: + """ + Serializes headers and cookies according to the request type. + Returns a dict that can be merged with the response payload. + + Parameters + ---------- + headers: Dict[str, List[str]] + A dictionary of headers to set in the response + cookies: List[str] + A list of cookies to set in the response + """ + raise NotImplementedError() + + +class HttpApiHeadersSerializer(BaseHeadersSerializer): + def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str]) -> Dict[str, Any]: + """ + When using HTTP APIs or LambdaFunctionURLs, everything is taken care automatically for us. + We can directly assign a list of cookies and a dict of headers to the response payload, and the + runtime will automatically serialize them correctly on the output. + + https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.proxy-format + https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.response + """ + + # Format 2.0 doesn't have multiValueHeaders or multiValueQueryStringParameters fields. + # Duplicate headers are combined with commas and included in the headers field. + combined_headers: Dict[str, str] = {} + for key, values in headers.items(): + if isinstance(values, str): + combined_headers[key] = values + else: + combined_headers[key] = ", ".join(values) + + return {"headers": combined_headers, "cookies": cookies} + + +class MultiValueHeadersSerializer(BaseHeadersSerializer): + def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str]) -> Dict[str, Any]: + """ + When using REST APIs, headers can be encoded using the `multiValueHeaders` key on the response. + This is also the case when using an ALB integration with the `multiValueHeaders` option enabled. + The solution covers headers with just one key or multiple keys. + + https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-output-format + https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html#multi-value-headers-response + """ + payload: Dict[str, List[str]] = defaultdict(list) + + for key, values in headers.items(): + if isinstance(values, str): + payload[key].append(values) + else: + for value in values: + payload[key].append(value) + + if cookies: + payload.setdefault("Set-Cookie", []) + for cookie in cookies: + payload["Set-Cookie"].append(cookie) + + return {"multiValueHeaders": payload} + + +class SingleValueHeadersSerializer(BaseHeadersSerializer): + def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str]) -> Dict[str, Any]: + """ + The ALB integration has `multiValueHeaders` disabled by default. + If we try to set multiple headers with the same key, or more than one cookie, print a warning. + + https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html#respond-to-load-balancer + """ + payload: Dict[str, Dict[str, str]] = {} + payload.setdefault("headers", {}) + + if cookies: + if len(cookies) > 1: + warnings.warn( + "Can't encode more than one cookie in the response. Sending the last cookie only. " + "Did you enable multiValueHeaders on the ALB Target Group?" + ) + + # We can only send one cookie, send the last one + payload["headers"]["Set-Cookie"] = cookies[-1] + + for key, values in headers.items(): + if isinstance(values, str): + payload["headers"][key] = values + else: + if len(values) > 1: + warnings.warn( + f"Can't encode more than one header value for the same key ('{key}') in the response. " + "Did you enable multiValueHeaders on the ALB Target Group?" + ) + + # We can only set one header per key, send the last one + payload["headers"][key] = values[-1] + + return payload diff --git a/aws_lambda_powertools/utilities/data_classes/alb_event.py b/aws_lambda_powertools/utilities/data_classes/alb_event.py index c28ec0d72e2..1bd49fd05b6 100644 --- a/aws_lambda_powertools/utilities/data_classes/alb_event.py +++ b/aws_lambda_powertools/utilities/data_classes/alb_event.py @@ -1,9 +1,11 @@ from typing import Dict, List, Optional -from aws_lambda_powertools.utilities.data_classes.common import ( - BaseProxyEvent, - DictWrapper, +from aws_lambda_powertools.shared.headers_serializer import ( + BaseHeadersSerializer, + MultiValueHeadersSerializer, + SingleValueHeadersSerializer, ) +from aws_lambda_powertools.utilities.data_classes.common import BaseProxyEvent, DictWrapper class ALBEventRequestContext(DictWrapper): @@ -33,3 +35,11 @@ def multi_value_query_string_parameters(self) -> Optional[Dict[str, List[str]]]: @property def multi_value_headers(self) -> Optional[Dict[str, List[str]]]: return self.get("multiValueHeaders") + + def header_serializer(self) -> BaseHeadersSerializer: + # When using the ALB integration, the `multiValueHeaders` feature can be disabled (default) or enabled. + # We can determine if the feature is enabled by looking if the event has a `multiValueHeaders` key. + if self.multi_value_headers: + return MultiValueHeadersSerializer() + + return SingleValueHeadersSerializer() diff --git a/aws_lambda_powertools/utilities/data_classes/api_gateway_proxy_event.py b/aws_lambda_powertools/utilities/data_classes/api_gateway_proxy_event.py index be374aba398..030d9739fa4 100644 --- a/aws_lambda_powertools/utilities/data_classes/api_gateway_proxy_event.py +++ b/aws_lambda_powertools/utilities/data_classes/api_gateway_proxy_event.py @@ -1,5 +1,10 @@ from typing import Any, Dict, List, Optional +from aws_lambda_powertools.shared.headers_serializer import ( + BaseHeadersSerializer, + HttpApiHeadersSerializer, + MultiValueHeadersSerializer, +) from aws_lambda_powertools.utilities.data_classes.common import ( BaseProxyEvent, BaseRequestContext, @@ -106,6 +111,9 @@ def path_parameters(self) -> Optional[Dict[str, str]]: def stage_variables(self) -> Optional[Dict[str, str]]: return self.get("stageVariables") + def header_serializer(self) -> BaseHeadersSerializer: + return MultiValueHeadersSerializer() + class RequestContextV2AuthorizerIam(DictWrapper): @property @@ -250,3 +258,6 @@ def path(self) -> str: def http_method(self) -> str: """The HTTP method used. Valid values include: DELETE, GET, HEAD, OPTIONS, PATCH, POST, and PUT.""" return self.request_context.http.method + + def header_serializer(self): + return HttpApiHeadersSerializer() diff --git a/aws_lambda_powertools/utilities/data_classes/common.py b/aws_lambda_powertools/utilities/data_classes/common.py index 1b671489cdd..fa0d479af8a 100644 --- a/aws_lambda_powertools/utilities/data_classes/common.py +++ b/aws_lambda_powertools/utilities/data_classes/common.py @@ -3,6 +3,8 @@ from collections.abc import Mapping from typing import Any, Dict, Iterator, Optional +from aws_lambda_powertools.shared.headers_serializer import BaseHeadersSerializer + class DictWrapper(Mapping): """Provides a single read only access to a wrapper dict""" @@ -134,6 +136,9 @@ def get_header_value( """ return get_header_value(self.headers, name, default_value, case_sensitive) + def header_serializer(self) -> BaseHeadersSerializer: + raise NotImplementedError() + class RequestContextClientCert(DictWrapper): @property diff --git a/docs/core/event_handler/api_gateway.md b/docs/core/event_handler/api_gateway.md index 10aaa9faeb9..8ee07890b48 100644 --- a/docs/core/event_handler/api_gateway.md +++ b/docs/core/event_handler/api_gateway.md @@ -312,7 +312,14 @@ For convenience, these are the default values when using `CORSConfig` to enable ### Fine grained responses -You can use the `Response` class to have full control over the response, for example you might want to add additional headers or set a custom Content-type. +You can use the `Response` class to have full control over the response. For example, you might want to add additional headers, cookies, or set a custom Content-type. + +???+ info + Powertools serializes headers and cookies according to the type of input event. + Some event sources require headers and cookies to be encoded as `multiValueHeaders`. + +???+ warning "Using multiple values for HTTP headers in ALB?" + Make sure you [enable the multi value headers feature](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html#multi-value-headers) to serialize response headers correctly. === "fine_grained_responses.py" diff --git a/docs/upgrade.md b/docs/upgrade.md new file mode 100644 index 00000000000..91ad54e42d3 --- /dev/null +++ b/docs/upgrade.md @@ -0,0 +1,57 @@ +--- +title: Upgrade guide +description: Guide to update between major Powertools versions +--- + + + +## Migrate to v2 from v1 + +The transition from Powertools for Python v1 to v2 is as painless as possible, as we aimed for minimal breaking changes. +Changes at a glance: + +* The API for **event handler's `Response`** has minor changes to support multi value headers and cookies. + +???+ important + Powertools for Python v2 drops suport for Python 3.6, following the Python 3.6 End-Of-Life (EOL) reached on December 23, 2021. + +### Initial Steps + +Before you start, we suggest making a copy of your current working project or create a new branch with git. + +1. **Upgrade** Python to at least v3.7 + +2. **Ensure** you have the latest `aws-lambda-powertools` + + ```bash + pip install aws-lambda-powertools -U + ``` + +3. **Review** the following sections to confirm whether they affect your code + +## Event Handler Response (headers and cookies) + +The `Response` class of the event handler utility changed slightly: + +1. The `headers` parameter now expects either a value or list of values per header (type `Union[str, Dict[str, List[str]]]`) +2. We introduced a new `cookies` parameter (type `List[str]`) + +???+ note + Code that set headers as `Dict[str, str]` will still work unchanged. + +```python hl_lines="6 12 13" +@app.get("/todos") +def get_todos(): + # Before + return Response( + # ... + headers={"Content-Type": "text/plain"} + ) + + # After + return Response( + # ... + headers={"Content-Type": ["text/plain"]}, + cookies=["CookieName=CookieValue"] + ) +``` diff --git a/examples/event_handler_rest/src/binary_responses_output.json b/examples/event_handler_rest/src/binary_responses_output.json index 0938dee6811..ec59d251732 100644 --- a/examples/event_handler_rest/src/binary_responses_output.json +++ b/examples/event_handler_rest/src/binary_responses_output.json @@ -1,7 +1,7 @@ { "body": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMjU2cHgiIGhlaWdodD0iMjU2cHgiIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaWRZTWlkIj4KICAgIDx0aXRsZT5BV1MgTGFtYmRhPC90aXRsZT4KICAgIDxkZWZzPgogICAgICAgIDxsaW5lYXJHcmFkaWVudCB4MT0iMCUiIHkxPSIxMDAlIiB4Mj0iMTAwJSIgeTI9IjAlIiBpZD0ibGluZWFyR3JhZGllbnQtMSI+CiAgICAgICAgICAgIDxzdG9wIHN0b3AtY29sb3I9IiNDODUxMUIiIG9mZnNldD0iMCUiPjwvc3RvcD4KICAgICAgICAgICAgPHN0b3Agc3RvcC1jb2xvcj0iI0ZGOTkwMCIgb2Zmc2V0PSIxMDAlIj48L3N0b3A+CiAgICAgICAgPC9saW5lYXJHcmFkaWVudD4KICAgIDwvZGVmcz4KICAgIDxnPgogICAgICAgIDxyZWN0IGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQtMSkiIHg9IjAiIHk9IjAiIHdpZHRoPSIyNTYiIGhlaWdodD0iMjU2Ij48L3JlY3Q+CiAgICAgICAgPHBhdGggZD0iTTg5LjYyNDExMjYsMjExLjIgTDQ5Ljg5MDMyNzcsMjExLjIgTDkzLjgzNTQ4MzIsMTE5LjM0NzIgTDExMy43NDcyOCwxNjAuMzM5MiBMODkuNjI0MTEyNiwyMTEuMiBaIE05Ni43MDI5MzU3LDExMC41Njk2IEM5Ni4xNjQwODU4LDEwOS40NjU2IDk1LjA0MTQ4MTMsMTA4Ljc2NDggOTMuODE2MjM4NCwxMDguNzY0OCBMOTMuODA2NjE2MywxMDguNzY0OCBDOTIuNTcxNzUxNCwxMDguNzY4IDkxLjQ0OTE0NjYsMTA5LjQ3NTIgOTAuOTE5OTE4NywxMTAuNTg1NiBMNDEuOTEzNDIwOCwyMTMuMDIwOCBDNDEuNDM4NzE5NywyMTQuMDEyOCA0MS41MDYwNzU4LDIxNS4xNzc2IDQyLjA5NjI0NTEsMjE2LjEwODggQzQyLjY3OTk5OTQsMjE3LjAzNjggNDMuNzA2MzgwNSwyMTcuNiA0NC44MDY1MzMxLDIxNy42IEw5MS42NTQ0MjMsMjE3LjYgQzkyLjg5NTcwMjcsMjE3LjYgOTQuMDIxNTE0OSwyMTYuODg2NCA5NC41NTM5NTAxLDIxNS43Njk2IEwxMjAuMjAzODU5LDE2MS42ODk2IEMxMjAuNjE3NjE5LDE2MC44MTI4IDEyMC42MTQ0MTIsMTU5Ljc5ODQgMTIwLjE4NzgyMiwxNTguOTI4IEw5Ni43MDI5MzU3LDExMC41Njk2IFogTTIwNy45ODUxMTcsMjExLjIgTDE2OC41MDc5MjgsMjExLjIgTDEwNS4xNzM3ODksNzguNjI0IEMxMDQuNjQ0NTYxLDc3LjUxMDQgMTAzLjUxNTU0MSw3Ni44IDEwMi4yNzc0NjksNzYuOCBMNzYuNDQ3OTQzLDc2LjggTDc2LjQ3NjgwOTksNDQuOCBMMTI3LjEwMzA2Niw0NC44IEwxOTAuMTQ1MzI4LDE3Ny4zNzI4IEMxOTAuNjc0NTU2LDE3OC40ODY0IDE5MS44MDM1NzUsMTc5LjIgMTkzLjA0MTY0NywxNzkuMiBMMjA3Ljk4NTExNywxNzkuMiBMMjA3Ljk4NTExNywyMTEuMiBaIE0yMTEuMTkyNTU4LDE3Mi44IEwxOTUuMDcxOTU4LDE3Mi44IEwxMzIuMDI5Njk2LDQwLjIyNzIgQzEzMS41MDA0NjgsMzkuMTEzNiAxMzAuMzcxNDQ5LDM4LjQgMTI5LjEzMDE2OSwzOC40IEw3My4yNzI1NzYsMzguNCBDNzEuNTA1Mjc1OCwzOC40IDcwLjA2ODM0MjEsMzkuODMwNCA3MC4wNjUxMzQ0LDQxLjU5NjggTDcwLjAyOTg1MjgsNzkuOTk2OCBDNzAuMDI5ODUyOCw4MC44NDggNzAuMzYzNDI2Niw4MS42NjA4IDcwLjk2OTYzMyw4Mi4yNjI0IEM3MS41Njk0MjQ2LDgyLjg2NCA3Mi4zODQxMTQ2LDgzLjIgNzMuMjM3Mjk0MSw4My4yIEwxMDAuMjUzNTczLDgzLjIgTDE2My41OTA5MiwyMTUuNzc2IEMxNjQuMTIzMzU1LDIxNi44ODk2IDE2NS4yNDU5NiwyMTcuNiAxNjYuNDg0MDMyLDIxNy42IEwyMTEuMTkyNTU4LDIxNy42IEMyMTIuOTY2Mjc0LDIxNy42IDIxNC40LDIxNi4xNjY0IDIxNC40LDIxNC40IEwyMTQuNCwxNzYgQzIxNC40LDE3NC4yMzM2IDIxMi45NjYyNzQsMTcyLjggMjExLjE5MjU1OCwxNzIuOCBMMjExLjE5MjU1OCwxNzIuOCBaIiBmaWxsPSIjRkZGRkZGIj48L3BhdGg+CiAgICA8L2c+Cjwvc3ZnPg==", - "headers": { - "Content-Type": "image/svg+xml" + "multiValueHeaders": { + "Content-Type": ["image/svg+xml"] }, "isBase64Encoded": true, "statusCode": 200 diff --git a/examples/event_handler_rest/src/compressing_responses_output.json b/examples/event_handler_rest/src/compressing_responses_output.json index 0836b3aa726..60a63966494 100644 --- a/examples/event_handler_rest/src/compressing_responses_output.json +++ b/examples/event_handler_rest/src/compressing_responses_output.json @@ -1,8 +1,8 @@ { "statusCode": 200, - "headers": { - "Content-Type": "application/json", - "Content-Encoding": "gzip" + "multiValueHeaders": { + "Content-Type": ["application/json"], + "Content-Encoding": ["gzip"] }, "body": "H4sIAAAAAAACE42STU4DMQyFrxJl3QXln96AMyAW7sSDLCVxiJ0Kqerd8TCCUOgii1EmP/783pOPXjmw+N3L0TfB+hz8brvxtC5KGtHvfMCIkzZx0HT5MPmNnziViIr2dIYoeNr8Q1x3xHsjcVadIbkZJoq2RXU8zzQROLseQ9505NzeCNQdMJNBE+UmY4zbzjAJhWtlZ57sB84BWtul+rteH2HPlVgWARwjqXkxpklK5gmEHAQqJBMtFsGVygcKmNVRjG0wxvuzGF2L0dpVUOKMC3bfJNjJgWMrCuZk7cUp02AiD72D6WKHHwUDKbiJs6AZ0VZXKOUx4uNvzdxT+E4mLcMA+6G8nzrLQkaxkNEVrFKW2VGbJCoCY7q2V3+tiv5kGThyxfTecDWbgGz/NfYXhL6ePgF9PnFdPgMAAA==", "isBase64Encoded": true diff --git a/examples/event_handler_rest/src/fine_grained_responses.py b/examples/event_handler_rest/src/fine_grained_responses.py index 15c70cd282b..051797f2477 100644 --- a/examples/event_handler_rest/src/fine_grained_responses.py +++ b/examples/event_handler_rest/src/fine_grained_responses.py @@ -23,13 +23,14 @@ def get_todos(): todos: requests.Response = requests.get("https://jsonplaceholder.typicode.com/todos") todos.raise_for_status() - custom_headers = {"X-Transaction-Id": f"{uuid4()}"} + custom_headers = {"X-Transaction-Id": [f"{uuid4()}"]} return Response( status_code=HTTPStatus.OK.value, # 200 content_type=content_types.APPLICATION_JSON, body=todos.json()[:10], headers=custom_headers, + cookies=["=; Secure; Expires="], ) diff --git a/examples/event_handler_rest/src/fine_grained_responses_output.json b/examples/event_handler_rest/src/fine_grained_responses_output.json index c3d58098e80..1ce606839b1 100644 --- a/examples/event_handler_rest/src/fine_grained_responses_output.json +++ b/examples/event_handler_rest/src/fine_grained_responses_output.json @@ -1,8 +1,9 @@ { "statusCode": 200, - "headers": { - "Content-Type": "application/json", - "X-Transaction-Id": "3490eea9-791b-47a0-91a4-326317db61a9" + "multiValueHeaders": { + "Content-Type": ["application/json"], + "X-Transaction-Id": ["3490eea9-791b-47a0-91a4-326317db61a9"], + "Set-Cookie": ["=; Secure; Expires="] }, "body": "{\"todos\":[{\"userId\":1,\"id\":1,\"title\":\"delectus aut autem\",\"completed\":false},{\"userId\":1,\"id\":2,\"title\":\"quis ut nam facilis et officia qui\",\"completed\":false},{\"userId\":1,\"id\":3,\"title\":\"fugiat veniam minus\",\"completed\":false},{\"userId\":1,\"id\":4,\"title\":\"et porro tempora\",\"completed\":true},{\"userId\":1,\"id\":5,\"title\":\"laboriosam mollitia et enim quasi adipisci quia provident illum\",\"completed\":false},{\"userId\":1,\"id\":6,\"title\":\"qui ullam ratione quibusdam voluptatem quia omnis\",\"completed\":false},{\"userId\":1,\"id\":7,\"title\":\"illo expedita consequatur quia in\",\"completed\":false},{\"userId\":1,\"id\":8,\"title\":\"quo adipisci enim quam ut ab\",\"completed\":true},{\"userId\":1,\"id\":9,\"title\":\"molestiae perspiciatis ipsa\",\"completed\":false},{\"userId\":1,\"id\":10,\"title\":\"illo est ratione doloremque quia maiores aut\",\"completed\":true}]}", "isBase64Encoded": false diff --git a/examples/event_handler_rest/src/getting_started_rest_api_resolver_output.json b/examples/event_handler_rest/src/getting_started_rest_api_resolver_output.json index 2ef3714531f..24d2b5c6dbc 100644 --- a/examples/event_handler_rest/src/getting_started_rest_api_resolver_output.json +++ b/examples/event_handler_rest/src/getting_started_rest_api_resolver_output.json @@ -1,7 +1,7 @@ { "statusCode": 200, - "headers": { - "Content-Type": "application/json" + "multiValueHeaders": { + "Content-Type": ["application/json"] }, "body": "{\"todos\":[{\"userId\":1,\"id\":1,\"title\":\"delectus aut autem\",\"completed\":false},{\"userId\":1,\"id\":2,\"title\":\"quis ut nam facilis et officia qui\",\"completed\":false},{\"userId\":1,\"id\":3,\"title\":\"fugiat veniam minus\",\"completed\":false},{\"userId\":1,\"id\":4,\"title\":\"et porro tempora\",\"completed\":true},{\"userId\":1,\"id\":5,\"title\":\"laboriosam mollitia et enim quasi adipisci quia provident illum\",\"completed\":false},{\"userId\":1,\"id\":6,\"title\":\"qui ullam ratione quibusdam voluptatem quia omnis\",\"completed\":false},{\"userId\":1,\"id\":7,\"title\":\"illo expedita consequatur quia in\",\"completed\":false},{\"userId\":1,\"id\":8,\"title\":\"quo adipisci enim quam ut ab\",\"completed\":true},{\"userId\":1,\"id\":9,\"title\":\"molestiae perspiciatis ipsa\",\"completed\":false},{\"userId\":1,\"id\":10,\"title\":\"illo est ratione doloremque quia maiores aut\",\"completed\":true}]}", "isBase64Encoded": false diff --git a/examples/event_handler_rest/src/setting_cors_output.json b/examples/event_handler_rest/src/setting_cors_output.json index ca86e892d38..19660941e91 100644 --- a/examples/event_handler_rest/src/setting_cors_output.json +++ b/examples/event_handler_rest/src/setting_cors_output.json @@ -1,9 +1,9 @@ { "statusCode": 200, - "headers": { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "https://www.example.com", - "Access-Control-Allow-Headers": "Authorization,Content-Type,X-Amz-Date,X-Amz-Security-Token,X-Api-Key" + "multiValueHeaders": { + "Content-Type": ["application/json"], + "Access-Control-Allow-Origin": ["https://www.example.com"], + "Access-Control-Allow-Headers": ["Authorization,Content-Type,X-Amz-Date,X-Amz-Security-Token,X-Api-Key"] }, "body": "{\"todos\":[{\"userId\":1,\"id\":1,\"title\":\"delectus aut autem\",\"completed\":false},{\"userId\":1,\"id\":2,\"title\":\"quis ut nam facilis et officia qui\",\"completed\":false},{\"userId\":1,\"id\":3,\"title\":\"fugiat veniam minus\",\"completed\":false},{\"userId\":1,\"id\":4,\"title\":\"et porro tempora\",\"completed\":true},{\"userId\":1,\"id\":5,\"title\":\"laboriosam mollitia et enim quasi adipisci quia provident illum\",\"completed\":false},{\"userId\":1,\"id\":6,\"title\":\"qui ullam ratione quibusdam voluptatem quia omnis\",\"completed\":false},{\"userId\":1,\"id\":7,\"title\":\"illo expedita consequatur quia in\",\"completed\":false},{\"userId\":1,\"id\":8,\"title\":\"quo adipisci enim quam ut ab\",\"completed\":true},{\"userId\":1,\"id\":9,\"title\":\"molestiae perspiciatis ipsa\",\"completed\":false},{\"userId\":1,\"id\":10,\"title\":\"illo est ratione doloremque quia maiores aut\",\"completed\":true}]}", "isBase64Encoded": false diff --git a/mkdocs.yml b/mkdocs.yml index 171cf36eb13..59fcdfa6a08 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -10,6 +10,7 @@ nav: - Tutorial: tutorial/index.md - Roadmap: roadmap.md - API reference: api/" target="_blank + - Upgrade guide: upgrade.md - Core utilities: - core/tracer.md - core/logger.md diff --git a/poetry.lock b/poetry.lock index 8f13157b12b..7d95f6b9f8e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,37 +1,61 @@ -[[package]] -name = "atomicwrites" -version = "1.4.1" -description = "Atomic file writes." -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - [[package]] name = "attrs" -version = "21.4.0" +version = "22.1.0" description = "Classes Without Boilerplate" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.5" [package.extras] -dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six", "sphinx", "sphinx-notfound-page", "zope.interface"] -docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] -tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six", "zope.interface"] -tests_no_zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "cloudpickle"] [[package]] name = "aws-cdk-lib" -version = "2.23.0" +version = "2.46.0" description = "Version 2 of the AWS Cloud Development Kit library" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = "~=3.7" [package.dependencies] constructs = ">=10.0.0,<11.0.0" -jsii = ">=1.57.0,<2.0.0" +jsii = ">=1.69.0,<2.0.0" publication = ">=0.0.3" +typeguard = ">=2.13.3,<2.14.0" + +[[package]] +name = "aws-cdk.aws-apigatewayv2-alpha" +version = "2.46.0a0" +description = "The CDK Construct Library for AWS::APIGatewayv2" +category = "dev" +optional = false +python-versions = "~=3.7" + +[package.dependencies] +aws-cdk-lib = ">=2.46.0,<3.0.0" +constructs = ">=10.0.0,<11.0.0" +jsii = ">=1.69.0,<2.0.0" +publication = ">=0.0.3" +typeguard = ">=2.13.3,<2.14.0" + +[[package]] +name = "aws-cdk.aws-apigatewayv2-integrations-alpha" +version = "2.46.0a0" +description = "Integrations for AWS APIGateway V2" +category = "dev" +optional = false +python-versions = "~=3.7" + +[package.dependencies] +aws-cdk-lib = ">=2.46.0,<3.0.0" +"aws-cdk.aws-apigatewayv2-alpha" = "2.46.0.a0" +constructs = ">=10.0.0,<11.0.0" +jsii = ">=1.69.0,<2.0.0" +publication = ">=0.0.3" +typeguard = ">=2.13.3,<2.14.0" [[package]] name = "aws-xray-sdk" @@ -47,11 +71,11 @@ wrapt = "*" [[package]] name = "bandit" -version = "1.7.1" +version = "1.7.4" description = "Security oriented static analyser for python code." category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.7" [package.dependencies] colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} @@ -59,17 +83,21 @@ GitPython = ">=1.0.1" PyYAML = ">=5.3.1" stevedore = ">=1.20.0" +[package.extras] +test = ["coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)", "toml", "beautifulsoup4 (>=4.8.0)", "pylint (==1.9.4)"] +toml = ["toml"] +yaml = ["pyyaml"] + [[package]] name = "black" -version = "22.8.0" +version = "22.10.0" description = "The uncompromising code formatter." category = "dev" optional = false -python-versions = ">=3.6.2" +python-versions = ">=3.7" [package.dependencies] click = ">=8.0.0" -dataclasses = {version = ">=0.6", markers = "python_version < \"3.7\""} mypy-extensions = ">=0.4.3" pathspec = ">=0.9.0" platformdirs = ">=2" @@ -85,27 +113,27 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "boto3" -version = "1.23.10" +version = "1.24.90" description = "The AWS SDK for Python" category = "main" optional = false -python-versions = ">= 3.6" +python-versions = ">= 3.7" [package.dependencies] -botocore = ">=1.26.10,<1.27.0" +botocore = ">=1.27.90,<1.28.0" jmespath = ">=0.7.1,<2.0.0" -s3transfer = ">=0.5.0,<0.6.0" +s3transfer = ">=0.6.0,<0.7.0" [package.extras] crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.26.10" +version = "1.27.90" description = "Low-level, data-driven core of boto 3." category = "main" optional = false -python-versions = ">= 3.6" +python-versions = ">= 3.7" [package.dependencies] jmespath = ">=0.7.1,<2.0.0" @@ -113,21 +141,7 @@ python-dateutil = ">=2.1,<3.0.0" urllib3 = ">=1.25.4,<1.27" [package.extras] -crt = ["awscrt (==0.13.8)"] - -[[package]] -name = "cattrs" -version = "1.0.0" -description = "Composable complex class support for attrs." -category = "dev" -optional = false -python-versions = "*" - -[package.dependencies] -attrs = ">=17.3" - -[package.extras] -dev = ["Sphinx", "bumpversion", "coverage", "flake8", "hypothesis", "pendulum", "pytest", "tox", "watchdog", "wheel"] +crt = ["awscrt (==0.14.0)"] [[package]] name = "cattrs" @@ -144,7 +158,7 @@ typing_extensions = {version = "*", markers = "python_version >= \"3.7\" and pyt [[package]] name = "certifi" -version = "2022.6.15.1" +version = "2022.9.24" description = "Python package for providing Mozilla's CA Bundle." category = "dev" optional = false @@ -152,22 +166,22 @@ python-versions = ">=3.6" [[package]] name = "charset-normalizer" -version = "2.0.12" +version = "2.1.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "dev" optional = false -python-versions = ">=3.5.0" +python-versions = ">=3.6.0" [package.extras] unicode_backport = ["unicodedata2"] [[package]] name = "click" -version = "8.0.4" +version = "8.1.3" description = "Composable command line interface toolkit" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} @@ -183,38 +197,31 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "constructs" -version = "10.1.1" +version = "10.1.131" description = "A programming model for software-defined state" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = "~=3.7" [package.dependencies] -jsii = ">=1.57.0,<2.0.0" +jsii = ">=1.69.0,<2.0.0" publication = ">=0.0.3" +typeguard = ">=2.13.3,<2.14.0" [[package]] name = "coverage" -version = "6.2" +version = "6.5.0" description = "Code coverage measurement for Python" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] -tomli = {version = "*", optional = true, markers = "extra == \"toml\""} +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] toml = ["tomli"] -[[package]] -name = "dataclasses" -version = "0.8" -description = "A backport of the dataclasses module for Python 3.6" -category = "main" -optional = false -python-versions = ">=3.6, <3.7" - [[package]] name = "decorator" version = "5.1.1" @@ -232,8 +239,8 @@ optional = true python-versions = ">=3.6,<4.0" [package.extras] -curio = ["curio (>=1.2,<2.0)", "sniffio (>=1.1,<2.0)"] dnssec = ["cryptography (>=2.6,<37.0)"] +curio = ["curio (>=1.2,<2.0)", "sniffio (>=1.1,<2.0)"] doh = ["h2 (>=4.1.0)", "httpx (>=0.21.1)", "requests (>=2.23.0,<3.0.0)", "requests-toolbelt (>=0.9.1,<0.10.0)"] idna = ["idna (>=2.1,<4.0)"] trio = ["trio (>=0.14,<0.20)"] @@ -290,7 +297,7 @@ optional = false python-versions = "*" [package.extras] -devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"] +devel = ["colorama", "jsonschema", "json-spec", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"] [[package]] name = "filelock" @@ -318,6 +325,19 @@ mccabe = ">=0.6.0,<0.7.0" pycodestyle = ">=2.7.0,<2.8.0" pyflakes = ">=2.3.0,<2.4.0" +[[package]] +name = "flake8-black" +version = "0.3.3" +description = "flake8 plugin to call black as a code style validator" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +black = ">=22.1.0" +flake8 = ">=3.0.0" +tomli = "*" + [[package]] name = "flake8-bugbear" version = "22.9.23" @@ -349,42 +369,41 @@ test = ["coverage", "coveralls", "mock", "pytest", "pytest-cov"] [[package]] name = "flake8-comprehensions" -version = "3.7.0" +version = "3.10.0" description = "A flake8 plugin to help you write better list/set/dict comprehensions." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] -flake8 = ">=3.0,<3.2.0 || >3.2.0,<5" +flake8 = ">=3.0,<3.2.0 || >3.2.0" importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [[package]] name = "flake8-debugger" -version = "4.0.0" +version = "4.1.2" description = "ipdb/pdb statement checker plugin for flake8" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] flake8 = ">=3.0" pycodestyle = "*" -six = "*" [[package]] name = "flake8-eradicate" -version = "1.3.0" +version = "1.4.0" description = "Flake8 plugin to find commented out code" category = "dev" optional = false -python-versions = ">=3.6,<4.0" +python-versions = ">=3.7,<4.0" [package.dependencies] attrs = "*" eradicate = ">=2.0,<3.0" flake8 = ">=3.5,<6" -setuptools = "*" +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [[package]] name = "flake8-fixme" @@ -417,9 +436,6 @@ category = "dev" optional = false python-versions = "*" -[package.dependencies] -setuptools = "*" - [[package]] name = "future" version = "0.18.2" @@ -440,7 +456,7 @@ python-versions = "*" python-dateutil = ">=2.8.1" [package.extras] -dev = ["flake8", "markdown", "twine", "wheel"] +dev = ["wheel", "flake8", "markdown", "twine"] [[package]] name = "gitdb" @@ -455,19 +471,19 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.20" -description = "Python Git Library" +version = "3.1.29" +description = "GitPython is a python library used to interact with Git repositories" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] gitdb = ">=4.0.1,<5" -typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.10\""} +typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} [[package]] name = "idna" -version = "3.3" +version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" category = "main" optional = false @@ -475,35 +491,20 @@ python-versions = ">=3.5" [[package]] name = "importlib-metadata" -version = "4.8.3" +version = "5.0.0" description = "Read metadata from Python packages" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] -docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"] +docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "jaraco.tidelift (>=1.4)"] perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pep517", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy", "pytest-perf (>=0.9.2)"] - -[[package]] -name = "importlib-resources" -version = "5.4.0" -description = "Read resources from Python packages" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} - -[package.extras] -docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"] -testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] [[package]] name = "iniconfig" @@ -522,18 +523,18 @@ optional = false python-versions = ">=3.6.1,<4.0" [package.extras] -colors = ["colorama (>=0.4.3,<0.5.0)"] pipfile_deprecated_finder = ["pipreqs", "requirementslib"] +requirements_deprecated_finder = ["pipreqs", "pip-api"] +colors = ["colorama (>=0.4.3,<0.5.0)"] plugins = ["setuptools"] -requirements_deprecated_finder = ["pip-api", "pipreqs"] [[package]] name = "jinja2" -version = "3.0.3" +version = "3.1.2" description = "A very fast and expressive template engine." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] MarkupSafe = ">=2.0" @@ -543,32 +544,30 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "jmespath" -version = "0.10.0" +version = "1.0.1" description = "JSON Matching Expressions" category = "main" optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = ">=3.7" [[package]] name = "jsii" -version = "1.57.0" +version = "1.69.0" description = "Python client for jsii runtime" category = "dev" optional = false -python-versions = "~=3.6" +python-versions = "~=3.7" [package.dependencies] -attrs = ">=21.2,<22.0" -cattrs = [ - {version = ">=1.0.0,<1.1.0", markers = "python_version < \"3.7\""}, - {version = ">=1.8,<22.2", markers = "python_version >= \"3.7\""}, -] -importlib-resources = {version = "*", markers = "python_version < \"3.7\""} +attrs = ">=21.2,<23.0" +cattrs = ">=1.8,<22.2" +publication = ">=0.0.3" python-dateutil = "*" +typeguard = ">=2.13.3,<2.14.0" typing-extensions = ">=3.7,<5.0" [[package]] -name = "Mako" +name = "mako" version = "1.2.3" description = "A super-fast templating language that borrows the best ideas from the existing templating languages." category = "dev" @@ -580,7 +579,7 @@ importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} MarkupSafe = ">=0.9.2" [package.extras] -babel = ["Babel"] +babel = ["babel"] lingua = ["lingua"] testing = ["pytest"] @@ -614,11 +613,11 @@ testing = ["coverage", "pyyaml"] [[package]] name = "markupsafe" -version = "2.0.1" +version = "2.1.1" description = "Safely add untrusted strings to HTML/XML markup." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [[package]] name = "mccabe" @@ -651,27 +650,28 @@ packaging = "*" "ruamel.yaml" = "*" [package.extras] -dev = ["coverage", "flake8 (>=3.0)", "pypandoc (>=1.4)"] -test = ["coverage", "flake8 (>=3.0)"] +test = ["flake8 (>=3.0)", "coverage"] +dev = ["pypandoc (>=1.4)", "flake8 (>=3.0)", "coverage"] [[package]] name = "mkdocs" -version = "1.3.1" +version = "1.4.0" description = "Project documentation with Markdown." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] -click = ">=3.3" +click = ">=7.0" ghp-import = ">=1.0" -importlib-metadata = ">=4.3" -Jinja2 = ">=2.10.2" +importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} +Jinja2 = ">=2.11.1" Markdown = ">=3.2.1,<3.4" mergedeep = ">=1.3.4" packaging = ">=20.5" -PyYAML = ">=3.10" +PyYAML = ">=5.1" pyyaml-env-tag = ">=0.1" +typing-extensions = {version = ">=3.10", markers = "python_version < \"3.8\""} watchdog = ">=2.0" [package.extras] @@ -692,7 +692,7 @@ mkdocs = ">=0.17" [[package]] name = "mkdocs-material" -version = "8.5.4" +version = "8.5.6" description = "Documentation that simply works" category = "dev" optional = false @@ -701,7 +701,7 @@ python-versions = ">=3.7" [package.dependencies] jinja2 = ">=3.0.2" markdown = ">=3.2" -mkdocs = ">=1.3.0" +mkdocs = ">=1.4.0" mkdocs-material-extensions = ">=1.0.3" pygments = ">=2.12" pymdown-extensions = ">=9.4" @@ -824,8 +824,8 @@ typing-extensions = ">=4.1.0" [[package]] name = "mypy-boto3-ssm" -version = "1.24.81" -description = "Type annotations for boto3.SSM 1.24.81 service generated with mypy-boto3-builder 7.11.9" +version = "1.24.90" +description = "Type annotations for boto3.SSM 1.24.90 service generated with mypy-boto3-builder 7.11.10" category = "dev" optional = false python-versions = ">=3.7" @@ -865,11 +865,11 @@ pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" [[package]] name = "pathspec" -version = "0.9.0" +version = "0.10.1" description = "Utility library for gitignore style pattern matching of file paths." category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +python-versions = ">=3.7" [[package]] name = "pbr" @@ -893,15 +893,15 @@ markdown = ">=3.0" [[package]] name = "platformdirs" -version = "2.4.0" +version = "2.5.2" 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" +python-versions = ">=3.7" [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)"] +docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] +test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] [[package]] name = "pluggy" @@ -915,8 +915,8 @@ python-versions = ">=3.6" importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} [package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] +testing = ["pytest-benchmark", "pytest"] +dev = ["tox", "pre-commit"] [[package]] name = "publication" @@ -952,15 +952,14 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pydantic" -version = "1.9.2" +version = "1.10.2" description = "Data validation and settings management using python type hints" category = "main" optional = true -python-versions = ">=3.6.1" +python-versions = ">=3.7" [package.dependencies] -dataclasses = {version = ">=0.6", markers = "python_version < \"3.7\""} -typing-extensions = ">=3.7.4.3" +typing-extensions = ">=4.1.0" [package.extras] dotenv = ["python-dotenv (>=0.10.4)"] @@ -987,7 +986,7 @@ plugins = ["importlib-metadata"] [[package]] name = "pymdown-extensions" -version = "9.5" +version = "9.6" description = "Extension pack for Python Markdown." category = "dev" optional = false @@ -998,25 +997,24 @@ markdown = ">=3.2" [[package]] name = "pyparsing" -version = "3.0.7" -description = "Python parsing module" +version = "3.0.9" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.6.8" [package.extras] -diagrams = ["jinja2", "railroad-diagrams"] +diagrams = ["railroad-diagrams", "jinja2"] [[package]] name = "pytest" -version = "7.0.1" +version = "7.1.3" description = "pytest: simple powerful testing with Python" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] -atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} @@ -1073,7 +1071,7 @@ coverage = {version = ">=5.2.1", extras = ["toml"]} pytest = ">=4.6" [package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] +testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-forked" @@ -1089,17 +1087,17 @@ pytest = ">=3.10" [[package]] name = "pytest-mock" -version = "3.6.1" +version = "3.10.0" description = "Thin-wrapper around the mock package for easier use with pytest" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] pytest = ">=5.0" [package.extras] -dev = ["pre-commit", "pytest-asyncio", "tox"] +dev = ["pre-commit", "tox", "pytest-asyncio"] [[package]] name = "pytest-xdist" @@ -1172,21 +1170,21 @@ mando = ">=0.6,<0.7" [[package]] name = "requests" -version = "2.27.1" +version = "2.28.1" description = "Python HTTP for Humans." category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = ">=3.7, <4" [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} -idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} +charset-normalizer = ">=2,<3" +idna = ">=2.5,<4" urllib3 = ">=1.21.1,<1.27" [package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] -use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "retry" @@ -1225,11 +1223,11 @@ python-versions = ">=3.5" [[package]] name = "s3transfer" -version = "0.5.2" +version = "0.6.0" description = "An Amazon S3 Transfer Manager" category = "main" optional = false -python-versions = ">= 3.6" +python-versions = ">= 3.7" [package.dependencies] botocore = ">=1.12.36,<2.0a.0" @@ -1237,18 +1235,6 @@ botocore = ">=1.12.36,<2.0a.0" [package.extras] crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"] -[[package]] -name = "setuptools" -version = "59.6.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.extras] -docs = ["furo", "jaraco.packaging (>=8.2)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx", "sphinx-inline-tabs", "sphinxcontrib-towncrier"] -testing = ["flake8-2020", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mock", "paver", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy", "pytest-virtualenv (>=1.2.7)", "pytest-xdist", "sphinx", "virtualenv (>=13.0.0)", "wheel"] - [[package]] name = "six" version = "1.16.0" @@ -1267,7 +1253,7 @@ python-versions = ">=3.6" [[package]] name = "stevedore" -version = "3.5.0" +version = "3.5.1" description = "Manage dynamic plugins for Python applications" category = "dev" optional = false @@ -1279,11 +1265,11 @@ pbr = ">=2.0.0,<2.1.0 || >2.1.0" [[package]] name = "tomli" -version = "1.2.3" +version = "2.0.1" description = "A lil' TOML parser" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [[package]] name = "typed-ast" @@ -1293,6 +1279,18 @@ category = "dev" optional = false python-versions = ">=3.6" +[[package]] +name = "typeguard" +version = "2.13.3" +description = "Run-time type checker for Python" +category = "dev" +optional = false +python-versions = ">=3.5.3" + +[package.extras] +doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] +test = ["pytest", "typing-extensions", "mypy"] + [[package]] name = "types-requests" version = "2.28.11.2" @@ -1306,7 +1304,7 @@ types-urllib3 = "<1.27" [[package]] name = "types-urllib3" -version = "1.26.24" +version = "1.26.25" description = "Typing stubs for urllib3" category = "dev" optional = false @@ -1329,8 +1327,8 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] @@ -1367,155 +1365,155 @@ requests = ">=2.0,<3.0" [[package]] name = "zipp" -version = "3.6.0" +version = "3.9.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.extras] -docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"] -testing = ["func-timeout", "jaraco.itertools", "pytest (>=4.6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy"] +docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "jaraco.tidelift (>=1.4)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "jaraco.functools", "more-itertools", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] [extras] pydantic = ["pydantic", "email-validator"] [metadata] lock-version = "1.1" -python-versions = "^3.6.2" -content-hash = "b6eba8ccb0bd0673dec8656d0fafa5aac520761f92cc152798c41883e3c92dca" +python-versions = "^3.7.4" +content-hash = "1500a968030f6adae44497fbb31beaef774fa53f7020ee264a4f5971b38fc597" [metadata.files] -atomicwrites = [ - {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, -] attrs = [ - {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, - {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, + {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, + {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, ] aws-cdk-lib = [ - {file = "aws-cdk-lib-2.23.0.tar.gz", hash = "sha256:3e07d1c6b320795d38567be183e56c2125b4c4492589775257aabec3d3e2a384"}, - {file = "aws_cdk_lib-2.23.0-py3-none-any.whl", hash = "sha256:1ec04a146d3364cd0fc4da08e3f8ca25e28df68abaa90641936db17a415ca4bc"}, + {file = "aws-cdk-lib-2.46.0.tar.gz", hash = "sha256:ec2c6055d64a0574533fcbcdc2006ee32a23d38a5755bc4b99fd1796124b1de5"}, + {file = "aws_cdk_lib-2.46.0-py3-none-any.whl", hash = "sha256:28d76161acf834d97ab5f9a6b2003bb81345e14197474d706de7ee30847b87bd"}, +] +"aws-cdk.aws-apigatewayv2-alpha" = [ + {file = "aws-cdk.aws-apigatewayv2-alpha-2.46.0a0.tar.gz", hash = "sha256:10d9324da26db7aeee3a45853a2e249b6b85866fcc8f8f43fa1a0544ce582482"}, + {file = "aws_cdk.aws_apigatewayv2_alpha-2.46.0a0-py3-none-any.whl", hash = "sha256:2cdeac84fb1fe219e5686ee95d9528a1810e9d426b2bb7f305ea07cb43e328a8"}, +] +"aws-cdk.aws-apigatewayv2-integrations-alpha" = [ + {file = "aws-cdk.aws-apigatewayv2-integrations-alpha-2.46.0a0.tar.gz", hash = "sha256:91a792c94500987b69fd97cb00afec5ace00f2039ffebebd99f91ee6b47c3c8b"}, + {file = "aws_cdk.aws_apigatewayv2_integrations_alpha-2.46.0a0-py3-none-any.whl", hash = "sha256:c7bbe1c08019cee41c14b6c1513f673d60b337422ef338c67f9a0cb3e17cc963"}, ] aws-xray-sdk = [ {file = "aws-xray-sdk-2.10.0.tar.gz", hash = "sha256:9b14924fd0628cf92936055864655354003f0b1acc3e1c3ffde6403d0799dd7a"}, {file = "aws_xray_sdk-2.10.0-py2.py3-none-any.whl", hash = "sha256:7551e81a796e1a5471ebe84844c40e8edf7c218db33506d046fec61f7495eda4"}, ] bandit = [ - {file = "bandit-1.7.1-py3-none-any.whl", hash = "sha256:f5acd838e59c038a159b5c621cf0f8270b279e884eadd7b782d7491c02add0d4"}, - {file = "bandit-1.7.1.tar.gz", hash = "sha256:a81b00b5436e6880fa8ad6799bc830e02032047713cbb143a12939ac67eb756c"}, + {file = "bandit-1.7.4-py3-none-any.whl", hash = "sha256:412d3f259dab4077d0e7f0c11f50f650cc7d10db905d98f6520a95a18049658a"}, + {file = "bandit-1.7.4.tar.gz", hash = "sha256:2d63a8c573417bae338962d4b9b06fbc6080f74ecd955a092849e1e65c717bd2"}, ] black = [ - {file = "black-22.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ce957f1d6b78a8a231b18e0dd2d94a33d2ba738cd88a7fe64f53f659eea49fdd"}, - {file = "black-22.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5107ea36b2b61917956d018bd25129baf9ad1125e39324a9b18248d362156a27"}, - {file = "black-22.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8166b7bfe5dcb56d325385bd1d1e0f635f24aae14b3ae437102dedc0c186747"}, - {file = "black-22.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd82842bb272297503cbec1a2600b6bfb338dae017186f8f215c8958f8acf869"}, - {file = "black-22.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d839150f61d09e7217f52917259831fe2b689f5c8e5e32611736351b89bb2a90"}, - {file = "black-22.8.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a05da0430bd5ced89176db098567973be52ce175a55677436a271102d7eaa3fe"}, - {file = "black-22.8.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a098a69a02596e1f2a58a2a1c8d5a05d5a74461af552b371e82f9fa4ada8342"}, - {file = "black-22.8.0-cp36-cp36m-win_amd64.whl", hash = "sha256:5594efbdc35426e35a7defa1ea1a1cb97c7dbd34c0e49af7fb593a36bd45edab"}, - {file = "black-22.8.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a983526af1bea1e4cf6768e649990f28ee4f4137266921c2c3cee8116ae42ec3"}, - {file = "black-22.8.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b2c25f8dea5e8444bdc6788a2f543e1fb01494e144480bc17f806178378005e"}, - {file = "black-22.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:78dd85caaab7c3153054756b9fe8c611efa63d9e7aecfa33e533060cb14b6d16"}, - {file = "black-22.8.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:cea1b2542d4e2c02c332e83150e41e3ca80dc0fb8de20df3c5e98e242156222c"}, - {file = "black-22.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5b879eb439094751185d1cfdca43023bc6786bd3c60372462b6f051efa6281a5"}, - {file = "black-22.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0a12e4e1353819af41df998b02c6742643cfef58282915f781d0e4dd7a200411"}, - {file = "black-22.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3a73f66b6d5ba7288cd5d6dad9b4c9b43f4e8a4b789a94bf5abfb878c663eb3"}, - {file = "black-22.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:e981e20ec152dfb3e77418fb616077937378b322d7b26aa1ff87717fb18b4875"}, - {file = "black-22.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8ce13ffed7e66dda0da3e0b2eb1bdfc83f5812f66e09aca2b0978593ed636b6c"}, - {file = "black-22.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:32a4b17f644fc288c6ee2bafdf5e3b045f4eff84693ac069d87b1a347d861497"}, - {file = "black-22.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ad827325a3a634bae88ae7747db1a395d5ee02cf05d9aa7a9bd77dfb10e940c"}, - {file = "black-22.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53198e28a1fb865e9fe97f88220da2e44df6da82b18833b588b1883b16bb5d41"}, - {file = "black-22.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:bc4d4123830a2d190e9cc42a2e43570f82ace35c3aeb26a512a2102bce5af7ec"}, - {file = "black-22.8.0-py3-none-any.whl", hash = "sha256:d2c21d439b2baf7aa80d6dd4e3659259be64c6f49dfd0f32091063db0e006db4"}, - {file = "black-22.8.0.tar.gz", hash = "sha256:792f7eb540ba9a17e8656538701d3eb1afcb134e3b45b71f20b25c77a8db7e6e"}, + {file = "black-22.10.0-1fixedarch-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:5cc42ca67989e9c3cf859e84c2bf014f6633db63d1cbdf8fdb666dcd9e77e3fa"}, + {file = "black-22.10.0-1fixedarch-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:5d8f74030e67087b219b032aa33a919fae8806d49c867846bfacde57f43972ef"}, + {file = "black-22.10.0-1fixedarch-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:197df8509263b0b8614e1df1756b1dd41be6738eed2ba9e9769f3880c2b9d7b6"}, + {file = "black-22.10.0-1fixedarch-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:2644b5d63633702bc2c5f3754b1b475378fbbfb481f62319388235d0cd104c2d"}, + {file = "black-22.10.0-1fixedarch-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:e41a86c6c650bcecc6633ee3180d80a025db041a8e2398dcc059b3afa8382cd4"}, + {file = "black-22.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2039230db3c6c639bd84efe3292ec7b06e9214a2992cd9beb293d639c6402edb"}, + {file = "black-22.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14ff67aec0a47c424bc99b71005202045dc09270da44a27848d534600ac64fc7"}, + {file = "black-22.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:819dc789f4498ecc91438a7de64427c73b45035e2e3680c92e18795a839ebb66"}, + {file = "black-22.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5b9b29da4f564ba8787c119f37d174f2b69cdfdf9015b7d8c5c16121ddc054ae"}, + {file = "black-22.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8b49776299fece66bffaafe357d929ca9451450f5466e997a7285ab0fe28e3b"}, + {file = "black-22.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:21199526696b8f09c3997e2b4db8d0b108d801a348414264d2eb8eb2532e540d"}, + {file = "black-22.10.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e464456d24e23d11fced2bc8c47ef66d471f845c7b7a42f3bd77bf3d1789650"}, + {file = "black-22.10.0-cp37-cp37m-win_amd64.whl", hash = "sha256:9311e99228ae10023300ecac05be5a296f60d2fd10fff31cf5c1fa4ca4b1988d"}, + {file = "black-22.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fba8a281e570adafb79f7755ac8721b6cf1bbf691186a287e990c7929c7692ff"}, + {file = "black-22.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:915ace4ff03fdfff953962fa672d44be269deb2eaf88499a0f8805221bc68c87"}, + {file = "black-22.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:444ebfb4e441254e87bad00c661fe32df9969b2bf224373a448d8aca2132b395"}, + {file = "black-22.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:974308c58d057a651d182208a484ce80a26dac0caef2895836a92dd6ebd725e0"}, + {file = "black-22.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72ef3925f30e12a184889aac03d77d031056860ccae8a1e519f6cbb742736383"}, + {file = "black-22.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:432247333090c8c5366e69627ccb363bc58514ae3e63f7fc75c54b1ea80fa7de"}, + {file = "black-22.10.0-py3-none-any.whl", hash = "sha256:c957b2b4ea88587b46cf49d1dc17681c1e672864fd7af32fc1e9664d572b3458"}, + {file = "black-22.10.0.tar.gz", hash = "sha256:f513588da599943e0cde4e32cc9879e825d58720d6557062d1098c5ad80080e1"}, ] boto3 = [ - {file = "boto3-1.23.10-py3-none-any.whl", hash = "sha256:40d08614f17a69075e175c02c5d5aab69a6153fd50e40fa7057b913ac7bf40e7"}, - {file = "boto3-1.23.10.tar.gz", hash = "sha256:2a4395e3241c20eef441d7443a5e6eaa0ee3f7114653fb9d9cef41587526f7bd"}, + {file = "boto3-1.24.90-py3-none-any.whl", hash = "sha256:2a058a86989d88e27c79dd3e17218b3b167070d8738e20844d49363a3dd64281"}, + {file = "boto3-1.24.90.tar.gz", hash = "sha256:83c2bc50f762ba87471fc73ec3eefd86626883b38533e98d7993259450c043db"}, ] botocore = [ - {file = "botocore-1.26.10-py3-none-any.whl", hash = "sha256:8a4a984bf901ccefe40037da11ba2abd1ddbcb3b490a492b7f218509c99fc12f"}, - {file = "botocore-1.26.10.tar.gz", hash = "sha256:5df2cf7ebe34377470172bd0bbc582cf98c5cbd02da0909a14e9e2885ab3ae9c"}, + {file = "botocore-1.27.90-py3-none-any.whl", hash = "sha256:2a934e713e83ae7f4dde1dd013be280538e3c4a3825c5f5a2727a1956d4cd82c"}, + {file = "botocore-1.27.90.tar.gz", hash = "sha256:4ecc149d1dd36d32ba222de135c2e147731107ae444440b12714282dc00f88a4"}, ] cattrs = [ - {file = "cattrs-1.0.0-py2.py3-none-any.whl", hash = "sha256:616972ae3dfa6e623a40ad3cb845420e64942989152774ab055e5c2b2f89f997"}, - {file = "cattrs-1.0.0.tar.gz", hash = "sha256:b7ab5cf8ad127c42eefd01410c1c6e28569a45a255ea80ed968511873c433c7a"}, {file = "cattrs-22.1.0-py3-none-any.whl", hash = "sha256:d55c477b4672f93606e992049f15d526dc7867e6c756cd6256d4af92e2b1e364"}, {file = "cattrs-22.1.0.tar.gz", hash = "sha256:94b67b64cf92c994f8784c40c082177dc916e0489a73a9a36b24eb18a9db40c6"}, ] certifi = [ - {file = "certifi-2022.6.15.1-py3-none-any.whl", hash = "sha256:43dadad18a7f168740e66944e4fa82c6611848ff9056ad910f8f7a3e46ab89e0"}, - {file = "certifi-2022.6.15.1.tar.gz", hash = "sha256:cffdcd380919da6137f76633531a5817e3a9f268575c128249fb637e4f9e73fb"}, + {file = "certifi-2022.9.24-py3-none-any.whl", hash = "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"}, + {file = "certifi-2022.9.24.tar.gz", hash = "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14"}, ] charset-normalizer = [ - {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}, - {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, + {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, + {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, ] click = [ - {file = "click-8.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"}, - {file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"}, + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, ] colorama = [ {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, ] constructs = [ - {file = "constructs-10.1.1-py3-none-any.whl", hash = "sha256:c1f3deb196f54e070ded3c92c4339f73ef2b6022d35fb34908c0ebfa7ef8a640"}, - {file = "constructs-10.1.1.tar.gz", hash = "sha256:6ce0dd1352367237b5d7c51a25740482c852735d2a5e067c536acc1657f39ea5"}, + {file = "constructs-10.1.131-py3-none-any.whl", hash = "sha256:3dc720ce1593ad8e05c76b11abee1ab196e7a9ced3a5e1b4539c7d06cd9cd5cf"}, + {file = "constructs-10.1.131.tar.gz", hash = "sha256:90b2ca25b6f26f7547d1ea695bb55f9ce2f08072d322af5afcce0347a3add9af"}, ] coverage = [ - {file = "coverage-6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6dbc1536e105adda7a6312c778f15aaabe583b0e9a0b0a324990334fd458c94b"}, - {file = "coverage-6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:174cf9b4bef0db2e8244f82059a5a72bd47e1d40e71c68ab055425172b16b7d0"}, - {file = "coverage-6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:92b8c845527eae547a2a6617d336adc56394050c3ed8a6918683646328fbb6da"}, - {file = "coverage-6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c7912d1526299cb04c88288e148c6c87c0df600eca76efd99d84396cfe00ef1d"}, - {file = "coverage-6.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5d2033d5db1d58ae2d62f095e1aefb6988af65b4b12cb8987af409587cc0739"}, - {file = "coverage-6.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3feac4084291642165c3a0d9eaebedf19ffa505016c4d3db15bfe235718d4971"}, - {file = "coverage-6.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:276651978c94a8c5672ea60a2656e95a3cce2a3f31e9fb2d5ebd4c215d095840"}, - {file = "coverage-6.2-cp310-cp310-win32.whl", hash = "sha256:f506af4f27def639ba45789fa6fde45f9a217da0be05f8910458e4557eed020c"}, - {file = "coverage-6.2-cp310-cp310-win_amd64.whl", hash = "sha256:3f7c17209eef285c86f819ff04a6d4cbee9b33ef05cbcaae4c0b4e8e06b3ec8f"}, - {file = "coverage-6.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:13362889b2d46e8d9f97c421539c97c963e34031ab0cb89e8ca83a10cc71ac76"}, - {file = "coverage-6.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:22e60a3ca5acba37d1d4a2ee66e051f5b0e1b9ac950b5b0cf4aa5366eda41d47"}, - {file = "coverage-6.2-cp311-cp311-win_amd64.whl", hash = "sha256:b637c57fdb8be84e91fac60d9325a66a5981f8086c954ea2772efe28425eaf64"}, - {file = "coverage-6.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f467bbb837691ab5a8ca359199d3429a11a01e6dfb3d9dcc676dc035ca93c0a9"}, - {file = "coverage-6.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2641f803ee9f95b1f387f3e8f3bf28d83d9b69a39e9911e5bfee832bea75240d"}, - {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1219d760ccfafc03c0822ae2e06e3b1248a8e6d1a70928966bafc6838d3c9e48"}, - {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9a2b5b52be0a8626fcbffd7e689781bf8c2ac01613e77feda93d96184949a98e"}, - {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:8e2c35a4c1f269704e90888e56f794e2d9c0262fb0c1b1c8c4ee44d9b9e77b5d"}, - {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5d6b09c972ce9200264c35a1d53d43ca55ef61836d9ec60f0d44273a31aa9f17"}, - {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e3db840a4dee542e37e09f30859f1612da90e1c5239a6a2498c473183a50e781"}, - {file = "coverage-6.2-cp36-cp36m-win32.whl", hash = "sha256:4e547122ca2d244f7c090fe3f4b5a5861255ff66b7ab6d98f44a0222aaf8671a"}, - {file = "coverage-6.2-cp36-cp36m-win_amd64.whl", hash = "sha256:01774a2c2c729619760320270e42cd9e797427ecfddd32c2a7b639cdc481f3c0"}, - {file = "coverage-6.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb8b8ee99b3fffe4fd86f4c81b35a6bf7e4462cba019997af2fe679365db0c49"}, - {file = "coverage-6.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:619346d57c7126ae49ac95b11b0dc8e36c1dd49d148477461bb66c8cf13bb521"}, - {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0a7726f74ff63f41e95ed3a89fef002916c828bb5fcae83b505b49d81a066884"}, - {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cfd9386c1d6f13b37e05a91a8583e802f8059bebfccde61a418c5808dea6bbfa"}, - {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:17e6c11038d4ed6e8af1407d9e89a2904d573be29d51515f14262d7f10ef0a64"}, - {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c254b03032d5a06de049ce8bca8338a5185f07fb76600afff3c161e053d88617"}, - {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dca38a21e4423f3edb821292e97cec7ad38086f84313462098568baedf4331f8"}, - {file = "coverage-6.2-cp37-cp37m-win32.whl", hash = "sha256:600617008aa82032ddeace2535626d1bc212dfff32b43989539deda63b3f36e4"}, - {file = "coverage-6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:bf154ba7ee2fd613eb541c2bc03d3d9ac667080a737449d1a3fb342740eb1a74"}, - {file = "coverage-6.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f9afb5b746781fc2abce26193d1c817b7eb0e11459510fba65d2bd77fe161d9e"}, - {file = "coverage-6.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edcada2e24ed68f019175c2b2af2a8b481d3d084798b8c20d15d34f5c733fa58"}, - {file = "coverage-6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a9c8c4283e17690ff1a7427123ffb428ad6a52ed720d550e299e8291e33184dc"}, - {file = "coverage-6.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f614fc9956d76d8a88a88bb41ddc12709caa755666f580af3a688899721efecd"}, - {file = "coverage-6.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9365ed5cce5d0cf2c10afc6add145c5037d3148585b8ae0e77cc1efdd6aa2953"}, - {file = "coverage-6.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8bdfe9ff3a4ea37d17f172ac0dff1e1c383aec17a636b9b35906babc9f0f5475"}, - {file = "coverage-6.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:63c424e6f5b4ab1cf1e23a43b12f542b0ec2e54f99ec9f11b75382152981df57"}, - {file = "coverage-6.2-cp38-cp38-win32.whl", hash = "sha256:49dbff64961bc9bdd2289a2bda6a3a5a331964ba5497f694e2cbd540d656dc1c"}, - {file = "coverage-6.2-cp38-cp38-win_amd64.whl", hash = "sha256:9a29311bd6429be317c1f3fe4bc06c4c5ee45e2fa61b2a19d4d1d6111cb94af2"}, - {file = "coverage-6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03b20e52b7d31be571c9c06b74746746d4eb82fc260e594dc662ed48145e9efd"}, - {file = "coverage-6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:215f8afcc02a24c2d9a10d3790b21054b58d71f4b3c6f055d4bb1b15cecce685"}, - {file = "coverage-6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a4bdeb0a52d1d04123b41d90a4390b096f3ef38eee35e11f0b22c2d031222c6c"}, - {file = "coverage-6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c332d8f8d448ded473b97fefe4a0983265af21917d8b0cdcb8bb06b2afe632c3"}, - {file = "coverage-6.2-cp39-cp39-win32.whl", hash = "sha256:6e1394d24d5938e561fbeaa0cd3d356207579c28bd1792f25a068743f2d5b282"}, - {file = "coverage-6.2-cp39-cp39-win_amd64.whl", hash = "sha256:86f2e78b1eff847609b1ca8050c9e1fa3bd44ce755b2ec30e70f2d3ba3844644"}, - {file = "coverage-6.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:5829192582c0ec8ca4a2532407bc14c2f338d9878a10442f5d03804a95fac9de"}, - {file = "coverage-6.2.tar.gz", hash = "sha256:e2cad8093172b7d1595b4ad66f24270808658e11acf43a8f95b41276162eb5b8"}, -] -dataclasses = [ - {file = "dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf"}, - {file = "dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"}, + {file = "coverage-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef8674b0ee8cc11e2d574e3e2998aea5df5ab242e012286824ea3c6970580e53"}, + {file = "coverage-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:784f53ebc9f3fd0e2a3f6a78b2be1bd1f5575d7863e10c6e12504f240fd06660"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4a5be1748d538a710f87542f22c2cad22f80545a847ad91ce45e77417293eb4"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83516205e254a0cb77d2d7bb3632ee019d93d9f4005de31dca0a8c3667d5bc04"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af4fffaffc4067232253715065e30c5a7ec6faac36f8fc8d6f64263b15f74db0"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:97117225cdd992a9c2a5515db1f66b59db634f59d0679ca1fa3fe8da32749cae"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a1170fa54185845505fbfa672f1c1ab175446c887cce8212c44149581cf2d466"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:11b990d520ea75e7ee8dcab5bc908072aaada194a794db9f6d7d5cfd19661e5a"}, + {file = "coverage-6.5.0-cp310-cp310-win32.whl", hash = "sha256:5dbec3b9095749390c09ab7c89d314727f18800060d8d24e87f01fb9cfb40b32"}, + {file = "coverage-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:59f53f1dc5b656cafb1badd0feb428c1e7bc19b867479ff72f7a9dd9b479f10e"}, + {file = "coverage-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5375e28c5191ac38cca59b38edd33ef4cc914732c916f2929029b4bfb50795"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4ed2820d919351f4167e52425e096af41bfabacb1857186c1ea32ff9983ed75"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33a7da4376d5977fbf0a8ed91c4dffaaa8dbf0ddbf4c8eea500a2486d8bc4d7b"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8fb6cf131ac4070c9c5a3e21de0f7dc5a0fbe8bc77c9456ced896c12fcdad91"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a6b7d95969b8845250586f269e81e5dfdd8ff828ddeb8567a4a2eaa7313460c4"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1ef221513e6f68b69ee9e159506d583d31aa3567e0ae84eaad9d6ec1107dddaa"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cca4435eebea7962a52bdb216dec27215d0df64cf27fc1dd538415f5d2b9da6b"}, + {file = "coverage-6.5.0-cp311-cp311-win32.whl", hash = "sha256:98e8a10b7a314f454d9eff4216a9a94d143a7ee65018dd12442e898ee2310578"}, + {file = "coverage-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:bc8ef5e043a2af066fa8cbfc6e708d58017024dc4345a1f9757b329a249f041b"}, + {file = "coverage-6.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4433b90fae13f86fafff0b326453dd42fc9a639a0d9e4eec4d366436d1a41b6d"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4f05d88d9a80ad3cac6244d36dd89a3c00abc16371769f1340101d3cb899fc3"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94e2565443291bd778421856bc975d351738963071e9b8839ca1fc08b42d4bef"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:027018943386e7b942fa832372ebc120155fd970837489896099f5cfa2890f79"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:255758a1e3b61db372ec2736c8e2a1fdfaf563977eedbdf131de003ca5779b7d"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:851cf4ff24062c6aec510a454b2584f6e998cada52d4cb58c5e233d07172e50c"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:12adf310e4aafddc58afdb04d686795f33f4d7a6fa67a7a9d4ce7d6ae24d949f"}, + {file = "coverage-6.5.0-cp37-cp37m-win32.whl", hash = "sha256:b5604380f3415ba69de87a289a2b56687faa4fe04dbee0754bfcae433489316b"}, + {file = "coverage-6.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4a8dbc1f0fbb2ae3de73eb0bdbb914180c7abfbf258e90b311dcd4f585d44bd2"}, + {file = "coverage-6.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d900bb429fdfd7f511f868cedd03a6bbb142f3f9118c09b99ef8dc9bf9643c3c"}, + {file = "coverage-6.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2198ea6fc548de52adc826f62cb18554caedfb1d26548c1b7c88d8f7faa8f6ba"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c4459b3de97b75e3bd6b7d4b7f0db13f17f504f3d13e2a7c623786289dd670e"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20c8ac5386253717e5ccc827caad43ed66fea0efe255727b1053a8154d952398"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b07130585d54fe8dff3d97b93b0e20290de974dc8177c320aeaf23459219c0b"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dbdb91cd8c048c2b09eb17713b0c12a54fbd587d79adcebad543bc0cd9a3410b"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:de3001a203182842a4630e7b8d1a2c7c07ec1b45d3084a83d5d227a3806f530f"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e07f4a4a9b41583d6eabec04f8b68076ab3cd44c20bd29332c6572dda36f372e"}, + {file = "coverage-6.5.0-cp38-cp38-win32.whl", hash = "sha256:6d4817234349a80dbf03640cec6109cd90cba068330703fa65ddf56b60223a6d"}, + {file = "coverage-6.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:7ccf362abd726b0410bf8911c31fbf97f09f8f1061f8c1cf03dfc4b6372848f6"}, + {file = "coverage-6.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:633713d70ad6bfc49b34ead4060531658dc6dfc9b3eb7d8a716d5873377ab745"}, + {file = "coverage-6.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:95203854f974e07af96358c0b261f1048d8e1083f2de9b1c565e1be4a3a48cfc"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9023e237f4c02ff739581ef35969c3739445fb059b060ca51771e69101efffe"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:265de0fa6778d07de30bcf4d9dc471c3dc4314a23a3c6603d356a3c9abc2dfcf"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f830ed581b45b82451a40faabb89c84e1a998124ee4212d440e9c6cf70083e5"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7b6be138d61e458e18d8e6ddcddd36dd96215edfe5f1168de0b1b32635839b62"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:42eafe6778551cf006a7c43153af1211c3aaab658d4d66fa5fcc021613d02518"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:723e8130d4ecc8f56e9a611e73b31219595baa3bb252d539206f7bbbab6ffc1f"}, + {file = "coverage-6.5.0-cp39-cp39-win32.whl", hash = "sha256:d9ecf0829c6a62b9b573c7bb6d4dcd6ba8b6f80be9ba4fc7ed50bf4ac9aecd72"}, + {file = "coverage-6.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc2af30ed0d5ae0b1abdb4ebdce598eafd5b35397d4d75deb341a614d333d987"}, + {file = "coverage-6.5.0-pp36.pp37.pp38-none-any.whl", hash = "sha256:1431986dac3923c5945271f169f59c45b8802a114c8f548d611f2015133df77a"}, + {file = "coverage-6.5.0.tar.gz", hash = "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84"}, ] decorator = [ {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, @@ -1553,6 +1551,10 @@ flake8 = [ {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, ] +flake8-black = [ + {file = "flake8-black-0.3.3.tar.gz", hash = "sha256:8211f5e20e954cb57c709acccf2f3281ce27016d4c4b989c3e51f878bb7ce12a"}, + {file = "flake8_black-0.3.3-py3-none-any.whl", hash = "sha256:7d667d0059fd1aa468de1669d77cc934b7f1feeac258d57bdae69a8e73c4cd90"}, +] flake8-bugbear = [ {file = "flake8-bugbear-22.9.23.tar.gz", hash = "sha256:17b9623325e6e0dcdcc80ed9e4aa811287fcc81d7e03313b8736ea5733759937"}, {file = "flake8_bugbear-22.9.23-py3-none-any.whl", hash = "sha256:cd2779b2b7ada212d7a322814a1e5651f1868ab0d3f24cc9da66169ab8fda474"}, @@ -1562,16 +1564,16 @@ flake8-builtins = [ {file = "flake8_builtins-1.5.3-py2.py3-none-any.whl", hash = "sha256:7706babee43879320376861897e5d1468e396a40b8918ed7bccf70e5f90b8687"}, ] flake8-comprehensions = [ - {file = "flake8-comprehensions-3.7.0.tar.gz", hash = "sha256:6b3218b2dde8ac5959c6476cde8f41a79e823c22feb656be2710cd2a3232cef9"}, - {file = "flake8_comprehensions-3.7.0-py3-none-any.whl", hash = "sha256:a5d7aea6315bbbd6fbcb2b4e80bff6a54d1600155e26236e555d0c6fe1d62522"}, + {file = "flake8-comprehensions-3.10.0.tar.gz", hash = "sha256:181158f7e7aa26a63a0a38e6017cef28c6adee71278ce56ce11f6ec9c4905058"}, + {file = "flake8_comprehensions-3.10.0-py3-none-any.whl", hash = "sha256:dad454fd3d525039121e98fa1dd90c46bc138708196a4ebbc949ad3c859adedb"}, ] 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"}, + {file = "flake8-debugger-4.1.2.tar.gz", hash = "sha256:52b002560941e36d9bf806fca2523dc7fb8560a295d5f1a6e15ac2ded7a73840"}, + {file = "flake8_debugger-4.1.2-py3-none-any.whl", hash = "sha256:0a5e55aeddcc81da631ad9c8c366e7318998f83ff00985a49e6b3ecf61e571bf"}, ] flake8-eradicate = [ - {file = "flake8-eradicate-1.3.0.tar.gz", hash = "sha256:e4c98f00d17dc8653e3388cac2624cd81e9735de2fd4a8dcf99029633ebd7a63"}, - {file = "flake8_eradicate-1.3.0-py3-none-any.whl", hash = "sha256:85a71e0c5f4e07f7c6c5fec520483561fd6bd295417d622855bdeade99242e3d"}, + {file = "flake8-eradicate-1.4.0.tar.gz", hash = "sha256:3088cfd6717d1c9c6c3ac45ef2e5f5b6c7267f7504d5a74b781500e95cb9c7e1"}, + {file = "flake8_eradicate-1.4.0-py3-none-any.whl", hash = "sha256:e3bbd0871be358e908053c1ab728903c114f062ba596b4d40c852fd18f473d56"}, ] flake8-fixme = [ {file = "flake8-fixme-1.1.1.tar.gz", hash = "sha256:50cade07d27a4c30d4f12351478df87339e67640c83041b664724bda6d16f33a"}, @@ -1596,20 +1598,16 @@ gitdb = [ {file = "gitdb-4.0.9.tar.gz", hash = "sha256:bac2fd45c0a1c9cf619e63a90d62bdc63892ef92387424b855792a6cabe789aa"}, ] gitpython = [ - {file = "GitPython-3.1.20-py3-none-any.whl", hash = "sha256:b1e1c269deab1b08ce65403cf14e10d2ef1f6c89e33ea7c5e5bb0222ea593b8a"}, - {file = "GitPython-3.1.20.tar.gz", hash = "sha256:df0e072a200703a65387b0cfdf0466e3bab729c0458cf6b7349d0e9877636519"}, + {file = "GitPython-3.1.29-py3-none-any.whl", hash = "sha256:41eea0deec2deea139b459ac03656f0dd28fc4a3387240ec1d3c259a2c47850f"}, + {file = "GitPython-3.1.29.tar.gz", hash = "sha256:cc36bfc4a3f913e66805a28e84703e419d9c264c1077e537b54f0e1af85dbefd"}, ] idna = [ - {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, - {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.8.3-py3-none-any.whl", hash = "sha256:65a9576a5b2d58ca44d133c42a241905cc45e34d2c06fd5ba2bafa221e5d7b5e"}, - {file = "importlib_metadata-4.8.3.tar.gz", hash = "sha256:766abffff765960fcc18003801f7044eb6755ffae4521c8e8ce8e83b9c9b0668"}, -] -importlib-resources = [ - {file = "importlib_resources-5.4.0-py3-none-any.whl", hash = "sha256:33a95faed5fc19b4bc16b29a6eeae248a3fe69dd55d4d229d2b480e23eeaad45"}, - {file = "importlib_resources-5.4.0.tar.gz", hash = "sha256:d756e2f85dd4de2ba89be0b21dba2a3bbec2e871a42a3a16719258a11f87506b"}, + {file = "importlib_metadata-5.0.0-py3-none-any.whl", hash = "sha256:ddb0e35065e8938f867ed4928d0ae5bf2a53b7773871bfe6bcc7e4fcdc7dea43"}, + {file = "importlib_metadata-5.0.0.tar.gz", hash = "sha256:da31db32b304314d044d3c12c79bd59e307889b287ad12ff387b3500835fc2ab"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, @@ -1620,18 +1618,18 @@ isort = [ {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, ] jinja2 = [ - {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"}, - {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"}, + {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, + {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, ] jmespath = [ - {file = "jmespath-0.10.0-py2.py3-none-any.whl", hash = "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f"}, - {file = "jmespath-0.10.0.tar.gz", hash = "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9"}, + {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, + {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, ] jsii = [ - {file = "jsii-1.57.0-py3-none-any.whl", hash = "sha256:4888091986a9ed8d50b042cc9c35a9564dd54c19e78adb890bf06d9ffac1b325"}, - {file = "jsii-1.57.0.tar.gz", hash = "sha256:ff7a3c51c1a653dd8a4342043b5f8e40b928bc617e3141e0d5d66175d22a754b"}, + {file = "jsii-1.69.0-py3-none-any.whl", hash = "sha256:f3ae5cdf5e854b4d59256dc1f8818cd3fabb8eb43fbd3134a8e8aef962643005"}, + {file = "jsii-1.69.0.tar.gz", hash = "sha256:7c7ed2a913372add17d63322a640c6435324770eb78c6b89e4c701e07d9c84db"}, ] -Mako = [ +mako = [ {file = "Mako-1.2.3-py3-none-any.whl", hash = "sha256:c413a086e38cd885088d5e165305ee8eed04e8b3f8f62df343480da0a385735f"}, {file = "Mako-1.2.3.tar.gz", hash = "sha256:7fde96466fcfeedb0eed94f187f20b23d85e4cb41444be0e542e2c8c65c396cd"}, ] @@ -1644,75 +1642,46 @@ markdown = [ {file = "Markdown-3.3.7.tar.gz", hash = "sha256:cbb516f16218e643d8e0a95b309f77eb118cb138d39a4f27851e6a63581db874"}, ] markupsafe = [ - {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"}, - {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"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, - {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"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, - {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"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, - {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"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, - {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"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, + {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, ] mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, @@ -1727,15 +1696,15 @@ mike = [ {file = "mike-0.6.0.tar.gz", hash = "sha256:6d6239de2a60d733da2f34617e9b9a14c4b5437423b47e524f14dc96d6ce5f2f"}, ] mkdocs = [ - {file = "mkdocs-1.3.1-py3-none-any.whl", hash = "sha256:fda92466393127d2da830bc6edc3a625a14b436316d1caf347690648e774c4f0"}, - {file = "mkdocs-1.3.1.tar.gz", hash = "sha256:a41a2ff25ce3bbacc953f9844ba07d106233cd76c88bac1f59cb1564ac0d87ed"}, + {file = "mkdocs-1.4.0-py3-none-any.whl", hash = "sha256:ce057e9992f017b8e1496b591b6c242cbd34c2d406e2f9af6a19b97dd6248faa"}, + {file = "mkdocs-1.4.0.tar.gz", hash = "sha256:e5549a22d59e7cb230d6a791edd2c3d06690908454c0af82edc31b35d57e3069"}, ] mkdocs-git-revision-date-plugin = [ {file = "mkdocs_git_revision_date_plugin-0.3.2-py3-none-any.whl", hash = "sha256:2e67956cb01823dd2418e2833f3623dee8604cdf223bddd005fe36226a56f6ef"}, ] mkdocs-material = [ - {file = "mkdocs_material-8.5.4-py3-none-any.whl", hash = "sha256:aec2f0f2143109f8388aadf76e6fff749a2b74ebe730d0f674c65b53da89d19d"}, - {file = "mkdocs_material-8.5.4.tar.gz", hash = "sha256:70dc47820d4765b77968b9119f2957d09b4d8d328d950bee4544ff224d5c7b36"}, + {file = "mkdocs_material-8.5.6-py3-none-any.whl", hash = "sha256:b473162c800321b9760453f301a91f7cb40a120a85a9d0464e1e484e74b76bb2"}, + {file = "mkdocs_material-8.5.6.tar.gz", hash = "sha256:38a21d817265d0c203ab3dad64996e45859c983f72180f6937bd5540a4eb84e4"}, ] mkdocs-material-extensions = [ {file = "mkdocs-material-extensions-1.0.3.tar.gz", hash = "sha256:bfd24dfdef7b41c312ede42648f9eb83476ea168ec163b613f9abd12bbfddba2"}, @@ -1799,8 +1768,8 @@ mypy-boto3-secretsmanager = [ {file = "mypy_boto3_secretsmanager-1.24.83-py3-none-any.whl", hash = "sha256:9ed3ec38a6c05961cb39a2d9fb891441d4cf22c63e34a6998fbd3d28ba290d9a"}, ] mypy-boto3-ssm = [ - {file = "mypy-boto3-ssm-1.24.81.tar.gz", hash = "sha256:2b3167faa868442e43f0c6065fac8549762aafc967e487aae2d9e15c5bad20c3"}, - {file = "mypy_boto3_ssm-1.24.81-py3-none-any.whl", hash = "sha256:a50fe448f3c18f76255e15878e21020001ec04a85b42996db721d9b89770ff11"}, + {file = "mypy-boto3-ssm-1.24.90.tar.gz", hash = "sha256:8fdc65a34958ae89d4ae8ea7748caec46226216b35d75adf87e8ed40a798bf95"}, + {file = "mypy_boto3_ssm-1.24.90-py3-none-any.whl", hash = "sha256:6fc26896e1fb4f84f5bbc04f79ba698e4dd296586ca462c517bc64e78d326fb5"}, ] mypy-boto3-xray = [ {file = "mypy-boto3-xray-1.24.36.post1.tar.gz", hash = "sha256:104f1ecf7f1f6278c582201e71a7ab64843d3a3fdc8f23295cf68788cc77e9bb"}, @@ -1815,8 +1784,8 @@ packaging = [ {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] pathspec = [ - {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, - {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, + {file = "pathspec-0.10.1-py3-none-any.whl", hash = "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93"}, + {file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"}, ] pbr = [ {file = "pbr-5.10.0-py2.py3-none-any.whl", hash = "sha256:da3e18aac0a3c003e9eea1a81bd23e5a3a75d745670dcf736317b7d966887fdf"}, @@ -1827,8 +1796,8 @@ 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"}, + {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, + {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, ] pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, @@ -1850,41 +1819,42 @@ pycodestyle = [ {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, ] pydantic = [ - {file = "pydantic-1.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9c9e04a6cdb7a363d7cb3ccf0efea51e0abb48e180c0d31dca8d247967d85c6e"}, - {file = "pydantic-1.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fafe841be1103f340a24977f61dee76172e4ae5f647ab9e7fd1e1fca51524f08"}, - {file = "pydantic-1.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afacf6d2a41ed91fc631bade88b1d319c51ab5418870802cedb590b709c5ae3c"}, - {file = "pydantic-1.9.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ee0d69b2a5b341fc7927e92cae7ddcfd95e624dfc4870b32a85568bd65e6131"}, - {file = "pydantic-1.9.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ff68fc85355532ea77559ede81f35fff79a6a5543477e168ab3a381887caea76"}, - {file = "pydantic-1.9.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c0f5e142ef8217019e3eef6ae1b6b55f09a7a15972958d44fbd228214cede567"}, - {file = "pydantic-1.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:615661bfc37e82ac677543704437ff737418e4ea04bef9cf11c6d27346606044"}, - {file = "pydantic-1.9.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:328558c9f2eed77bd8fffad3cef39dbbe3edc7044517f4625a769d45d4cf7555"}, - {file = "pydantic-1.9.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bd446bdb7755c3a94e56d7bdfd3ee92396070efa8ef3a34fab9579fe6aa1d84"}, - {file = "pydantic-1.9.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0b214e57623a535936005797567231a12d0da0c29711eb3514bc2b3cd008d0f"}, - {file = "pydantic-1.9.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:d8ce3fb0841763a89322ea0432f1f59a2d3feae07a63ea2c958b2315e1ae8adb"}, - {file = "pydantic-1.9.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b34ba24f3e2d0b39b43f0ca62008f7ba962cff51efa56e64ee25c4af6eed987b"}, - {file = "pydantic-1.9.2-cp36-cp36m-win_amd64.whl", hash = "sha256:84d76ecc908d917f4684b354a39fd885d69dd0491be175f3465fe4b59811c001"}, - {file = "pydantic-1.9.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4de71c718c9756d679420c69f216776c2e977459f77e8f679a4a961dc7304a56"}, - {file = "pydantic-1.9.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5803ad846cdd1ed0d97eb00292b870c29c1f03732a010e66908ff48a762f20e4"}, - {file = "pydantic-1.9.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a8c5360a0297a713b4123608a7909e6869e1b56d0e96eb0d792c27585d40757f"}, - {file = "pydantic-1.9.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:cdb4272678db803ddf94caa4f94f8672e9a46bae4a44f167095e4d06fec12979"}, - {file = "pydantic-1.9.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:19b5686387ea0d1ea52ecc4cffb71abb21702c5e5b2ac626fd4dbaa0834aa49d"}, - {file = "pydantic-1.9.2-cp37-cp37m-win_amd64.whl", hash = "sha256:32e0b4fb13ad4db4058a7c3c80e2569adbd810c25e6ca3bbd8b2a9cc2cc871d7"}, - {file = "pydantic-1.9.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:91089b2e281713f3893cd01d8e576771cd5bfdfbff5d0ed95969f47ef6d676c3"}, - {file = "pydantic-1.9.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e631c70c9280e3129f071635b81207cad85e6c08e253539467e4ead0e5b219aa"}, - {file = "pydantic-1.9.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b3946f87e5cef3ba2e7bd3a4eb5a20385fe36521d6cc1ebf3c08a6697c6cfb3"}, - {file = "pydantic-1.9.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5565a49effe38d51882cb7bac18bda013cdb34d80ac336428e8908f0b72499b0"}, - {file = "pydantic-1.9.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:bd67cb2c2d9602ad159389c29e4ca964b86fa2f35c2faef54c3eb28b4efd36c8"}, - {file = "pydantic-1.9.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4aafd4e55e8ad5bd1b19572ea2df546ccace7945853832bb99422a79c70ce9b8"}, - {file = "pydantic-1.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:d70916235d478404a3fa8c997b003b5f33aeac4686ac1baa767234a0f8ac2326"}, - {file = "pydantic-1.9.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f0ca86b525264daa5f6b192f216a0d1e860b7383e3da1c65a1908f9c02f42801"}, - {file = "pydantic-1.9.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1061c6ee6204f4f5a27133126854948e3b3d51fcc16ead2e5d04378c199b2f44"}, - {file = "pydantic-1.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e78578f0c7481c850d1c969aca9a65405887003484d24f6110458fb02cca7747"}, - {file = "pydantic-1.9.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5da164119602212a3fe7e3bc08911a89db4710ae51444b4224c2382fd09ad453"}, - {file = "pydantic-1.9.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ead3cd020d526f75b4188e0a8d71c0dbbe1b4b6b5dc0ea775a93aca16256aeb"}, - {file = "pydantic-1.9.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7d0f183b305629765910eaad707800d2f47c6ac5bcfb8c6397abdc30b69eeb15"}, - {file = "pydantic-1.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:f1a68f4f65a9ee64b6ccccb5bf7e17db07caebd2730109cb8a95863cfa9c4e55"}, - {file = "pydantic-1.9.2-py3-none-any.whl", hash = "sha256:78a4d6bdfd116a559aeec9a4cfe77dda62acc6233f8b56a716edad2651023e5e"}, - {file = "pydantic-1.9.2.tar.gz", hash = "sha256:8cb0bc509bfb71305d7a59d00163d5f9fc4530f0881ea32c74ff4f74c85f3d3d"}, + {file = "pydantic-1.10.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bb6ad4489af1bac6955d38ebcb95079a836af31e4c4f74aba1ca05bb9f6027bd"}, + {file = "pydantic-1.10.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a1f5a63a6dfe19d719b1b6e6106561869d2efaca6167f84f5ab9347887d78b98"}, + {file = "pydantic-1.10.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:352aedb1d71b8b0736c6d56ad2bd34c6982720644b0624462059ab29bd6e5912"}, + {file = "pydantic-1.10.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19b3b9ccf97af2b7519c42032441a891a5e05c68368f40865a90eb88833c2559"}, + {file = "pydantic-1.10.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e9069e1b01525a96e6ff49e25876d90d5a563bc31c658289a8772ae186552236"}, + {file = "pydantic-1.10.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:355639d9afc76bcb9b0c3000ddcd08472ae75318a6eb67a15866b87e2efa168c"}, + {file = "pydantic-1.10.2-cp310-cp310-win_amd64.whl", hash = "sha256:ae544c47bec47a86bc7d350f965d8b15540e27e5aa4f55170ac6a75e5f73b644"}, + {file = "pydantic-1.10.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4c805731c33a8db4b6ace45ce440c4ef5336e712508b4d9e1aafa617dc9907f"}, + {file = "pydantic-1.10.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d49f3db871575e0426b12e2f32fdb25e579dea16486a26e5a0474af87cb1ab0a"}, + {file = "pydantic-1.10.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37c90345ec7dd2f1bcef82ce49b6235b40f282b94d3eec47e801baf864d15525"}, + {file = "pydantic-1.10.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b5ba54d026c2bd2cb769d3468885f23f43710f651688e91f5fb1edcf0ee9283"}, + {file = "pydantic-1.10.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:05e00dbebbe810b33c7a7362f231893183bcc4251f3f2ff991c31d5c08240c42"}, + {file = "pydantic-1.10.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2d0567e60eb01bccda3a4df01df677adf6b437958d35c12a3ac3e0f078b0ee52"}, + {file = "pydantic-1.10.2-cp311-cp311-win_amd64.whl", hash = "sha256:c6f981882aea41e021f72779ce2a4e87267458cc4d39ea990729e21ef18f0f8c"}, + {file = "pydantic-1.10.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c4aac8e7103bf598373208f6299fa9a5cfd1fc571f2d40bf1dd1955a63d6eeb5"}, + {file = "pydantic-1.10.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a7b66c3f499108b448f3f004801fcd7d7165fb4200acb03f1c2402da73ce4c"}, + {file = "pydantic-1.10.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bedf309630209e78582ffacda64a21f96f3ed2e51fbf3962d4d488e503420254"}, + {file = "pydantic-1.10.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9300fcbebf85f6339a02c6994b2eb3ff1b9c8c14f502058b5bf349d42447dcf5"}, + {file = "pydantic-1.10.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:216f3bcbf19c726b1cc22b099dd409aa371f55c08800bcea4c44c8f74b73478d"}, + {file = "pydantic-1.10.2-cp37-cp37m-win_amd64.whl", hash = "sha256:dd3f9a40c16daf323cf913593083698caee97df2804aa36c4b3175d5ac1b92a2"}, + {file = "pydantic-1.10.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b97890e56a694486f772d36efd2ba31612739bc6f3caeee50e9e7e3ebd2fdd13"}, + {file = "pydantic-1.10.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9cabf4a7f05a776e7793e72793cd92cc865ea0e83a819f9ae4ecccb1b8aa6116"}, + {file = "pydantic-1.10.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06094d18dd5e6f2bbf93efa54991c3240964bb663b87729ac340eb5014310624"}, + {file = "pydantic-1.10.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc78cc83110d2f275ec1970e7a831f4e371ee92405332ebfe9860a715f8336e1"}, + {file = "pydantic-1.10.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ee433e274268a4b0c8fde7ad9d58ecba12b069a033ecc4645bb6303c062d2e9"}, + {file = "pydantic-1.10.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7c2abc4393dea97a4ccbb4ec7d8658d4e22c4765b7b9b9445588f16c71ad9965"}, + {file = "pydantic-1.10.2-cp38-cp38-win_amd64.whl", hash = "sha256:0b959f4d8211fc964772b595ebb25f7652da3f22322c007b6fed26846a40685e"}, + {file = "pydantic-1.10.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c33602f93bfb67779f9c507e4d69451664524389546bacfe1bee13cae6dc7488"}, + {file = "pydantic-1.10.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5760e164b807a48a8f25f8aa1a6d857e6ce62e7ec83ea5d5c5a802eac81bad41"}, + {file = "pydantic-1.10.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6eb843dcc411b6a2237a694f5e1d649fc66c6064d02b204a7e9d194dff81eb4b"}, + {file = "pydantic-1.10.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b8795290deaae348c4eba0cebb196e1c6b98bdbe7f50b2d0d9a4a99716342fe"}, + {file = "pydantic-1.10.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e0bedafe4bc165ad0a56ac0bd7695df25c50f76961da29c050712596cf092d6d"}, + {file = "pydantic-1.10.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2e05aed07fa02231dbf03d0adb1be1d79cabb09025dd45aa094aa8b4e7b9dcda"}, + {file = "pydantic-1.10.2-cp39-cp39-win_amd64.whl", hash = "sha256:c1ba1afb396148bbc70e9eaa8c06c1716fdddabaf86e7027c5988bae2a829ab6"}, + {file = "pydantic-1.10.2-py3-none-any.whl", hash = "sha256:1b6ee725bd6e83ec78b1aa32c5b1fa67a3a65badddde3976bca5fe4568f27709"}, + {file = "pydantic-1.10.2.tar.gz", hash = "sha256:91b8e218852ef6007c2b98cd861601c6a09f1aa32bbbb74fab5b1c33d4a1e410"}, ] pyflakes = [ {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, @@ -1895,16 +1865,16 @@ pygments = [ {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"}, ] pymdown-extensions = [ - {file = "pymdown_extensions-9.5-py3-none-any.whl", hash = "sha256:ec141c0f4983755349f0c8710416348d1a13753976c028186ed14f190c8061c4"}, - {file = "pymdown_extensions-9.5.tar.gz", hash = "sha256:3ef2d998c0d5fa7eb09291926d90d69391283561cf6306f85cd588a5eb5befa0"}, + {file = "pymdown_extensions-9.6-py3-none-any.whl", hash = "sha256:1e36490adc7bfcef1fdb21bb0306e93af99cff8ec2db199bd17e3bf009768c11"}, + {file = "pymdown_extensions-9.6.tar.gz", hash = "sha256:b956b806439bbff10f726103a941266beb03fbe99f897c7d5e774d7170339ad9"}, ] pyparsing = [ - {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, - {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, + {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, + {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, ] pytest = [ - {file = "pytest-7.0.1-py3-none-any.whl", hash = "sha256:9ce3ff477af913ecf6321fe337b93a2c0dcf2a0a1439c43f5452112c1e4280db"}, - {file = "pytest-7.0.1.tar.gz", hash = "sha256:e30905a0c131d3d94b89624a1cc5afec3e0ba2fbdb151867d8e0ebd49850f171"}, + {file = "pytest-7.1.3-py3-none-any.whl", hash = "sha256:1377bda3466d70b55e3f5cecfa55bb7cfcf219c7964629b967c37cf0bda818b7"}, + {file = "pytest-7.1.3.tar.gz", hash = "sha256:4f365fec2dff9c1162f834d9f18af1ba13062db0c708bf7b946f8a5c76180c39"}, ] pytest-asyncio = [ {file = "pytest-asyncio-0.16.0.tar.gz", hash = "sha256:7496c5977ce88c34379df64a66459fe395cd05543f0a2f837016e7144391fcfb"}, @@ -1923,8 +1893,8 @@ pytest-forked = [ {file = "pytest_forked-1.4.0-py3-none-any.whl", hash = "sha256:bbbb6717efc886b9d64537b41fb1497cfaf3c9601276be8da2cccfea5a3c8ad8"}, ] pytest-mock = [ - {file = "pytest-mock-3.6.1.tar.gz", hash = "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62"}, - {file = "pytest_mock-3.6.1-py3-none-any.whl", hash = "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3"}, + {file = "pytest-mock-3.10.0.tar.gz", hash = "sha256:fbbdb085ef7c252a326fd8cdcac0aa3b1333d8811f131bdcc701002e1be7ed4f"}, + {file = "pytest_mock-3.10.0-py3-none-any.whl", hash = "sha256:f4c973eeae0282963eb293eb173ce91b091a79c1334455acfac9ddee8a1c784b"}, ] pytest-xdist = [ {file = "pytest-xdist-2.5.0.tar.gz", hash = "sha256:4580deca3ff04ddb2ac53eba39d76cb5dd5edeac050cb6fbc768b0dd712b4edf"}, @@ -2035,8 +2005,8 @@ radon = [ {file = "radon-5.1.0.tar.gz", hash = "sha256:cb1d8752e5f862fb9e20d82b5f758cbc4fb1237c92c9a66450ea0ea7bf29aeee"}, ] requests = [ - {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, - {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, + {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, + {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, ] retry = [ {file = "retry-0.9.2-py2.py3-none-any.whl", hash = "sha256:ccddf89761fa2c726ab29391837d4327f819ea14d244c232a1d24c67a2f98606"}, @@ -2079,12 +2049,8 @@ retry = [ {file = "ruamel.yaml.clib-0.2.6.tar.gz", hash = "sha256:4ff604ce439abb20794f05613c374759ce10e3595d1867764dd1ae675b85acbd"}, ] s3transfer = [ - {file = "s3transfer-0.5.2-py3-none-any.whl", hash = "sha256:7a6f4c4d1fdb9a2b640244008e142cbc2cd3ae34b386584ef044dd0f27101971"}, - {file = "s3transfer-0.5.2.tar.gz", hash = "sha256:95c58c194ce657a5f4fb0b9e60a84968c808888aed628cd98ab8771fe1db98ed"}, -] -setuptools = [ - {file = "setuptools-59.6.0-py3-none-any.whl", hash = "sha256:4ce92f1e1f8f01233ee9952c04f6b81d1e02939d6e1b488428154974a4d0783e"}, - {file = "setuptools-59.6.0.tar.gz", hash = "sha256:22c7348c6d2976a52632c67f7ab0cdf40147db7789f9aed18734643fe9cf3373"}, + {file = "s3transfer-0.6.0-py3-none-any.whl", hash = "sha256:06176b74f3a15f61f1b4f25a1fc29a4429040b7647133a463da8fa5bd28d5ecd"}, + {file = "s3transfer-0.6.0.tar.gz", hash = "sha256:2ed07d3866f523cc561bf4a00fc5535827981b117dd7876f036b0c1aca42c947"}, ] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, @@ -2095,12 +2061,12 @@ smmap = [ {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, ] stevedore = [ - {file = "stevedore-3.5.0-py3-none-any.whl", hash = "sha256:a547de73308fd7e90075bb4d301405bebf705292fa90a90fc3bcf9133f58616c"}, - {file = "stevedore-3.5.0.tar.gz", hash = "sha256:f40253887d8712eaa2bb0ea3830374416736dc8ec0e22f5a65092c1174c44335"}, + {file = "stevedore-3.5.1-py3-none-any.whl", hash = "sha256:df36e6c003264de286d6e589994552d3254052e7fc6a117753d87c471f06de2a"}, + {file = "stevedore-3.5.1.tar.gz", hash = "sha256:1fecadf3d7805b940227f10e6a0140b202c9a24ba5c60cb539159046dc11e8d7"}, ] tomli = [ - {file = "tomli-1.2.3-py3-none-any.whl", hash = "sha256:e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c"}, - {file = "tomli-1.2.3.tar.gz", hash = "sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f"}, + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] typed-ast = [ {file = "typed_ast-1.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4"}, @@ -2128,13 +2094,17 @@ typed-ast = [ {file = "typed_ast-1.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1"}, {file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"}, ] +typeguard = [ + {file = "typeguard-2.13.3-py3-none-any.whl", hash = "sha256:5e3e3be01e887e7eafae5af63d1f36c849aaa94e3a0112097312aabfa16284f1"}, + {file = "typeguard-2.13.3.tar.gz", hash = "sha256:00edaa8da3a133674796cf5ea87d9f4b4c367d77476e185e80251cc13dfbb8c4"}, +] types-requests = [ {file = "types-requests-2.28.11.2.tar.gz", hash = "sha256:fdcd7bd148139fb8eef72cf4a41ac7273872cad9e6ada14b11ff5dfdeee60ed3"}, {file = "types_requests-2.28.11.2-py3-none-any.whl", hash = "sha256:14941f8023a80b16441b3b46caffcbfce5265fd14555844d6029697824b5a2ef"}, ] types-urllib3 = [ - {file = "types-urllib3-1.26.24.tar.gz", hash = "sha256:a1b3aaea7dda3eb1b51699ee723aadd235488e4dc4648e030f09bc429ecff42f"}, - {file = "types_urllib3-1.26.24-py3-none-any.whl", hash = "sha256:cf7918503d02d3576e503bbfb419b0e047c4617653bba09624756ab7175e15c9"}, + {file = "types-urllib3-1.26.25.tar.gz", hash = "sha256:5aef0e663724eef924afa8b320b62ffef2c1736c1fa6caecfc9bc6c8ae2c3def"}, + {file = "types_urllib3-1.26.25-py3-none-any.whl", hash = "sha256:c1d78cef7bd581e162e46c20a57b2e1aa6ebecdcf01fd0713bb90978ff3e3427"}, ] typing-extensions = [ {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, @@ -2242,6 +2212,6 @@ xenon = [ {file = "xenon-0.9.0.tar.gz", hash = "sha256:d2b9cb6c6260f771a432c1e588e51fddb17858f88f73ef641e7532f7a5f58fb8"}, ] zipp = [ - {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"}, - {file = "zipp-3.6.0.tar.gz", hash = "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832"}, + {file = "zipp-3.9.0-py3-none-any.whl", hash = "sha256:972cfa31bc2fedd3fa838a51e9bc7e64b7fb725a8c00e7431554311f180e9980"}, + {file = "zipp-3.9.0.tar.gz", hash = "sha256:3a7af91c3db40ec72dd9d154ae18e008c69efe8ca88dde4f9a731bb82fe2f9eb"}, ] diff --git a/pyproject.toml b/pyproject.toml index eeb60fd0d4a..74b7bffeb3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ classifiers=[ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Natural Language :: English", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", @@ -20,7 +19,7 @@ keywords = ["aws_lambda_powertools", "aws", "tracing", "logging", "lambda", "pow license = "MIT-0" [tool.poetry.dependencies] -python = "^3.6.2" +python = "^3.7.4" aws-xray-sdk = "^2.8.0" fastjsonschema = "^2.14.5" boto3 = "^1.18" @@ -39,6 +38,7 @@ flake8-debugger = "^4.0.0" flake8-fixme = "^1.1.1" flake8-isort = "^4.1.2" flake8-variables-names = "^0.0.4" +flake8-black = "^0.3.3" isort = "^5.10.1" pytest-cov = "^4.0.0" pytest-mock = "^3.5.1" @@ -54,25 +54,25 @@ mike = "^0.6.0" mypy = "^0.971" retry = "^0.9.2" pytest-xdist = "^2.5.0" -aws-cdk-lib = "^2.23.0" +aws-cdk-lib = "^2.38.1" +"aws-cdk.aws-apigatewayv2-alpha" = "^2.38.1-alpha.0" +"aws-cdk.aws-apigatewayv2-integrations-alpha" = "^2.38.1-alpha.0" pytest-benchmark = "^3.4.1" -mypy-boto3-appconfig = { version = "^1.24.29", python = ">=3.7" } -mypy-boto3-cloudformation = { version = "^1.24.0", python = ">=3.7" } -mypy-boto3-cloudwatch = { version = "^1.24.35", python = ">=3.7" } -mypy-boto3-dynamodb = { version = "^1.24.74", python = ">=3.7" } -mypy-boto3-lambda = { version = "^1.24.0", python = ">=3.7" } -mypy-boto3-logs = { version = "^1.24.0", python = ">=3.7" } -mypy-boto3-secretsmanager = { version = "^1.24.83", python = ">=3.7" } -mypy-boto3-ssm = { version = "^1.24.81", python = ">=3.7" } -mypy-boto3-s3 = { version = "^1.24.76", python = ">=3.7" } -mypy-boto3-xray = { version = "^1.24.0", python = ">=3.7" } -types-requests = "^2.28.11" -typing-extensions = { version = "^4.4.0", python = ">=3.7" } python-snappy = "^0.6.1" -mkdocs-material = { version = "^8.5.4", python = ">=3.7" } -filelock = { version = "^3.8.0", python = ">=3.7" } -# Maintenance: 2022-09-19 pinned mako to fix vulnerability as a pdoc3 dependency. Remove once we drop python 3.6. -Mako = {version = "1.2.3", python = ">=3.7"} +mypy-boto3-appconfig = "^1.24.29" +mypy-boto3-cloudformation = "^1.24.0" +mypy-boto3-cloudwatch = "^1.24.35" +mypy-boto3-dynamodb = "^1.24.60" +mypy-boto3-lambda = "^1.24.0" +mypy-boto3-logs = "^1.24.0" +mypy-boto3-secretsmanager = "^1.24.11" +mypy-boto3-ssm = "^1.24.0" +mypy-boto3-s3 = "^1.24.0" +mypy-boto3-xray = "^1.24.0" +types-requests = "^2.28.11" +typing-extensions = "^4.4.0" +mkdocs-material = "^8.5.4" +filelock = "^3.8.0" [tool.poetry.extras] pydantic = ["pydantic", "email-validator"] diff --git a/tests/e2e/event_handler/__init__.py b/tests/e2e/event_handler/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/event_handler/conftest.py b/tests/e2e/event_handler/conftest.py new file mode 100644 index 00000000000..207ec443456 --- /dev/null +++ b/tests/e2e/event_handler/conftest.py @@ -0,0 +1,28 @@ +from pathlib import Path + +import pytest + +from tests.e2e.event_handler.infrastructure import EventHandlerStack + + +@pytest.fixture(autouse=True, scope="module") +def infrastructure(request: pytest.FixtureRequest, lambda_layer_arn: str): + """Setup and teardown logic for E2E test infrastructure + + Parameters + ---------- + request : pytest.FixtureRequest + pytest request fixture to introspect absolute path to test being executed + lambda_layer_arn : str + Lambda Layer ARN + + Yields + ------ + Dict[str, str] + CloudFormation Outputs from deployed infrastructure + """ + stack = EventHandlerStack(handlers_dir=Path(f"{request.path.parent}/handlers"), layer_arn=lambda_layer_arn) + try: + yield stack.deploy() + finally: + stack.delete() diff --git a/tests/e2e/event_handler/handlers/alb_handler.py b/tests/e2e/event_handler/handlers/alb_handler.py new file mode 100644 index 00000000000..4c3f4f9dac3 --- /dev/null +++ b/tests/e2e/event_handler/handlers/alb_handler.py @@ -0,0 +1,18 @@ +from aws_lambda_powertools.event_handler import ALBResolver, Response, content_types + +app = ALBResolver() + + +@app.get("/todos") +def hello(): + return Response( + status_code=200, + content_type=content_types.TEXT_PLAIN, + body="Hello world", + cookies=["CookieMonster", "MonsterCookie"], + headers={"Foo": ["bar", "zbr"]}, + ) + + +def lambda_handler(event, context): + return app.resolve(event, context) diff --git a/tests/e2e/event_handler/handlers/api_gateway_http_handler.py b/tests/e2e/event_handler/handlers/api_gateway_http_handler.py new file mode 100644 index 00000000000..1a20b730285 --- /dev/null +++ b/tests/e2e/event_handler/handlers/api_gateway_http_handler.py @@ -0,0 +1,18 @@ +from aws_lambda_powertools.event_handler import APIGatewayHttpResolver, Response, content_types + +app = APIGatewayHttpResolver() + + +@app.get("/todos") +def hello(): + return Response( + status_code=200, + content_type=content_types.TEXT_PLAIN, + body="Hello world", + cookies=["CookieMonster", "MonsterCookie"], + headers={"Foo": ["bar", "zbr"]}, + ) + + +def lambda_handler(event, context): + return app.resolve(event, context) diff --git a/tests/e2e/event_handler/handlers/api_gateway_rest_handler.py b/tests/e2e/event_handler/handlers/api_gateway_rest_handler.py new file mode 100644 index 00000000000..2f5ad0b94fa --- /dev/null +++ b/tests/e2e/event_handler/handlers/api_gateway_rest_handler.py @@ -0,0 +1,18 @@ +from aws_lambda_powertools.event_handler import APIGatewayRestResolver, Response, content_types + +app = APIGatewayRestResolver() + + +@app.get("/todos") +def hello(): + return Response( + status_code=200, + content_type=content_types.TEXT_PLAIN, + body="Hello world", + cookies=["CookieMonster", "MonsterCookie"], + headers={"Foo": ["bar", "zbr"]}, + ) + + +def lambda_handler(event, context): + return app.resolve(event, context) diff --git a/tests/e2e/event_handler/handlers/lambda_function_url_handler.py b/tests/e2e/event_handler/handlers/lambda_function_url_handler.py new file mode 100644 index 00000000000..3fd4b46ea28 --- /dev/null +++ b/tests/e2e/event_handler/handlers/lambda_function_url_handler.py @@ -0,0 +1,18 @@ +from aws_lambda_powertools.event_handler import LambdaFunctionUrlResolver, Response, content_types + +app = LambdaFunctionUrlResolver() + + +@app.get("/todos") +def hello(): + return Response( + status_code=200, + content_type=content_types.TEXT_PLAIN, + body="Hello world", + cookies=["CookieMonster", "MonsterCookie"], + headers={"Foo": ["bar", "zbr"]}, + ) + + +def lambda_handler(event, context): + return app.resolve(event, context) diff --git a/tests/e2e/event_handler/infrastructure.py b/tests/e2e/event_handler/infrastructure.py new file mode 100644 index 00000000000..62421b8aac9 --- /dev/null +++ b/tests/e2e/event_handler/infrastructure.py @@ -0,0 +1,81 @@ +from pathlib import Path +from typing import Dict, Optional + +from aws_cdk import CfnOutput +from aws_cdk import aws_apigateway as apigwv1 +from aws_cdk import aws_apigatewayv2_alpha as apigwv2 +from aws_cdk import aws_apigatewayv2_integrations_alpha as apigwv2integrations +from aws_cdk import aws_ec2 as ec2 +from aws_cdk import aws_elasticloadbalancingv2 as elbv2 +from aws_cdk import aws_elasticloadbalancingv2_targets as targets +from aws_cdk.aws_lambda import Function, FunctionUrlAuthType + +from tests.e2e.utils.infrastructure import BaseInfrastructure + + +class EventHandlerStack(BaseInfrastructure): + FEATURE_NAME = "event-handlers" + + def __init__(self, handlers_dir: Path, feature_name: str = FEATURE_NAME, layer_arn: str = "") -> None: + super().__init__(feature_name, handlers_dir, layer_arn) + + def create_resources(self): + functions = self.create_lambda_functions() + + self._create_alb(function=functions["AlbHandler"]) + self._create_api_gateway_rest(function=functions["ApiGatewayRestHandler"]) + self._create_api_gateway_http(function=functions["ApiGatewayHttpHandler"]) + self._create_lambda_function_url(function=functions["LambdaFunctionUrlHandler"]) + + def _create_alb(self, function: Function): + vpc = ec2.Vpc(self.stack, "EventHandlerVPC", max_azs=2) + + alb = elbv2.ApplicationLoadBalancer(self.stack, "ALB", vpc=vpc, internet_facing=True) + CfnOutput(self.stack, "ALBDnsName", value=alb.load_balancer_dns_name) + + self._create_alb_listener(alb=alb, name="Basic", port=80, function=function) + self._create_alb_listener( + alb=alb, + name="MultiValueHeader", + port=8080, + function=function, + attributes={"lambda.multi_value_headers.enabled": "true"}, + ) + + def _create_alb_listener( + self, + alb: elbv2.ApplicationLoadBalancer, + name: str, + port: int, + function: Function, + attributes: Optional[Dict[str, str]] = None, + ): + listener = alb.add_listener(name, port=port, protocol=elbv2.ApplicationProtocol.HTTP) + target = listener.add_targets(f"ALB{name}Target", targets=[targets.LambdaTarget(function)]) + if attributes is not None: + for key, value in attributes.items(): + target.set_attribute(key, value) + CfnOutput(self.stack, f"ALB{name}ListenerPort", value=str(port)) + + def _create_api_gateway_http(self, function: Function): + apigw = apigwv2.HttpApi(self.stack, "APIGatewayHTTP", create_default_stage=True) + apigw.add_routes( + path="/todos", + methods=[apigwv2.HttpMethod.GET], + integration=apigwv2integrations.HttpLambdaIntegration("TodosIntegration", function), + ) + + CfnOutput(self.stack, "APIGatewayHTTPUrl", value=(apigw.url or "")) + + def _create_api_gateway_rest(self, function: Function): + apigw = apigwv1.RestApi(self.stack, "APIGatewayRest", deploy_options=apigwv1.StageOptions(stage_name="dev")) + + todos = apigw.root.add_resource("todos") + todos.add_method("GET", apigwv1.LambdaIntegration(function, proxy=True)) + + CfnOutput(self.stack, "APIGatewayRestUrl", value=apigw.url) + + def _create_lambda_function_url(self, function: Function): + # Maintenance: move auth to IAM when we create sigv4 builders + function_url = function.add_function_url(auth_type=FunctionUrlAuthType.NONE) + CfnOutput(self.stack, "LambdaFunctionUrl", value=function_url.url) diff --git a/tests/e2e/event_handler/test_header_serializer.py b/tests/e2e/event_handler/test_header_serializer.py new file mode 100644 index 00000000000..2b1d51bfb3d --- /dev/null +++ b/tests/e2e/event_handler/test_header_serializer.py @@ -0,0 +1,141 @@ +import pytest +from requests import Request + +from tests.e2e.utils import data_fetcher + + +@pytest.fixture +def alb_basic_listener_endpoint(infrastructure: dict) -> str: + dns_name = infrastructure.get("ALBDnsName") + port = infrastructure.get("ALBBasicListenerPort", "") + return f"http://{dns_name}:{port}" + + +@pytest.fixture +def alb_multi_value_header_listener_endpoint(infrastructure: dict) -> str: + dns_name = infrastructure.get("ALBDnsName") + port = infrastructure.get("ALBMultiValueHeaderListenerPort", "") + return f"http://{dns_name}:{port}" + + +@pytest.fixture +def apigw_rest_endpoint(infrastructure: dict) -> str: + return infrastructure.get("APIGatewayRestUrl", "") + + +@pytest.fixture +def apigw_http_endpoint(infrastructure: dict) -> str: + return infrastructure.get("APIGatewayHTTPUrl", "") + + +@pytest.fixture +def lambda_function_url_endpoint(infrastructure: dict) -> str: + return infrastructure.get("LambdaFunctionUrl", "") + + +def test_alb_headers_serializer(alb_basic_listener_endpoint): + # GIVEN + url = f"{alb_basic_listener_endpoint}/todos" + + # WHEN + response = data_fetcher.get_http_response(Request(method="GET", url=url)) + + # THEN + assert response.status_code == 200 + assert response.content == b"Hello world" + assert response.headers["content-type"] == "text/plain" + + # Only the last header for key "Foo" should be set + assert response.headers["Foo"] == "zbr" + + # Only the last cookie should be set + assert "MonsterCookie" in response.cookies.keys() + assert "CookieMonster" not in response.cookies.keys() + + +def test_alb_multi_value_headers_serializer(alb_multi_value_header_listener_endpoint): + # GIVEN + url = f"{alb_multi_value_header_listener_endpoint}/todos" + + # WHEN + response = data_fetcher.get_http_response(Request(method="GET", url=url)) + + # THEN + assert response.status_code == 200 + assert response.content == b"Hello world" + assert response.headers["content-type"] == "text/plain" + + # Only the last header for key "Foo" should be set + assert "Foo" in response.headers + foo_headers = [x.strip() for x in response.headers["Foo"].split(",")] + assert sorted(foo_headers) == ["bar", "zbr"] + + # Only the last cookie should be set + assert "MonsterCookie" in response.cookies.keys() + assert "CookieMonster" in response.cookies.keys() + + +def test_api_gateway_rest_headers_serializer(apigw_rest_endpoint): + # GIVEN + url = f"{apigw_rest_endpoint}/todos" + + # WHEN + response = data_fetcher.get_http_response(Request(method="GET", url=url)) + + # THEN + assert response.status_code == 200 + assert response.content == b"Hello world" + assert response.headers["content-type"] == "text/plain" + + # Only the last header for key "Foo" should be set + assert "Foo" in response.headers + foo_headers = [x.strip() for x in response.headers["Foo"].split(",")] + assert sorted(foo_headers) == ["bar", "zbr"] + + # Only the last cookie should be set + assert "MonsterCookie" in response.cookies.keys() + assert "CookieMonster" in response.cookies.keys() + + +def test_api_gateway_http_headers_serializer(apigw_http_endpoint): + # GIVEN + url = f"{apigw_http_endpoint}/todos" + + # WHEN + response = data_fetcher.get_http_response(Request(method="GET", url=url)) + + # THEN + assert response.status_code == 200 + assert response.content == b"Hello world" + assert response.headers["content-type"] == "text/plain" + + # Only the last header for key "Foo" should be set + assert "Foo" in response.headers + foo_headers = [x.strip() for x in response.headers["Foo"].split(",")] + assert sorted(foo_headers) == ["bar", "zbr"] + + # Only the last cookie should be set + assert "MonsterCookie" in response.cookies.keys() + assert "CookieMonster" in response.cookies.keys() + + +def test_lambda_function_url_headers_serializer(lambda_function_url_endpoint): + # GIVEN + url = f"{lambda_function_url_endpoint}todos" # the function url endpoint already has the trailing / + + # WHEN + response = data_fetcher.get_http_response(Request(method="GET", url=url)) + + # THEN + assert response.status_code == 200 + assert response.content == b"Hello world" + assert response.headers["content-type"] == "text/plain" + + # Only the last header for key "Foo" should be set + assert "Foo" in response.headers + foo_headers = [x.strip() for x in response.headers["Foo"].split(",")] + assert sorted(foo_headers) == ["bar", "zbr"] + + # Only the last cookie should be set + assert "MonsterCookie" in response.cookies.keys() + assert "CookieMonster" in response.cookies.keys() diff --git a/tests/e2e/utils/data_fetcher/__init__.py b/tests/e2e/utils/data_fetcher/__init__.py index 43024f9946f..be6909537e5 100644 --- a/tests/e2e/utils/data_fetcher/__init__.py +++ b/tests/e2e/utils/data_fetcher/__init__.py @@ -1,4 +1,4 @@ -from tests.e2e.utils.data_fetcher.common import get_lambda_response +from tests.e2e.utils.data_fetcher.common import get_http_response, get_lambda_response from tests.e2e.utils.data_fetcher.logs import get_logs from tests.e2e.utils.data_fetcher.metrics import get_metrics from tests.e2e.utils.data_fetcher.traces import get_traces diff --git a/tests/e2e/utils/data_fetcher/common.py b/tests/e2e/utils/data_fetcher/common.py index 2de8838dc74..29f97eab2de 100644 --- a/tests/e2e/utils/data_fetcher/common.py +++ b/tests/e2e/utils/data_fetcher/common.py @@ -2,8 +2,12 @@ from typing import Optional, Tuple import boto3 +import requests as requests from mypy_boto3_lambda import LambdaClient from mypy_boto3_lambda.type_defs import InvocationResponseTypeDef +from requests import Request, Response +from requests.exceptions import RequestException +from retry import retry def get_lambda_response( @@ -13,3 +17,11 @@ def get_lambda_response( payload = payload or "" execution_time = datetime.utcnow() return client.invoke(FunctionName=lambda_arn, InvocationType="RequestResponse", Payload=payload), execution_time + + +@retry(RequestException, delay=2, jitter=1.5, tries=5) +def get_http_response(request: Request) -> Response: + session = requests.Session() + result = session.send(request.prepare()) + result.raise_for_status() + return result diff --git a/tests/e2e/utils/infrastructure.py b/tests/e2e/utils/infrastructure.py index 87be83d2f96..97714b95cfc 100644 --- a/tests/e2e/utils/infrastructure.py +++ b/tests/e2e/utils/infrastructure.py @@ -24,9 +24,11 @@ from filelock import FileLock from mypy_boto3_cloudformation import CloudFormationClient +from aws_lambda_powertools import PACKAGE_PATH from tests.e2e.utils.asset import Assets PYTHON_RUNTIME_VERSION = f"V{''.join(map(str, sys.version_info[:2]))}" +SOURCE_CODE_ROOT_PATH = PACKAGE_PATH.parent logger = logging.getLogger(__name__) @@ -57,7 +59,7 @@ def __init__(self, feature_name: str, handlers_dir: Path, layer_arn: str = "") - # NOTE: Investigate why cdk.Environment in Stack # changes synthesized asset (no object_key in asset manifest) - self.app = App() + self.app = App(outdir=str(SOURCE_CODE_ROOT_PATH / ".cdk")) self.stack = Stack(self.app, self.stack_name) self.session = boto3.Session() self.cfn: CloudFormationClient = self.session.client("cloudformation") @@ -66,7 +68,7 @@ def __init__(self, feature_name: str, handlers_dir: Path, layer_arn: str = "") - self.account_id = self.session.client("sts").get_caller_identity()["Account"] self.region = self.session.region_name - def create_lambda_functions(self, function_props: Optional[Dict] = None): + def create_lambda_functions(self, function_props: Optional[Dict] = None) -> Dict[str, Function]: """Create Lambda functions available under handlers_dir It creates CloudFormation Outputs for every function found in PascalCase. For example, @@ -78,6 +80,11 @@ def create_lambda_functions(self, function_props: Optional[Dict] = None): function_props: Optional[Dict] Dictionary representing CDK Lambda FunctionProps to override defaults + Returns + ------- + output: Dict[str, Function] + A dict with PascalCased function names and the corresponding CDK Function object + Examples -------- @@ -106,6 +113,8 @@ def create_lambda_functions(self, function_props: Optional[Dict] = None): layer = LayerVersion.from_layer_version_arn(self.stack, "layer-arn", layer_version_arn=self.layer_arn) function_settings_override = function_props or {} + output: Dict[str, Function] = {} + for fn in handlers: fn_name = fn.stem fn_name_pascal_case = fn_name.title().replace("_", "") # basic_handler -> BasicHandler @@ -133,6 +142,10 @@ def create_lambda_functions(self, function_props: Optional[Dict] = None): # CFN Outputs only support hyphen hence pascal case self.add_cfn_output(name=fn_name_pascal_case, value=function.function_name, arn=function.function_arn) + output[fn_name_pascal_case] = function + + return output + def deploy(self) -> Dict[str, str]: """Creates CloudFormation Stack and return stack outputs as dict @@ -296,7 +309,7 @@ def _create_layer(self) -> str: layer_version_name="aws-lambda-powertools-e2e-test", compatible_runtimes=[PythonVersion[PYTHON_RUNTIME_VERSION].value["runtime"]], code=Code.from_asset( - path=".", + path=str(SOURCE_CODE_ROOT_PATH), bundling=BundlingOptions( image=DockerImage.from_build( str(Path(__file__).parent), diff --git a/tests/events/albMultiValueHeadersEvent.json b/tests/events/albMultiValueHeadersEvent.json new file mode 100644 index 00000000000..6b34709605c --- /dev/null +++ b/tests/events/albMultiValueHeadersEvent.json @@ -0,0 +1,35 @@ +{ + "requestContext": { + "elb": { + "targetGroupArn": "arn:aws:elasticloadbalancing:eu-central-1:1234567890:targetgroup/alb-c-Targe-11GDXTPQ7663S/804a67588bfdc10f" + } + }, + "httpMethod": "GET", + "path": "/todos", + "multiValueQueryStringParameters": {}, + "multiValueHeaders": { + "accept": [ + "*/*" + ], + "host": [ + "alb-c-LoadB-14POFKYCLBNSF-1815800096.eu-central-1.elb.amazonaws.com" + ], + "user-agent": [ + "curl/7.79.1" + ], + "x-amzn-trace-id": [ + "Root=1-62fa9327-21cdd4da4c6db451490a5fb7" + ], + "x-forwarded-for": [ + "123.123.123.123" + ], + "x-forwarded-port": [ + "80" + ], + "x-forwarded-proto": [ + "http" + ] + }, + "body": "", + "isBase64Encoded": false +} diff --git a/tests/functional/event_handler/test_api_gateway.py b/tests/functional/event_handler/test_api_gateway.py index ae2c3eee43e..daf31e58e59 100644 --- a/tests/functional/event_handler/test_api_gateway.py +++ b/tests/functional/event_handler/test_api_gateway.py @@ -92,7 +92,26 @@ def get_lambda() -> Response: # THEN process event correctly # AND set the current_event type as APIGatewayProxyEvent assert result["statusCode"] == 200 - assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] + + +def test_api_gateway_v1_cookies(): + # GIVEN a Http API V1 proxy type event + app = APIGatewayRestResolver() + cookie = "CookieMonster" + + @app.get("/my/path") + def get_lambda() -> Response: + assert isinstance(app.current_event, APIGatewayProxyEvent) + return Response(200, content_types.TEXT_PLAIN, "Hello world", cookies=[cookie]) + + # WHEN calling the event handler + result = app(LOAD_GW_EVENT, {}) + + # THEN process event correctly + # AND set the current_event type as APIGatewayProxyEvent + assert result["statusCode"] == 200 + assert result["multiValueHeaders"]["Set-Cookie"] == [cookie] def test_api_gateway(): @@ -110,7 +129,7 @@ def get_lambda() -> Response: # THEN process event correctly # AND set the current_event type as APIGatewayProxyEvent assert result["statusCode"] == 200 - assert result["headers"]["Content-Type"] == content_types.TEXT_HTML + assert result["multiValueHeaders"]["Content-Type"] == [content_types.TEXT_HTML] assert result["body"] == "foo" @@ -132,9 +151,30 @@ def my_path() -> Response: # AND set the current_event type as APIGatewayProxyEventV2 assert result["statusCode"] == 200 assert result["headers"]["Content-Type"] == content_types.TEXT_PLAIN + assert "Cookies" not in result["headers"] assert result["body"] == "tom" +def test_api_gateway_v2_cookies(): + # GIVEN a Http API V2 proxy type event + app = APIGatewayHttpResolver() + cookie = "CookieMonster" + + @app.post("/my/path") + def my_path() -> Response: + assert isinstance(app.current_event, APIGatewayProxyEventV2) + return Response(200, content_types.TEXT_PLAIN, "Hello world", cookies=[cookie]) + + # WHEN calling the event handler + result = app(load_event("apiGatewayProxyV2Event.json"), {}) + + # THEN process event correctly + # AND set the current_event type as APIGatewayProxyEventV2 + assert result["statusCode"] == 200 + assert result["headers"]["Content-Type"] == content_types.TEXT_PLAIN + assert result["cookies"] == [cookie] + + def test_include_rule_matching(): # GIVEN app = ApiGatewayResolver() @@ -149,7 +189,7 @@ def get_lambda(my_id: str, name: str) -> Response: # THEN assert result["statusCode"] == 200 - assert result["headers"]["Content-Type"] == content_types.TEXT_HTML + assert result["multiValueHeaders"]["Content-Type"] == [content_types.TEXT_HTML] assert result["body"] == "path" @@ -200,7 +240,7 @@ def handler(event, context): result = handler(LOAD_GW_EVENT, None) assert result["statusCode"] == 404 # AND cors headers are not returned - assert "Access-Control-Allow-Origin" not in result["headers"] + assert "Access-Control-Allow-Origin" not in result["multiValueHeaders"] def test_cors(): @@ -223,17 +263,17 @@ def handler(event, context): result = handler(LOAD_GW_EVENT, None) # THEN the headers should include cors headers - assert "headers" in result - headers = result["headers"] - assert headers["Content-Type"] == content_types.TEXT_HTML - assert headers["Access-Control-Allow-Origin"] == "*" + assert "multiValueHeaders" in result + headers = result["multiValueHeaders"] + assert headers["Content-Type"] == [content_types.TEXT_HTML] + assert headers["Access-Control-Allow-Origin"] == ["*"] assert "Access-Control-Allow-Credentials" not in headers - assert headers["Access-Control-Allow-Headers"] == ",".join(sorted(CORSConfig._REQUIRED_HEADERS)) + assert headers["Access-Control-Allow-Headers"] == [",".join(sorted(CORSConfig._REQUIRED_HEADERS))] # THEN for routes without cors flag return no cors headers mock_event = {"path": "/my/request", "httpMethod": "GET"} result = handler(mock_event, None) - assert "Access-Control-Allow-Origin" not in result["headers"] + assert "Access-Control-Allow-Origin" not in result["multiValueHeaders"] def test_cors_preflight_body_is_empty_not_null(): @@ -272,8 +312,8 @@ def handler(event, context): assert isinstance(body, str) decompress = zlib.decompress(base64.b64decode(body), wbits=zlib.MAX_WBITS | 16).decode("UTF-8") assert decompress == expected_value - headers = result["headers"] - assert headers["Content-Encoding"] == "gzip" + headers = result["multiValueHeaders"] + assert headers["Content-Encoding"] == ["gzip"] def test_base64_encode(): @@ -292,8 +332,8 @@ def read_image() -> Response: assert result["isBase64Encoded"] is True body = result["body"] assert isinstance(body, str) - headers = result["headers"] - assert headers["Content-Encoding"] == "gzip" + headers = result["multiValueHeaders"] + assert headers["Content-Encoding"] == ["gzip"] def test_compress_no_accept_encoding(): @@ -348,9 +388,9 @@ def handler(event, context): result = handler({"path": "/success", "httpMethod": "GET"}, None) # THEN return the set Cache-Control - headers = result["headers"] - assert headers["Content-Type"] == content_types.TEXT_HTML - assert headers["Cache-Control"] == "max-age=600" + headers = result["multiValueHeaders"] + assert headers["Content-Type"] == [content_types.TEXT_HTML] + assert headers["Cache-Control"] == ["max-age=600"] def test_cache_control_non_200(): @@ -369,9 +409,9 @@ def handler(event, context): result = handler({"path": "/fails", "httpMethod": "DELETE"}, None) # THEN return a Cache-Control of "no-cache" - headers = result["headers"] - assert headers["Content-Type"] == content_types.TEXT_HTML - assert headers["Cache-Control"] == "no-cache" + headers = result["multiValueHeaders"] + assert headers["Content-Type"] == [content_types.TEXT_HTML] + assert headers["Cache-Control"] == ["no-cache"] def test_rest_api(): @@ -388,7 +428,7 @@ def rest_func() -> Dict: # THEN automatically process this as a json rest api response assert result["statusCode"] == 200 - assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] expected_str = json.dumps(expected_dict, separators=(",", ":"), indent=None, cls=Encoder) assert result["body"] == expected_str @@ -403,7 +443,7 @@ def rest_func() -> Response: status_code=404, content_type="used-if-not-set-in-header", body="Not found", - headers={"Content-Type": "header-content-type-wins", "custom": "value"}, + headers={"Content-Type": ["header-content-type-wins"], "custom": ["value"]}, ) # WHEN calling the event handler @@ -411,8 +451,8 @@ def rest_func() -> Response: # THEN the result can include some additional field control like overriding http headers assert result["statusCode"] == 404 - assert result["headers"]["Content-Type"] == "header-content-type-wins" - assert result["headers"]["custom"] == "value" + assert result["multiValueHeaders"]["Content-Type"] == ["header-content-type-wins"] + assert result["multiValueHeaders"]["custom"] == ["value"] assert result["body"] == "Not found" @@ -441,16 +481,16 @@ def another_one(): result = app(event, None) # THEN routes by default return the custom cors headers - assert "headers" in result - headers = result["headers"] - assert headers["Content-Type"] == content_types.APPLICATION_JSON - assert headers["Access-Control-Allow-Origin"] == cors_config.allow_origin - expected_allows_headers = ",".join(sorted(set(allow_header + cors_config._REQUIRED_HEADERS))) + assert "multiValueHeaders" in result + headers = result["multiValueHeaders"] + assert headers["Content-Type"] == [content_types.APPLICATION_JSON] + assert headers["Access-Control-Allow-Origin"] == [cors_config.allow_origin] + expected_allows_headers = [",".join(sorted(set(allow_header + cors_config._REQUIRED_HEADERS)))] assert headers["Access-Control-Allow-Headers"] == expected_allows_headers - assert headers["Access-Control-Expose-Headers"] == ",".join(cors_config.expose_headers) - assert headers["Access-Control-Max-Age"] == str(cors_config.max_age) + assert headers["Access-Control-Expose-Headers"] == [",".join(cors_config.expose_headers)] + assert headers["Access-Control-Max-Age"] == [str(cors_config.max_age)] assert "Access-Control-Allow-Credentials" in headers - assert headers["Access-Control-Allow-Credentials"] == "true" + assert headers["Access-Control-Allow-Credentials"] == ["true"] # AND custom cors was set on the app assert isinstance(app._cors, CORSConfig) @@ -459,7 +499,7 @@ def another_one(): # AND routes without cors don't include "Access-Control" headers event = {"path": "/another-one", "httpMethod": "GET"} result = app(event, None) - headers = result["headers"] + headers = result["multiValueHeaders"] assert "Access-Control-Allow-Origin" not in headers @@ -474,7 +514,7 @@ def test_no_content_response(): # THEN return an None body and no Content-Type header assert result["statusCode"] == response.status_code assert result["body"] is None - headers = result["headers"] + headers = result["multiValueHeaders"] assert "Content-Type" not in headers @@ -489,7 +529,7 @@ def test_no_matches_with_cors(): # THEN return a 404 # AND cors headers are returned assert result["statusCode"] == 404 - assert "Access-Control-Allow-Origin" in result["headers"] + assert "Access-Control-Allow-Origin" in result["multiValueHeaders"] assert "Not found" in result["body"] @@ -517,10 +557,10 @@ def post_no_cors(): # AND include Access-Control-Allow-Methods of the cors methods used assert result["statusCode"] == 204 assert result["body"] == "" - headers = result["headers"] + headers = result["multiValueHeaders"] assert "Content-Type" not in headers - assert "Access-Control-Allow-Origin" in result["headers"] - assert headers["Access-Control-Allow-Methods"] == "DELETE,GET,OPTIONS" + assert "Access-Control-Allow-Origin" in result["multiValueHeaders"] + assert headers["Access-Control-Allow-Methods"] == [",".join(sorted(["DELETE", "GET", "OPTIONS"]))] def test_custom_preflight_response(): @@ -535,7 +575,7 @@ def custom_preflight(): status_code=200, content_type=content_types.TEXT_HTML, body="Foo", - headers={"Access-Control-Allow-Methods": "CUSTOM"}, + headers={"Access-Control-Allow-Methods": ["CUSTOM"]}, ) @app.route(method="CUSTOM", rule="/some-call", cors=True) @@ -548,10 +588,10 @@ def custom_method(): # THEN return the custom preflight response assert result["statusCode"] == 200 assert result["body"] == "Foo" - headers = result["headers"] - assert headers["Content-Type"] == content_types.TEXT_HTML - assert "Access-Control-Allow-Origin" in result["headers"] - assert headers["Access-Control-Allow-Methods"] == "CUSTOM" + headers = result["multiValueHeaders"] + assert headers["Content-Type"] == [content_types.TEXT_HTML] + assert "Access-Control-Allow-Origin" in result["multiValueHeaders"] + assert headers["Access-Control-Allow-Methods"] == ["CUSTOM"] def test_service_error_responses(json_dump): @@ -569,7 +609,7 @@ def bad_request_error(): # THEN return the bad request error response # AND status code equals 400 assert result["statusCode"] == 400 - assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] expected = {"statusCode": 400, "message": "Missing required parameter"} assert result["body"] == json_dump(expected) @@ -584,7 +624,7 @@ def unauthorized_error(): # THEN return the unauthorized error response # AND status code equals 401 assert result["statusCode"] == 401 - assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] expected = {"statusCode": 401, "message": "Unauthorized"} assert result["body"] == json_dump(expected) @@ -599,7 +639,7 @@ def not_found_error(): # THEN return the not found error response # AND status code equals 404 assert result["statusCode"] == 404 - assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] expected = {"statusCode": 404, "message": "Not found"} assert result["body"] == json_dump(expected) @@ -614,7 +654,7 @@ def internal_server_error(): # THEN return the internal server error response # AND status code equals 500 assert result["statusCode"] == 500 - assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] expected = {"statusCode": 500, "message": "Internal server error"} assert result["body"] == json_dump(expected) @@ -629,8 +669,8 @@ def service_error(): # THEN return the service error response # AND status code equals 502 assert result["statusCode"] == 502 - assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON - assert "Access-Control-Allow-Origin" in result["headers"] + assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] + assert "Access-Control-Allow-Origin" in result["multiValueHeaders"] expected = {"statusCode": 502, "message": "Something went wrong!"} assert result["body"] == json_dump(expected) @@ -653,8 +693,8 @@ def raises_error(): # AND include the exception traceback in the response assert result["statusCode"] == 500 assert "Traceback (most recent call last)" in result["body"] - headers = result["headers"] - assert headers["Content-Type"] == content_types.TEXT_PLAIN + headers = result["multiValueHeaders"] + assert headers["Content-Type"] == [content_types.TEXT_PLAIN] def test_debug_unhandled_exceptions_debug_off(): @@ -951,7 +991,7 @@ def base(): # THEN process event correctly assert result["statusCode"] == 200 - assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] def test_api_gateway_app_router(): @@ -969,7 +1009,7 @@ def foo(): # THEN process event correctly assert result["statusCode"] == 200 - assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] def test_api_gateway_app_router_with_params(): @@ -995,7 +1035,7 @@ def foo(account_id): # THEN process event correctly assert result["statusCode"] == 200 - assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] def test_api_gateway_app_router_with_prefix(): @@ -1014,7 +1054,7 @@ def foo(): # THEN process event correctly assert result["statusCode"] == 200 - assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] def test_api_gateway_app_router_with_prefix_equals_path(): @@ -1034,7 +1074,7 @@ def foo(): # THEN process event correctly assert result["statusCode"] == 200 - assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] def test_api_gateway_app_router_with_different_methods(): @@ -1084,7 +1124,7 @@ def patch_func(): 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"] + assert "Access-Control-Allow-Origin" not in result["multiValueHeaders"] def test_duplicate_routes(): @@ -1143,11 +1183,11 @@ def foo(account_id): # THEN events are processed correctly assert get_result["statusCode"] == 200 - assert get_result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + assert get_result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] assert post_result["statusCode"] == 200 - assert post_result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + assert post_result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] assert put_result["statusCode"] == 404 - assert put_result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + assert put_result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] def test_api_gateway_app_router_access_to_resolver(): @@ -1166,7 +1206,7 @@ def foo(): result = app(LOAD_GW_EVENT, {}) assert result["statusCode"] == 200 - assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] def test_exception_handler(): @@ -1192,7 +1232,7 @@ def get_lambda() -> Response: # THEN call the exception_handler assert result["statusCode"] == 418 - assert result["headers"]["Content-Type"] == content_types.TEXT_HTML + assert result["multiValueHeaders"]["Content-Type"] == [content_types.TEXT_HTML] assert result["body"] == "Foo!" @@ -1219,7 +1259,7 @@ def get_lambda() -> Response: # THEN call the exception_handler assert result["statusCode"] == 500 - assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] assert result["body"] == "CUSTOM ERROR FORMAT" @@ -1238,7 +1278,7 @@ def handle_not_found(exc: NotFoundError) -> Response: # THEN call the exception_handler assert result["statusCode"] == 404 - assert result["headers"]["Content-Type"] == content_types.TEXT_PLAIN + assert result["multiValueHeaders"]["Content-Type"] == [content_types.TEXT_PLAIN] assert result["body"] == "I am a teapot!" @@ -1276,7 +1316,7 @@ def get_lambda() -> Response: # THEN call the exception_handler assert result["statusCode"] == 400 - assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] expected = {"statusCode": 400, "message": "Bad request"} assert result["body"] == json_dump(expected) diff --git a/tests/functional/event_handler/test_lambda_function_url.py b/tests/functional/event_handler/test_lambda_function_url.py index 4d4d5c39f35..69465cba7f4 100644 --- a/tests/functional/event_handler/test_lambda_function_url.py +++ b/tests/functional/event_handler/test_lambda_function_url.py @@ -25,9 +25,30 @@ def foo(): # AND set the current_event type as LambdaFunctionUrlEvent assert result["statusCode"] == 200 assert result["headers"]["Content-Type"] == content_types.TEXT_HTML + assert "Cookies" not in result["headers"] assert result["body"] == "foo" +def test_lambda_function_url_event_with_cookies(): + # GIVEN a Lambda Function Url type event + app = LambdaFunctionUrlResolver() + cookie = "CookieMonster" + + @app.get("/") + def foo(): + assert isinstance(app.current_event, LambdaFunctionUrlEvent) + assert app.lambda_context == {} + return Response(200, content_types.TEXT_PLAIN, "foo", cookies=[cookie]) + + # WHEN calling the event handler + result = app(load_event("lambdaFunctionUrlEvent.json"), {}) + + # THEN process event correctly + # AND set the current_event type as LambdaFunctionUrlEvent + assert result["statusCode"] == 200 + assert result["cookies"] == [cookie] + + def test_lambda_function_url_no_matches(): # GIVEN a Lambda Function Url type event app = LambdaFunctionUrlResolver() diff --git a/tests/functional/test_headers_serializer.py b/tests/functional/test_headers_serializer.py new file mode 100644 index 00000000000..8a27ce8baa8 --- /dev/null +++ b/tests/functional/test_headers_serializer.py @@ -0,0 +1,147 @@ +from collections import defaultdict + +import pytest + +from aws_lambda_powertools.shared.headers_serializer import ( + HttpApiHeadersSerializer, + MultiValueHeadersSerializer, + SingleValueHeadersSerializer, +) + + +def test_http_api_headers_serializer(): + cookies = ["UUID=12345", "SSID=0xdeadbeef"] + header_values = ["bar", "zbr"] + headers = {"Foo": header_values} + + serializer = HttpApiHeadersSerializer() + payload = serializer.serialize(headers=headers, cookies=cookies) + + assert payload["cookies"] == cookies + assert payload["headers"]["Foo"] == ", ".join(header_values) + + +def test_http_api_headers_serializer_with_empty_values(): + serializer = HttpApiHeadersSerializer() + payload = serializer.serialize(headers={}, cookies=[]) + assert payload == {"headers": {}, "cookies": []} + + +def test_http_api_headers_serializer_with_headers_only(): + content_type = "text/html" + serializer = HttpApiHeadersSerializer() + payload = serializer.serialize(headers={"Content-Type": [content_type]}, cookies=[]) + assert payload["headers"]["Content-Type"] == content_type + + +def test_http_api_headers_serializer_with_single_headers_only(): + content_type = "text/html" + serializer = HttpApiHeadersSerializer() + payload = serializer.serialize(headers={"Content-Type": content_type}, cookies=[]) + assert payload["headers"]["Content-Type"] == content_type + + +def test_http_api_headers_serializer_with_cookies_only(): + cookies = ["UUID=12345", "SSID=0xdeadbeef"] + serializer = HttpApiHeadersSerializer() + payload = serializer.serialize(headers={}, cookies=cookies) + assert payload["cookies"] == cookies + + +def test_multi_value_headers_serializer(): + cookies = ["UUID=12345", "SSID=0xdeadbeef"] + header_values = ["bar", "zbr"] + headers = {"Foo": header_values} + + serializer = MultiValueHeadersSerializer() + payload = serializer.serialize(headers=headers, cookies=cookies) + + assert payload["multiValueHeaders"]["Set-Cookie"] == cookies + assert payload["multiValueHeaders"]["Foo"] == header_values + + +def test_multi_value_headers_serializer_with_headers_only(): + content_type = "text/html" + serializer = MultiValueHeadersSerializer() + payload = serializer.serialize(headers={"Content-Type": [content_type]}, cookies=[]) + assert payload["multiValueHeaders"]["Content-Type"] == [content_type] + + +def test_multi_value_headers_serializer_with_single_headers_only(): + content_type = "text/html" + serializer = MultiValueHeadersSerializer() + payload = serializer.serialize(headers={"Content-Type": content_type}, cookies=[]) + assert payload["multiValueHeaders"]["Content-Type"] == [content_type] + + +def test_multi_value_headers_serializer_with_cookies_only(): + cookie = "UUID=12345" + serializer = MultiValueHeadersSerializer() + payload = serializer.serialize(headers={}, cookies=[cookie]) + assert payload["multiValueHeaders"]["Set-Cookie"] == [cookie] + + +def test_multi_value_headers_serializer_with_empty_values(): + serializer = MultiValueHeadersSerializer() + payload = serializer.serialize(headers={}, cookies=[]) + assert payload["multiValueHeaders"] == defaultdict(list) + + +def test_single_value_headers_serializer(): + cookie = "UUID=12345" + content_type = "text/html" + headers = {"Content-Type": [content_type]} + + serializer = SingleValueHeadersSerializer() + payload = serializer.serialize(headers=headers, cookies=[cookie]) + assert payload["headers"]["Content-Type"] == content_type + assert payload["headers"]["Set-Cookie"] == cookie + + +def test_single_value_headers_serializer_with_headers_only(): + content_type = "text/html" + serializer = SingleValueHeadersSerializer() + payload = serializer.serialize(headers={"Content-Type": [content_type]}, cookies=[]) + assert payload["headers"]["Content-Type"] == content_type + + +def test_single_value_headers_serializer_with_single_headers_only(): + content_type = "text/html" + serializer = SingleValueHeadersSerializer() + payload = serializer.serialize(headers={"Content-Type": content_type}, cookies=[]) + assert payload["headers"]["Content-Type"] == content_type + + +def test_single_value_headers_serializer_with_cookies_only(): + cookie = "UUID=12345" + serializer = SingleValueHeadersSerializer() + payload = serializer.serialize(headers={}, cookies=[cookie]) + assert payload["headers"] == {"Set-Cookie": cookie} + + +def test_single_value_headers_serializer_with_empty_values(): + serializer = SingleValueHeadersSerializer() + payload = serializer.serialize(headers={}, cookies=[]) + assert payload["headers"] == {} + + +def test_single_value_headers_with_multiple_cookies_warning(): + cookies = ["UUID=12345", "SSID=0xdeadbeef"] + warning_message = "Can't encode more than one cookie in the response. Sending the last cookie only." + serializer = SingleValueHeadersSerializer() + + with pytest.warns(match=warning_message): + payload = serializer.serialize(cookies=cookies, headers={}) + + assert payload["headers"]["Set-Cookie"] == cookies[-1] + + +def test_single_value_headers_with_multiple_header_values_warning(): + headers = {"Foo": ["bar", "zbr"]} + warning_message = "Can't encode more than one header value for the same key." + serializer = SingleValueHeadersSerializer() + + with pytest.warns(match=warning_message): + payload = serializer.serialize(cookies=[], headers=headers) + + assert payload["headers"]["Foo"] == headers["Foo"][-1] From 90b45aaef48b3fe407d0d4d09e712720d1de88e1 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Mon, 29 Aug 2022 18:00:47 +0200 Subject: [PATCH 02/30] docs(homepage): note about v2 version --- docs/index.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/index.md b/docs/index.md index 3ba70df740c..dccfd49eb33 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,6 +5,9 @@ description: AWS Lambda Powertools for Python +???+ danger + This documentation is for v2 that is not yet released. + A suite of utilities for AWS Lambda functions to ease adopting best practices such as tracing, structured logging, custom metrics, idempotency, batching, and more. ???+ note From c83c6352c077b643df582c3b01ed906f9278120d Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Thu, 1 Sep 2022 15:04:44 +0200 Subject: [PATCH 03/30] refactor(batch): remove legacy sqs_batch_processor (#1492) --- .../utilities/batch/__init__.py | 6 - aws_lambda_powertools/utilities/batch/base.py | 8 +- .../utilities/batch/exceptions.py | 13 - aws_lambda_powertools/utilities/batch/sqs.py | 250 ------------- docs/upgrade.md | 87 +++++ docs/utilities/batch.md | 214 +---------- tests/functional/test_utilities_batch.py | 353 +----------------- tests/unit/test_utilities_batch.py | 141 ------- 8 files changed, 101 insertions(+), 971 deletions(-) delete mode 100644 aws_lambda_powertools/utilities/batch/sqs.py delete mode 100644 tests/unit/test_utilities_batch.py diff --git a/aws_lambda_powertools/utilities/batch/__init__.py b/aws_lambda_powertools/utilities/batch/__init__.py index 7db0781232c..08c35560b3f 100644 --- a/aws_lambda_powertools/utilities/batch/__init__.py +++ b/aws_lambda_powertools/utilities/batch/__init__.py @@ -13,10 +13,6 @@ batch_processor, ) from aws_lambda_powertools.utilities.batch.exceptions import ExceptionInfo -from aws_lambda_powertools.utilities.batch.sqs import ( - PartialSQSProcessor, - sqs_batch_processor, -) __all__ = ( "BatchProcessor", @@ -24,8 +20,6 @@ "ExceptionInfo", "EventType", "FailureResponse", - "PartialSQSProcessor", "SuccessResponse", "batch_processor", - "sqs_batch_processor", ) diff --git a/aws_lambda_powertools/utilities/batch/base.py b/aws_lambda_powertools/utilities/batch/base.py index f2d7cd2ed74..e4a869a1e54 100644 --- a/aws_lambda_powertools/utilities/batch/base.py +++ b/aws_lambda_powertools/utilities/batch/base.py @@ -208,19 +208,19 @@ def batch_processor( Lambda's Context record_handler: Callable Callable to process each record from the batch - processor: PartialSQSProcessor + processor: BasePartialProcessor Batch Processor to handle partial failure cases Examples -------- - **Processes Lambda's event with PartialSQSProcessor** + **Processes Lambda's event with a BasePartialProcessor** - >>> from aws_lambda_powertools.utilities.batch import batch_processor, PartialSQSProcessor + >>> from aws_lambda_powertools.utilities.batch import batch_processor, BatchProcessor >>> >>> def record_handler(record): >>> return record["body"] >>> - >>> @batch_processor(record_handler=record_handler, processor=PartialSQSProcessor()) + >>> @batch_processor(record_handler=record_handler, processor=BatchProcessor()) >>> def handler(event, context): >>> return {"StatusCode": 200} diff --git a/aws_lambda_powertools/utilities/batch/exceptions.py b/aws_lambda_powertools/utilities/batch/exceptions.py index d90c25f12bc..d541d18d18f 100644 --- a/aws_lambda_powertools/utilities/batch/exceptions.py +++ b/aws_lambda_powertools/utilities/batch/exceptions.py @@ -24,19 +24,6 @@ def format_exceptions(self, parent_exception_str): return "\n".join(exception_list) -class SQSBatchProcessingError(BaseBatchProcessingError): - """When at least one message within a batch could not be processed""" - - def __init__(self, msg="", child_exceptions: Optional[List[ExceptionInfo]] = None): - super().__init__(msg, child_exceptions) - - # Overriding this method so we can output all child exception tracebacks when we raise this exception to prevent - # errors being lost. See https://github.com/awslabs/aws-lambda-powertools-python/issues/275 - def __str__(self): - parent_exception_str = super(SQSBatchProcessingError, self).__str__() - return self.format_exceptions(parent_exception_str) - - class BatchProcessingError(BaseBatchProcessingError): """When all batch records failed to be processed""" diff --git a/aws_lambda_powertools/utilities/batch/sqs.py b/aws_lambda_powertools/utilities/batch/sqs.py deleted file mode 100644 index 7b234c1372e..00000000000 --- a/aws_lambda_powertools/utilities/batch/sqs.py +++ /dev/null @@ -1,250 +0,0 @@ -# -*- coding: utf-8 -*- - -""" -Batch SQS utilities -""" -import logging -import math -import sys -import warnings -from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import Any, Callable, Dict, List, Optional, Tuple, cast - -import boto3 -from botocore.config import Config - -from aws_lambda_powertools.utilities.data_classes.sqs_event import SQSRecord - -from ...middleware_factory import lambda_handler_decorator -from .base import BasePartialProcessor -from .exceptions import SQSBatchProcessingError - -logger = logging.getLogger(__name__) - - -class PartialSQSProcessor(BasePartialProcessor): - """ - Amazon SQS batch processor to delete successes from the Queue. - - The whole batch will be processed, even if failures occur. After all records are processed, - SQSBatchProcessingError will be raised if there were any failures, causing messages to - be returned to the SQS queue. This behaviour can be disabled by passing suppress_exception. - - Parameters - ---------- - config: Config - botocore config object - suppress_exception: bool, optional - Supress exception raised if any messages fail processing, by default False - boto3_session : boto3.session.Session, optional - Boto3 session to use for AWS API communication - - - Example - ------- - **Process batch triggered by SQS** - - >>> from aws_lambda_powertools.utilities.batch import PartialSQSProcessor - >>> - >>> def record_handler(record): - >>> return record["body"] - >>> - >>> def handler(event, context): - >>> records = event["Records"] - >>> processor = PartialSQSProcessor() - >>> - >>> with processor(records=records, handler=record_handler): - >>> result = processor.process() - >>> - >>> # Case a partial failure occurred, all successful executions - >>> # have been deleted from the queue after context's exit. - >>> - >>> return result - - """ - - def __init__( - self, - config: Optional[Config] = None, - suppress_exception: bool = False, - boto3_session: Optional[boto3.session.Session] = None, - ): - """ - Initializes sqs client. - """ - config = config or Config() - session = boto3_session or boto3.session.Session() - self.client = session.client("sqs", config=config) - self.suppress_exception = suppress_exception - self.max_message_batch = 10 - - warnings.warn( - "The sqs_batch_processor decorator and PartialSQSProcessor class are now deprecated, " - "and will be removed in the next major version. " - "Please follow the upgrade guide at " - "https://awslabs.github.io/aws-lambda-powertools-python/latest/utilities/batch/#legacy " - "to use the native batch_processor decorator or BatchProcessor class." - ) - - super().__init__() - - def _get_queue_url(self) -> Optional[str]: - """ - Format QueueUrl from first records entry - """ - if not getattr(self, "records", None): - return None - - *_, account_id, queue_name = self.records[0]["eventSourceARN"].split(":") - return f"{self.client._endpoint.host}/{account_id}/{queue_name}" - - def _get_entries_to_clean(self) -> List[Dict[str, str]]: - """ - Format messages to use in batch deletion - """ - entries = [] - # success_messages has generic type of union of SQS, Dynamodb and Kinesis Streams records or Pydantic models. - # Here we get SQS Record only - messages = cast(List[SQSRecord], self.success_messages) - for msg in messages: - entries.append({"Id": msg["messageId"], "ReceiptHandle": msg["receiptHandle"]}) - return entries - - def _process_record(self, record) -> Tuple: - """ - Process a record with instance's handler - - Parameters - ---------- - record: Any - An object to be processed. - """ - try: - result = self.handler(record=record) - return self.success_handler(record=record, result=result) - except Exception: - return self.failure_handler(record=record, exception=sys.exc_info()) - - def _prepare(self): - """ - Remove results from previous execution. - """ - self.success_messages.clear() - self.fail_messages.clear() - - def _clean(self) -> Optional[List]: - """ - Delete messages from Queue in case of partial failure. - """ - - # If all messages were successful, fall back to the default SQS - - # Lambda behavior which deletes messages if Lambda responds successfully - if not self.fail_messages: - logger.debug(f"All {len(self.success_messages)} records successfully processed") - return None - - queue_url = self._get_queue_url() - if queue_url is None: - logger.debug("No queue url found") - return None - - entries_to_remove = self._get_entries_to_clean() - # Batch delete up to 10 messages at a time (SQS limit) - max_workers = math.ceil(len(entries_to_remove) / self.max_message_batch) - - if entries_to_remove: - with ThreadPoolExecutor(max_workers=max_workers) as executor: - futures, results = [], [] - while entries_to_remove: - futures.append( - executor.submit( - self._delete_messages, queue_url, entries_to_remove[: self.max_message_batch], self.client - ) - ) - entries_to_remove = entries_to_remove[self.max_message_batch :] - for future in as_completed(futures): - try: - logger.debug("Deleted batch of processed messages from SQS") - results.append(future.result()) - except Exception: - logger.exception("Couldn't remove batch of processed messages from SQS") - raise - if self.suppress_exception: - logger.debug(f"{len(self.fail_messages)} records failed processing, but exceptions are suppressed") - else: - logger.debug(f"{len(self.fail_messages)} records failed processing, raising exception") - raise SQSBatchProcessingError( - msg=f"Not all records processed successfully. {len(self.exceptions)} individual errors logged " - f"separately below.", - child_exceptions=self.exceptions, - ) - - return results - - def _delete_messages(self, queue_url: str, entries_to_remove: List, sqs_client: Any): - delete_message_response = sqs_client.delete_message_batch( - QueueUrl=queue_url, - Entries=entries_to_remove, - ) - return delete_message_response - - -@lambda_handler_decorator -def sqs_batch_processor( - handler: Callable, - event: Dict, - context: Dict, - record_handler: Callable, - config: Optional[Config] = None, - suppress_exception: bool = False, - boto3_session: Optional[boto3.session.Session] = None, -): - """ - Middleware to handle SQS batch event processing - - Parameters - ---------- - handler: Callable - Lambda's handler - event: Dict - Lambda's Event - context: Dict - Lambda's Context - record_handler: Callable - Callable to process each record from the batch - config: Config - botocore config object - suppress_exception: bool, optional - Supress exception raised if any messages fail processing, by default False - boto3_session : boto3.session.Session, optional - Boto3 session to use for AWS API communication - - Examples - -------- - **Processes Lambda's event with PartialSQSProcessor** - - >>> from aws_lambda_powertools.utilities.batch import sqs_batch_processor - >>> - >>> def record_handler(record): - >>> return record["body"] - >>> - >>> @sqs_batch_processor(record_handler=record_handler) - >>> def handler(event, context): - >>> return {"StatusCode": 200} - - Limitations - ----------- - * Async batch processors - - """ - config = config or Config() - session = boto3_session or boto3.session.Session() - - processor = PartialSQSProcessor(config=config, suppress_exception=suppress_exception, boto3_session=session) - - records = event["Records"] - - with processor(records, record_handler): - processor.process() - - return handler(event, context) diff --git a/docs/upgrade.md b/docs/upgrade.md index 91ad54e42d3..20cf4aa25a6 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -11,6 +11,7 @@ The transition from Powertools for Python v1 to v2 is as painless as possible, a Changes at a glance: * The API for **event handler's `Response`** has minor changes to support multi value headers and cookies. +* The **legacy SQS batch processor** was removed. ???+ important Powertools for Python v2 drops suport for Python 3.6, following the Python 3.6 End-Of-Life (EOL) reached on December 23, 2021. @@ -55,3 +56,89 @@ def get_todos(): cookies=["CookieName=CookieValue"] ) ``` + +## Legacy SQS Batch Processor + +The deprecated `PartialSQSProcessor` and `sqs_batch_processor` were removed. +You can migrate to the [native batch processing](https://aws.amazon.com/about-aws/whats-new/2021/11/aws-lambda-partial-batch-response-sqs-event-source/) capability by: + +1. If you use **`sqs_batch_decorator`** you can now use **`batch_processor`** decorator +2. If you use **`PartialSQSProcessor`** you can now use **`BatchProcessor`** +3. [Enable the functionality](../utilities/batch#required-resources) on SQS +4. Change your Lambda Handler to return the new response format + +=== "Decorator: Before" + + ```python hl_lines="1 6" + from aws_lambda_powertools.utilities.batch import sqs_batch_processor + + def record_handler(record): + return do_something_with(record["body"]) + + @sqs_batch_processor(record_handler=record_handler) + def lambda_handler(event, context): + return {"statusCode": 200} + ``` + +=== "Decorator: After" + + ```python hl_lines="3 5 11" + import json + + from aws_lambda_powertools.utilities.batch import BatchProcessor, EventType, batch_processor + + processor = BatchProcessor(event_type=EventType.SQS) + + + def record_handler(record): + return do_something_with(record["body"]) + + @batch_processor(record_handler=record_handler, processor=processor) + def lambda_handler(event, context): + return processor.response() + ``` + +=== "Context manager: Before" + + ```python hl_lines="1-2 4 14 19" + from aws_lambda_powertools.utilities.batch import PartialSQSProcessor + from botocore.config import Config + + config = Config(region_name="us-east-1") + + def record_handler(record): + return_value = do_something_with(record["body"]) + return return_value + + + def lambda_handler(event, context): + records = event["Records"] + + processor = PartialSQSProcessor(config=config) + + with processor(records, record_handler): + result = processor.process() + + return result + ``` + +=== "Context manager: After" + + ```python hl_lines="1 11" + from aws_lambda_powertools.utilities.batch import BatchProcessor, EventType, batch_processor + + + def record_handler(record): + return_value = do_something_with(record["body"]) + return return_value + + def lambda_handler(event, context): + records = event["Records"] + + processor = BatchProcessor(event_type=EventType.SQS) + + with processor(records, record_handler): + result = processor.process() + + return processor.response() + ``` diff --git a/docs/utilities/batch.md b/docs/utilities/batch.md index c429ac24693..1bbba86c395 100644 --- a/docs/utilities/batch.md +++ b/docs/utilities/batch.md @@ -5,11 +5,6 @@ description: Utility The batch processing utility handles partial failures when processing batches from Amazon SQS, Amazon Kinesis Data Streams, and Amazon DynamoDB Streams. -???+ warning - The legacy `sqs_batch_processor` decorator and `PartialSQSProcessor` class are deprecated and are going to be removed soon. - - Please check the [migration guide](#migration-guide) for more information. - ## Key Features * Reports batch item failures to reduce number of retries for a record upon errors @@ -1213,215 +1208,16 @@ class MyProcessor(BatchProcessor): return super().failure_handler(record, exception) ``` -## Legacy - -???+ tip - This is kept for historical purposes. Use the new [BatchProcessor](#processing-messages-from-sqs) instead. - -### Migration guide - -???+ info - Keep reading if you are using `sqs_batch_processor` or `PartialSQSProcessor`. - -[As of Nov 2021](https://aws.amazon.com/about-aws/whats-new/2021/11/aws-lambda-partial-batch-response-sqs-event-source/){target="_blank"}, this is no longer needed as both SQS, Kinesis, and DynamoDB Streams offer this capability natively with one caveat - it's an [opt-in feature](#required-resources). - -Being a native feature, we no longer need to instantiate boto3 nor other customizations like exception suppressing – this lowers the cost of your Lambda function as you can delegate deleting partial failures to Lambda. - -???+ tip - It's also easier to test since it's mostly a [contract based response](https://docs.aws.amazon.com/lambda/latest/dg/with-sqs.html#sqs-batchfailurereporting-syntax){target="_blank"}. - -You can migrate in three steps: - -1. If you are using **`sqs_batch_decorator`** you can now use **`batch_processor`** decorator -2. If you were using **`PartialSQSProcessor`** you can now use **`BatchProcessor`** -3. Change your Lambda Handler to return the new response format - -=== "Decorator: Before" - - ```python hl_lines="1 6" - from aws_lambda_powertools.utilities.batch import sqs_batch_processor - - def record_handler(record): - return do_something_with(record["body"]) - - @sqs_batch_processor(record_handler=record_handler) - def lambda_handler(event, context): - return {"statusCode": 200} - ``` - -=== "Decorator: After" - - ```python hl_lines="3 5 11" - import json - - from aws_lambda_powertools.utilities.batch import BatchProcessor, EventType, batch_processor - - processor = BatchProcessor(event_type=EventType.SQS) - - - def record_handler(record): - return do_something_with(record["body"]) - - @batch_processor(record_handler=record_handler, processor=processor) - def lambda_handler(event, context): - return processor.response() - ``` - -=== "Context manager: Before" - - ```python hl_lines="1-2 4 14 19" - from aws_lambda_powertools.utilities.batch import PartialSQSProcessor - from botocore.config import Config - - config = Config(region_name="us-east-1") - - def record_handler(record): - return_value = do_something_with(record["body"]) - return return_value - - - def lambda_handler(event, context): - records = event["Records"] - - processor = PartialSQSProcessor(config=config) - - with processor(records, record_handler): - result = processor.process() - - return result - ``` - -=== "Context manager: After" - - ```python hl_lines="1 11" - from aws_lambda_powertools.utilities.batch import BatchProcessor, EventType, batch_processor - - - def record_handler(record): - return_value = do_something_with(record["body"]) - return return_value - - def lambda_handler(event, context): - records = event["Records"] - - processor = BatchProcessor(event_type=EventType.SQS) - - with processor(records, record_handler): - result = processor.process() - - return processor.response() - ``` - -### Customizing boto configuration - -The **`config`** and **`boto3_session`** parameters enable you to pass in a custom [botocore config object](https://botocore.amazonaws.com/v1/documentation/api/latest/reference/config.html) -or a custom [boto3 session](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/core/session.html) when using the `sqs_batch_processor` -decorator or `PartialSQSProcessor` class. - -> Custom config example - -=== "Decorator" - - ```python hl_lines="4 12" - from aws_lambda_powertools.utilities.batch import sqs_batch_processor - from botocore.config import Config - - config = Config(region_name="us-east-1") - - def record_handler(record): - # This will be called for each individual message from a batch - # It should raise an exception if the message was not processed successfully - return_value = do_something_with(record["body"]) - return return_value - - @sqs_batch_processor(record_handler=record_handler, config=config) - def lambda_handler(event, context): - return {"statusCode": 200} - ``` - -=== "Context manager" - - ```python hl_lines="4 16" - from aws_lambda_powertools.utilities.batch import PartialSQSProcessor - from botocore.config import Config - - config = Config(region_name="us-east-1") - - def record_handler(record): - # This will be called for each individual message from a batch - # It should raise an exception if the message was not processed successfully - return_value = do_something_with(record["body"]) - return return_value - - - def lambda_handler(event, context): - records = event["Records"] - - processor = PartialSQSProcessor(config=config) - - with processor(records, record_handler): - result = processor.process() - - return result - ``` - -> Custom boto3 session example - -=== "Decorator" - - ```python hl_lines="4 12" - from aws_lambda_powertools.utilities.batch import sqs_batch_processor - from botocore.config import Config - - session = boto3.session.Session() - - def record_handler(record): - # This will be called for each individual message from a batch - # It should raise an exception if the message was not processed successfully - return_value = do_something_with(record["body"]) - return return_value - - @sqs_batch_processor(record_handler=record_handler, boto3_session=session) - def lambda_handler(event, context): - return {"statusCode": 200} - ``` - -=== "Context manager" - - ```python hl_lines="4 16" - from aws_lambda_powertools.utilities.batch import PartialSQSProcessor - import boto3 - - session = boto3.session.Session() - - def record_handler(record): - # This will be called for each individual message from a batch - # It should raise an exception if the message was not processed successfully - return_value = do_something_with(record["body"]) - return return_value - - - def lambda_handler(event, context): - records = event["Records"] - - processor = PartialSQSProcessor(boto3_session=session) - - with processor(records, record_handler): - result = processor.process() - - return result - ``` - ### Suppressing exceptions -If you want to disable the default behavior where `SQSBatchProcessingError` is raised if there are any errors, you can pass the `suppress_exception` boolean argument. +If you want to disable the default behavior where `BatchProcessingError` is raised if there are any errors, you can pass the `suppress_exception` boolean argument. === "Decorator" ```python hl_lines="3" - from aws_lambda_powertools.utilities.batch import sqs_batch_processor + from aws_lambda_powertools.utilities.batch import batch_processor - @sqs_batch_processor(record_handler=record_handler, config=config, suppress_exception=True) + @batch_processor(record_handler=record_handler, suppress_exception=True) def lambda_handler(event, context): return {"statusCode": 200} ``` @@ -1429,9 +1225,9 @@ If you want to disable the default behavior where `SQSBatchProcessingError` is r === "Context manager" ```python hl_lines="3" - from aws_lambda_powertools.utilities.batch import PartialSQSProcessor + from aws_lambda_powertools.utilities.batch import BatchProcessor, EventType - processor = PartialSQSProcessor(config=config, suppress_exception=True) + processor = BatchProcessor(event_type=EventType.SQS, suppress_exception=True) with processor(records, record_handler): result = processor.process() diff --git a/tests/functional/test_utilities_batch.py b/tests/functional/test_utilities_batch.py index b5489fb7c62..2c72f4494b2 100644 --- a/tests/functional/test_utilities_batch.py +++ b/tests/functional/test_utilities_batch.py @@ -1,31 +1,14 @@ import json -import math from random import randint from typing import Callable, Dict, Optional -from unittest.mock import patch -from uuid import uuid4 import pytest from botocore.config import Config -from botocore.stub import Stubber - -from aws_lambda_powertools.utilities.batch import ( - BatchProcessor, - EventType, - PartialSQSProcessor, - batch_processor, - sqs_batch_processor, -) -from aws_lambda_powertools.utilities.batch.exceptions import ( - BatchProcessingError, - SQSBatchProcessingError, -) -from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import ( - DynamoDBRecord, -) -from aws_lambda_powertools.utilities.data_classes.kinesis_stream_event import ( - KinesisStreamRecord, -) + +from aws_lambda_powertools.utilities.batch import BatchProcessor, EventType, batch_processor +from aws_lambda_powertools.utilities.batch.exceptions import BatchProcessingError +from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import DynamoDBRecord +from aws_lambda_powertools.utilities.data_classes.kinesis_stream_event import KinesisStreamRecord from aws_lambda_powertools.utilities.data_classes.sqs_event import SQSRecord from aws_lambda_powertools.utilities.parser import BaseModel, validator from aws_lambda_powertools.utilities.parser.models import ( @@ -152,30 +135,6 @@ def config() -> Config: return Config(region_name="us-east-1") -@pytest.fixture(scope="function") -def partial_processor(config) -> PartialSQSProcessor: - return PartialSQSProcessor(config=config) - - -@pytest.fixture(scope="function") -def partial_processor_suppressed(config) -> PartialSQSProcessor: - return PartialSQSProcessor(config=config, suppress_exception=True) - - -@pytest.fixture(scope="function") -def stubbed_partial_processor(config) -> PartialSQSProcessor: - processor = PartialSQSProcessor(config=config) - with Stubber(processor.client) as stubber: - yield stubber, processor - - -@pytest.fixture(scope="function") -def stubbed_partial_processor_suppressed(config) -> PartialSQSProcessor: - processor = PartialSQSProcessor(config=config, suppress_exception=True) - with Stubber(processor.client) as stubber: - yield stubber, processor - - @pytest.fixture(scope="module") def order_event_factory() -> Callable: def factory(item: Dict) -> str: @@ -184,270 +143,6 @@ def factory(item: Dict) -> str: return factory -@pytest.fixture(scope="module") -def lambda_context() -> LambdaContext: - class DummyLambdaContext: - def __init__(self): - self.function_name = "test-func" - self.memory_limit_in_mb = 128 - self.invoked_function_arn = "arn:aws:lambda:eu-west-1:809313241234:function:test-func" - self.aws_request_id = f"{uuid4()}" - - return DummyLambdaContext - - -@pytest.mark.parametrize( - "success_messages_count", - ([1, 18, 34]), -) -def test_partial_sqs_processor_context_with_failure( - success_messages_count, sqs_event_factory, record_handler, partial_processor -): - """ - Test processor with one failing record and multiple processed records - """ - fail_record = sqs_event_factory("fail") - success_records = [sqs_event_factory("success") for i in range(0, success_messages_count)] - - records = [fail_record, *success_records] - - response = {"Successful": [{"Id": fail_record["messageId"]}], "Failed": []} - - with Stubber(partial_processor.client) as stubber: - for _ in range(0, math.ceil((success_messages_count / partial_processor.max_message_batch))): - stubber.add_response("delete_message_batch", response) - with pytest.raises(SQSBatchProcessingError) as error: - with partial_processor(records, record_handler) as ctx: - ctx.process() - - assert len(error.value.child_exceptions) == 1 - stubber.assert_no_pending_responses() - - -def test_partial_sqs_processor_context_with_failure_exception(sqs_event_factory, record_handler, partial_processor): - """ - Test processor with one failing record - """ - fail_record = sqs_event_factory("fail") - success_record = sqs_event_factory("success") - - records = [fail_record, success_record] - - with Stubber(partial_processor.client) as stubber: - stubber.add_client_error( - method="delete_message_batch", service_error_code="ServiceUnavailable", http_status_code=503 - ) - with pytest.raises(Exception) as error: - with partial_processor(records, record_handler) as ctx: - ctx.process() - - assert "ServiceUnavailable" in str(error.value) - stubber.assert_no_pending_responses() - - -def test_partial_sqs_processor_context_only_success(sqs_event_factory, record_handler, partial_processor): - """ - Test processor without failure - """ - first_record = sqs_event_factory("success") - second_record = sqs_event_factory("success") - - records = [first_record, second_record] - - with partial_processor(records, record_handler) as ctx: - result = ctx.process() - - assert result == [ - ("success", first_record["body"], first_record), - ("success", second_record["body"], second_record), - ] - - -def test_partial_sqs_processor_context_multiple_calls(sqs_event_factory, record_handler, partial_processor): - """ - Test processor without failure - """ - first_record = sqs_event_factory("success") - second_record = sqs_event_factory("success") - - records = [first_record, second_record] - - with partial_processor(records, record_handler) as ctx: - ctx.process() - - with partial_processor([first_record], record_handler) as ctx: - ctx.process() - - assert partial_processor.success_messages == [first_record] - - -def test_batch_processor_middleware_with_partial_sqs_processor(sqs_event_factory, record_handler, partial_processor): - """ - Test middleware's integration with PartialSQSProcessor - """ - - @batch_processor(record_handler=record_handler, processor=partial_processor) - def lambda_handler(event, context): - return True - - fail_record = sqs_event_factory("fail") - - event = {"Records": [sqs_event_factory("fail"), sqs_event_factory("fail"), sqs_event_factory("success")]} - response = {"Successful": [{"Id": fail_record["messageId"]}], "Failed": []} - - with Stubber(partial_processor.client) as stubber: - stubber.add_response("delete_message_batch", response) - with pytest.raises(SQSBatchProcessingError) as error: - lambda_handler(event, {}) - - assert len(error.value.child_exceptions) == 2 - stubber.assert_no_pending_responses() - - -@patch("aws_lambda_powertools.utilities.batch.sqs.PartialSQSProcessor") -def test_sqs_batch_processor_middleware( - patched_sqs_processor, sqs_event_factory, record_handler, stubbed_partial_processor -): - """ - Test middleware's integration with PartialSQSProcessor - """ - - @sqs_batch_processor(record_handler=record_handler) - def lambda_handler(event, context): - return True - - stubber, processor = stubbed_partial_processor - patched_sqs_processor.return_value = processor - - fail_record = sqs_event_factory("fail") - - event = {"Records": [sqs_event_factory("fail"), sqs_event_factory("success")]} - response = {"Successful": [{"Id": fail_record["messageId"]}], "Failed": []} - stubber.add_response("delete_message_batch", response) - with pytest.raises(SQSBatchProcessingError) as error: - lambda_handler(event, {}) - - assert len(error.value.child_exceptions) == 1 - stubber.assert_no_pending_responses() - - -def test_batch_processor_middleware_with_custom_processor(capsys, sqs_event_factory, record_handler, config): - """ - Test middlewares' integration with custom batch processor - """ - - class CustomProcessor(PartialSQSProcessor): - def failure_handler(self, record, exception): - print("Oh no ! It's a failure.") - return super().failure_handler(record, exception) - - processor = CustomProcessor(config=config) - - @batch_processor(record_handler=record_handler, processor=processor) - def lambda_handler(event, context): - return True - - fail_record = sqs_event_factory("fail") - - event = {"Records": [sqs_event_factory("fail"), sqs_event_factory("success")]} - response = {"Successful": [{"Id": fail_record["messageId"]}], "Failed": []} - - with Stubber(processor.client) as stubber: - stubber.add_response("delete_message_batch", response) - with pytest.raises(SQSBatchProcessingError) as error: - lambda_handler(event, {}) - - stubber.assert_no_pending_responses() - - assert len(error.value.child_exceptions) == 1 - assert capsys.readouterr().out == "Oh no ! It's a failure.\n" - - -def test_batch_processor_middleware_suppressed_exceptions( - sqs_event_factory, record_handler, partial_processor_suppressed -): - """ - Test middleware's integration with PartialSQSProcessor - """ - - @batch_processor(record_handler=record_handler, processor=partial_processor_suppressed) - def lambda_handler(event, context): - return True - - fail_record = sqs_event_factory("fail") - - event = {"Records": [sqs_event_factory("fail"), sqs_event_factory("fail"), sqs_event_factory("success")]} - response = {"Successful": [{"Id": fail_record["messageId"]}], "Failed": []} - - with Stubber(partial_processor_suppressed.client) as stubber: - stubber.add_response("delete_message_batch", response) - result = lambda_handler(event, {}) - - stubber.assert_no_pending_responses() - assert result is True - - -def test_partial_sqs_processor_suppressed_exceptions(sqs_event_factory, record_handler, partial_processor_suppressed): - """ - Test processor without failure - """ - - first_record = sqs_event_factory("success") - second_record = sqs_event_factory("fail") - records = [first_record, second_record] - - fail_record = sqs_event_factory("fail") - response = {"Successful": [{"Id": fail_record["messageId"]}], "Failed": []} - - with Stubber(partial_processor_suppressed.client) as stubber: - stubber.add_response("delete_message_batch", response) - with partial_processor_suppressed(records, record_handler) as ctx: - ctx.process() - - assert partial_processor_suppressed.success_messages == [first_record] - - -@patch("aws_lambda_powertools.utilities.batch.sqs.PartialSQSProcessor") -def test_sqs_batch_processor_middleware_suppressed_exception( - patched_sqs_processor, sqs_event_factory, record_handler, stubbed_partial_processor_suppressed -): - """ - Test middleware's integration with PartialSQSProcessor - """ - - @sqs_batch_processor(record_handler=record_handler) - def lambda_handler(event, context): - return True - - stubber, processor = stubbed_partial_processor_suppressed - patched_sqs_processor.return_value = processor - - fail_record = sqs_event_factory("fail") - - event = {"Records": [sqs_event_factory("fail"), sqs_event_factory("success")]} - response = {"Successful": [{"Id": fail_record["messageId"]}], "Failed": []} - stubber.add_response("delete_message_batch", response) - result = lambda_handler(event, {}) - - stubber.assert_no_pending_responses() - assert result is True - - -def test_partial_sqs_processor_context_only_failure(sqs_event_factory, record_handler, partial_processor): - """ - Test processor with only failures - """ - first_record = sqs_event_factory("fail") - second_record = sqs_event_factory("fail") - - records = [first_record, second_record] - with pytest.raises(SQSBatchProcessingError) as error: - with partial_processor(records, record_handler) as ctx: - ctx.process() - - assert len(error.value.child_exceptions) == 2 - - def test_batch_processor_middleware_success_only(sqs_event_factory, record_handler): # GIVEN first_record = SQSRecord(sqs_event_factory("success")) @@ -937,41 +632,3 @@ def lambda_handler(event, context): # THEN raise BatchProcessingError assert "All records failed processing. " in str(e.value) - - -def test_batch_processor_handler_receives_lambda_context(sqs_event_factory, lambda_context: LambdaContext): - # GIVEN - def record_handler(record, lambda_context: LambdaContext = None): - return lambda_context.function_name == "test-func" - - first_record = SQSRecord(sqs_event_factory("success")) - event = {"Records": [first_record.raw_event]} - - processor = BatchProcessor(event_type=EventType.SQS) - - @batch_processor(record_handler=record_handler, processor=processor) - def lambda_handler(event, context): - return processor.response() - - # WHEN/THEN - lambda_handler(event, lambda_context()) - - -def test_batch_processor_context_manager_handler_receives_lambda_context( - sqs_event_factory, lambda_context: LambdaContext -): - # GIVEN - def record_handler(record, lambda_context: LambdaContext = None): - return lambda_context.function_name == "test-func" - - first_record = SQSRecord(sqs_event_factory("success")) - event = {"Records": [first_record.raw_event]} - - processor = BatchProcessor(event_type=EventType.SQS) - - def lambda_handler(event, context): - with processor(records=event["Records"], handler=record_handler, lambda_context=context) as batch: - batch.process() - - # WHEN/THEN - lambda_handler(event, lambda_context()) diff --git a/tests/unit/test_utilities_batch.py b/tests/unit/test_utilities_batch.py deleted file mode 100644 index 8cc4f0b0225..00000000000 --- a/tests/unit/test_utilities_batch.py +++ /dev/null @@ -1,141 +0,0 @@ -import pytest -from botocore.config import Config - -from aws_lambda_powertools.utilities.batch import PartialSQSProcessor -from aws_lambda_powertools.utilities.batch.exceptions import SQSBatchProcessingError - -# Maintenance: This will be deleted as part of legacy Batch deprecation - - -@pytest.fixture(scope="function") -def sqs_event(): - return { - "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", - "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a", - "body": "", - "attributes": {}, - "messageAttributes": {}, - "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", - "awsRegion": "us-east-1", - } - - -@pytest.fixture(scope="module") -def config() -> Config: - return Config(region_name="us-east-1") - - -@pytest.fixture(scope="function") -def partial_sqs_processor(config) -> PartialSQSProcessor: - return PartialSQSProcessor(config=config) - - -def test_partial_sqs_get_queue_url_with_records(mocker, sqs_event, partial_sqs_processor): - expected_url = "https://queue.amazonaws.com/123456789012/my-queue" - - records_mock = mocker.patch.object(PartialSQSProcessor, "records", create=True, new_callable=mocker.PropertyMock) - records_mock.return_value = [sqs_event] - - result = partial_sqs_processor._get_queue_url() - assert result == expected_url - - -def test_partial_sqs_get_queue_url_without_records(partial_sqs_processor): - assert partial_sqs_processor._get_queue_url() is None - - -def test_partial_sqs_get_entries_to_clean_with_success(mocker, sqs_event, partial_sqs_processor): - expected_entries = [{"Id": sqs_event["messageId"], "ReceiptHandle": sqs_event["receiptHandle"]}] - - success_messages_mock = mocker.patch.object( - PartialSQSProcessor, "success_messages", create=True, new_callable=mocker.PropertyMock - ) - success_messages_mock.return_value = [sqs_event] - - result = partial_sqs_processor._get_entries_to_clean() - - assert result == expected_entries - - -def test_partial_sqs_get_entries_to_clean_without_success(mocker, partial_sqs_processor): - expected_entries = [] - - success_messages_mock = mocker.patch.object( - PartialSQSProcessor, "success_messages", create=True, new_callable=mocker.PropertyMock - ) - success_messages_mock.return_value = [] - - result = partial_sqs_processor._get_entries_to_clean() - - assert result == expected_entries - - -def test_partial_sqs_process_record_success(mocker, partial_sqs_processor): - expected_value = mocker.sentinel.expected_value - - success_result = mocker.sentinel.success_result - record = mocker.sentinel.record - - handler_mock = mocker.patch.object(PartialSQSProcessor, "handler", create=True, return_value=success_result) - success_handler_mock = mocker.patch.object(PartialSQSProcessor, "success_handler", return_value=expected_value) - - result = partial_sqs_processor._process_record(record) - - handler_mock.assert_called_once_with(record=record) - success_handler_mock.assert_called_once_with(record=record, result=success_result) - - assert result == expected_value - - -def test_partial_sqs_process_record_failure(mocker, partial_sqs_processor): - expected_value = mocker.sentinel.expected_value - - failure_result = Exception() - record = mocker.sentinel.record - - handler_mock = mocker.patch.object(PartialSQSProcessor, "handler", create=True, side_effect=failure_result) - failure_handler_mock = mocker.patch.object(PartialSQSProcessor, "failure_handler", return_value=expected_value) - - result = partial_sqs_processor._process_record(record) - - handler_mock.assert_called_once_with(record=record) - - _, failure_handler_called_with_args = failure_handler_mock.call_args - failure_handler_mock.assert_called_once() - assert (failure_handler_called_with_args["record"]) == record - assert isinstance(failure_handler_called_with_args["exception"], tuple) - assert failure_handler_called_with_args["exception"][1] == failure_result - assert result == expected_value - - -def test_partial_sqs_prepare(mocker, partial_sqs_processor): - success_messages_mock = mocker.patch.object(partial_sqs_processor, "success_messages", spec=list) - failed_messages_mock = mocker.patch.object(partial_sqs_processor, "fail_messages", spec=list) - - partial_sqs_processor._prepare() - - success_messages_mock.clear.assert_called_once() - failed_messages_mock.clear.assert_called_once() - - -def test_partial_sqs_clean(monkeypatch, mocker, partial_sqs_processor): - records = [mocker.sentinel.record] - - monkeypatch.setattr(partial_sqs_processor, "fail_messages", records) - monkeypatch.setattr(partial_sqs_processor, "success_messages", records) - - queue_url_mock = mocker.patch.object(PartialSQSProcessor, "_get_queue_url") - entries_to_clean_mock = mocker.patch.object(PartialSQSProcessor, "_get_entries_to_clean") - - queue_url_mock.return_value = mocker.sentinel.queue_url - entries_to_clean_mock.return_value = [mocker.sentinel.entries_to_clean] - - client_mock = mocker.patch.object(partial_sqs_processor, "client", autospec=True) - with pytest.raises(SQSBatchProcessingError): - partial_sqs_processor._clean() - - client_mock.delete_message_batch.assert_called_once_with( - QueueUrl=mocker.sentinel.queue_url, Entries=[mocker.sentinel.entries_to_clean] - ) From a00156dae5da14149ab92d204be3133b1c54ad49 Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Fri, 2 Sep 2022 17:21:50 +0200 Subject: [PATCH 04/30] feat(event_handler): add cookies as 1st class citizen in v2 (#1487) Co-authored-by: Heitor Lessa Co-authored-by: Heitor Lessa --- .../event_handler/api_gateway.py | 6 +- aws_lambda_powertools/shared/cookies.py | 118 +++++++++++ .../shared/headers_serializer.py | 16 +- docs/upgrade.md | 2 +- .../src/fine_grained_responses.py | 3 +- .../src/fine_grained_responses_output.json | 2 +- .../e2e/event_handler/handlers/alb_handler.py | 20 +- .../handlers/api_gateway_http_handler.py | 20 +- .../handlers/api_gateway_rest_handler.py | 20 +- .../handlers/lambda_function_url_handler.py | 20 +- tests/e2e/event_handler/infrastructure.py | 4 +- .../event_handler/test_header_serializer.py | 188 ++++++++++++------ .../event_handler/test_api_gateway.py | 9 +- .../event_handler/test_lambda_function_url.py | 11 +- 14 files changed, 334 insertions(+), 105 deletions(-) create mode 100644 aws_lambda_powertools/shared/cookies.py diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index cf9963c6b1d..2b14ed3a3fd 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -28,6 +28,8 @@ from aws_lambda_powertools.event_handler.exceptions import NotFoundError, ServiceError from aws_lambda_powertools.shared import constants from aws_lambda_powertools.shared.functions import powertools_dev_is_set, strtobool +from aws_lambda_powertools.shared.cookies import Cookie +from aws_lambda_powertools.shared.functions import resolve_truthy_env_var_choice from aws_lambda_powertools.shared.json_encoder import Encoder from aws_lambda_powertools.utilities.data_classes import ( ALBEvent, @@ -159,7 +161,7 @@ def __init__( content_type: Optional[str] = None, body: Union[str, bytes, None] = None, headers: Optional[Dict[str, Union[str, List[str]]]] = None, - cookies: Optional[List[str]] = None, + cookies: Optional[List[Cookie]] = None, ): """ @@ -174,7 +176,7 @@ def __init__( Optionally set the response body. Note: bytes body will be automatically base64 encoded headers: dict[str, Union[str, List[str]]] Optionally set specific http headers. Setting "Content-Type" here would override the `content_type` value. - cookies: list[str] + cookies: list[Cookie] Optionally set cookies. """ self.status_code = status_code diff --git a/aws_lambda_powertools/shared/cookies.py b/aws_lambda_powertools/shared/cookies.py new file mode 100644 index 00000000000..944bcb5dc9f --- /dev/null +++ b/aws_lambda_powertools/shared/cookies.py @@ -0,0 +1,118 @@ +from datetime import datetime +from enum import Enum +from io import StringIO +from typing import List, Optional + + +class SameSite(Enum): + """ + SameSite allows a server to define a cookie attribute making it impossible for + the browser to send this cookie along with cross-site requests. The main + goal is to mitigate the risk of cross-origin information leakage, and provide + some protection against cross-site request forgery attacks. + + See https://tools.ietf.org/html/draft-ietf-httpbis-cookie-same-site-00 for details. + """ + + DEFAULT_MODE = "" + LAX_MODE = "Lax" + STRICT_MODE = "Strict" + NONE_MODE = "None" + + +def _format_date(timestamp: datetime) -> str: + # Specification example: Wed, 21 Oct 2015 07:28:00 GMT + return timestamp.strftime("%a, %d %b %Y %H:%M:%S GMT") + + +class Cookie: + """ + A Cookie represents an HTTP cookie as sent in the Set-Cookie header of an + HTTP response or the Cookie header of an HTTP request. + + See https://tools.ietf.org/html/rfc6265 for details. + """ + + def __init__( + self, + name: str, + value: str, + path: str = "", + domain: str = "", + secure: bool = True, + http_only: bool = False, + max_age: Optional[int] = None, + expires: Optional[datetime] = None, + same_site: Optional[SameSite] = None, + custom_attributes: Optional[List[str]] = None, + ): + """ + + Parameters + ---------- + name: str + The name of this cookie, for example session_id + value: str + The cookie value, for instance an uuid + path: str + The path for which this cookie is valid. Optional + domain: str + The domain for which this cookie is valid. Optional + secure: bool + Marks the cookie as secure, only sendable to the server with an encrypted request over the HTTPS protocol + http_only: bool + Enabling this attribute makes the cookie inaccessible to the JavaScript `Document.cookie` API + max_age: Optional[int] + Defines the period of time after which the cookie is invalid. Use negative values to force cookie deletion. + expires: Optional[datetime] + Defines a date where the permanent cookie expires. + same_site: Optional[SameSite] + Determines if the cookie should be sent to third party websites + custom_attributes: Optional[List[str]] + List of additional custom attributes to set on the cookie + """ + self.name = name + self.value = value + self.path = path + self.domain = domain + self.secure = secure + self.expires = expires + self.max_age = max_age + self.http_only = http_only + self.same_site = same_site + self.custom_attributes = custom_attributes + + def __str__(self) -> str: + payload = StringIO() + payload.write(f"{self.name}={self.value}") + + if self.path: + payload.write(f"; Path={self.path}") + + if self.domain: + payload.write(f"; Domain={self.domain}") + + if self.expires: + payload.write(f"; Expires={_format_date(self.expires)}") + + if self.max_age: + if self.max_age > 0: + payload.write(f"; MaxAge={self.max_age}") + else: + # negative or zero max-age should be set to 0 + payload.write("; MaxAge=0") + + if self.http_only: + payload.write("; HttpOnly") + + if self.secure: + payload.write("; Secure") + + if self.same_site: + payload.write(f"; SameSite={self.same_site.value}") + + if self.custom_attributes: + for attr in self.custom_attributes: + payload.write(f"; {attr}") + + return payload.getvalue() diff --git a/aws_lambda_powertools/shared/headers_serializer.py b/aws_lambda_powertools/shared/headers_serializer.py index 4db7effe81b..796fd9aeae3 100644 --- a/aws_lambda_powertools/shared/headers_serializer.py +++ b/aws_lambda_powertools/shared/headers_serializer.py @@ -2,6 +2,8 @@ from collections import defaultdict from typing import Any, Dict, List, Union +from aws_lambda_powertools.shared.cookies import Cookie + class BaseHeadersSerializer: """ @@ -9,7 +11,7 @@ class BaseHeadersSerializer: ALB and Lambda Function URL response payload. """ - def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str]) -> Dict[str, Any]: + def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[Cookie]) -> Dict[str, Any]: """ Serializes headers and cookies according to the request type. Returns a dict that can be merged with the response payload. @@ -25,7 +27,7 @@ def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str class HttpApiHeadersSerializer(BaseHeadersSerializer): - def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str]) -> Dict[str, Any]: + def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[Cookie]) -> Dict[str, Any]: """ When using HTTP APIs or LambdaFunctionURLs, everything is taken care automatically for us. We can directly assign a list of cookies and a dict of headers to the response payload, and the @@ -44,11 +46,11 @@ def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str else: combined_headers[key] = ", ".join(values) - return {"headers": combined_headers, "cookies": cookies} + return {"headers": combined_headers, "cookies": list(map(str, cookies))} class MultiValueHeadersSerializer(BaseHeadersSerializer): - def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str]) -> Dict[str, Any]: + def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[Cookie]) -> Dict[str, Any]: """ When using REST APIs, headers can be encoded using the `multiValueHeaders` key on the response. This is also the case when using an ALB integration with the `multiValueHeaders` option enabled. @@ -69,13 +71,13 @@ def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str if cookies: payload.setdefault("Set-Cookie", []) for cookie in cookies: - payload["Set-Cookie"].append(cookie) + payload["Set-Cookie"].append(str(cookie)) return {"multiValueHeaders": payload} class SingleValueHeadersSerializer(BaseHeadersSerializer): - def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str]) -> Dict[str, Any]: + def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[Cookie]) -> Dict[str, Any]: """ The ALB integration has `multiValueHeaders` disabled by default. If we try to set multiple headers with the same key, or more than one cookie, print a warning. @@ -93,7 +95,7 @@ def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str ) # We can only send one cookie, send the last one - payload["headers"]["Set-Cookie"] = cookies[-1] + payload["headers"]["Set-Cookie"] = str(cookies[-1]) for key, values in headers.items(): if isinstance(values, str): diff --git a/docs/upgrade.md b/docs/upgrade.md index 20cf4aa25a6..3d1257f1c12 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -53,7 +53,7 @@ def get_todos(): return Response( # ... headers={"Content-Type": ["text/plain"]}, - cookies=["CookieName=CookieValue"] + cookies=[Cookie(name="session_id", value="12345", secure=True, http_only=True)], ) ``` diff --git a/examples/event_handler_rest/src/fine_grained_responses.py b/examples/event_handler_rest/src/fine_grained_responses.py index 051797f2477..7d45d74621f 100644 --- a/examples/event_handler_rest/src/fine_grained_responses.py +++ b/examples/event_handler_rest/src/fine_grained_responses.py @@ -10,6 +10,7 @@ content_types, ) from aws_lambda_powertools.logging import correlation_paths +from aws_lambda_powertools.shared.cookies import Cookie from aws_lambda_powertools.utilities.typing import LambdaContext tracer = Tracer() @@ -30,7 +31,7 @@ def get_todos(): content_type=content_types.APPLICATION_JSON, body=todos.json()[:10], headers=custom_headers, - cookies=["=; Secure; Expires="], + cookies=[Cookie(name="session_id", value="12345")], ) diff --git a/examples/event_handler_rest/src/fine_grained_responses_output.json b/examples/event_handler_rest/src/fine_grained_responses_output.json index 1ce606839b1..0b33bd91542 100644 --- a/examples/event_handler_rest/src/fine_grained_responses_output.json +++ b/examples/event_handler_rest/src/fine_grained_responses_output.json @@ -3,7 +3,7 @@ "multiValueHeaders": { "Content-Type": ["application/json"], "X-Transaction-Id": ["3490eea9-791b-47a0-91a4-326317db61a9"], - "Set-Cookie": ["=; Secure; Expires="] + "Set-Cookie": ["session_id=12345; Secure"] }, "body": "{\"todos\":[{\"userId\":1,\"id\":1,\"title\":\"delectus aut autem\",\"completed\":false},{\"userId\":1,\"id\":2,\"title\":\"quis ut nam facilis et officia qui\",\"completed\":false},{\"userId\":1,\"id\":3,\"title\":\"fugiat veniam minus\",\"completed\":false},{\"userId\":1,\"id\":4,\"title\":\"et porro tempora\",\"completed\":true},{\"userId\":1,\"id\":5,\"title\":\"laboriosam mollitia et enim quasi adipisci quia provident illum\",\"completed\":false},{\"userId\":1,\"id\":6,\"title\":\"qui ullam ratione quibusdam voluptatem quia omnis\",\"completed\":false},{\"userId\":1,\"id\":7,\"title\":\"illo expedita consequatur quia in\",\"completed\":false},{\"userId\":1,\"id\":8,\"title\":\"quo adipisci enim quam ut ab\",\"completed\":true},{\"userId\":1,\"id\":9,\"title\":\"molestiae perspiciatis ipsa\",\"completed\":false},{\"userId\":1,\"id\":10,\"title\":\"illo est ratione doloremque quia maiores aut\",\"completed\":true}]}", "isBase64Encoded": false diff --git a/tests/e2e/event_handler/handlers/alb_handler.py b/tests/e2e/event_handler/handlers/alb_handler.py index 4c3f4f9dac3..0e386c82c51 100644 --- a/tests/e2e/event_handler/handlers/alb_handler.py +++ b/tests/e2e/event_handler/handlers/alb_handler.py @@ -3,14 +3,22 @@ app = ALBResolver() -@app.get("/todos") +@app.post("/todos") def hello(): + payload = app.current_event.json_body + + body = payload.get("body", "Hello World") + status_code = payload.get("status_code", 200) + headers = payload.get("headers", {}) + cookies = payload.get("cookies", []) + content_type = headers.get("Content-Type", content_types.TEXT_PLAIN) + return Response( - status_code=200, - content_type=content_types.TEXT_PLAIN, - body="Hello world", - cookies=["CookieMonster", "MonsterCookie"], - headers={"Foo": ["bar", "zbr"]}, + status_code=status_code, + content_type=content_type, + body=body, + cookies=cookies, + headers=headers, ) diff --git a/tests/e2e/event_handler/handlers/api_gateway_http_handler.py b/tests/e2e/event_handler/handlers/api_gateway_http_handler.py index 1a20b730285..990761cd3b9 100644 --- a/tests/e2e/event_handler/handlers/api_gateway_http_handler.py +++ b/tests/e2e/event_handler/handlers/api_gateway_http_handler.py @@ -3,14 +3,22 @@ app = APIGatewayHttpResolver() -@app.get("/todos") +@app.post("/todos") def hello(): + payload = app.current_event.json_body + + body = payload.get("body", "Hello World") + status_code = payload.get("status_code", 200) + headers = payload.get("headers", {}) + cookies = payload.get("cookies", []) + content_type = headers.get("Content-Type", content_types.TEXT_PLAIN) + return Response( - status_code=200, - content_type=content_types.TEXT_PLAIN, - body="Hello world", - cookies=["CookieMonster", "MonsterCookie"], - headers={"Foo": ["bar", "zbr"]}, + status_code=status_code, + content_type=content_type, + body=body, + cookies=cookies, + headers=headers, ) diff --git a/tests/e2e/event_handler/handlers/api_gateway_rest_handler.py b/tests/e2e/event_handler/handlers/api_gateway_rest_handler.py index 2f5ad0b94fa..0aa836cfe74 100644 --- a/tests/e2e/event_handler/handlers/api_gateway_rest_handler.py +++ b/tests/e2e/event_handler/handlers/api_gateway_rest_handler.py @@ -3,14 +3,22 @@ app = APIGatewayRestResolver() -@app.get("/todos") +@app.post("/todos") def hello(): + payload = app.current_event.json_body + + body = payload.get("body", "Hello World") + status_code = payload.get("status_code", 200) + headers = payload.get("headers", {}) + cookies = payload.get("cookies", []) + content_type = headers.get("Content-Type", content_types.TEXT_PLAIN) + return Response( - status_code=200, - content_type=content_types.TEXT_PLAIN, - body="Hello world", - cookies=["CookieMonster", "MonsterCookie"], - headers={"Foo": ["bar", "zbr"]}, + status_code=status_code, + content_type=content_type, + body=body, + cookies=cookies, + headers=headers, ) diff --git a/tests/e2e/event_handler/handlers/lambda_function_url_handler.py b/tests/e2e/event_handler/handlers/lambda_function_url_handler.py index 3fd4b46ea28..c9c825c38d2 100644 --- a/tests/e2e/event_handler/handlers/lambda_function_url_handler.py +++ b/tests/e2e/event_handler/handlers/lambda_function_url_handler.py @@ -3,14 +3,22 @@ app = LambdaFunctionUrlResolver() -@app.get("/todos") +@app.post("/todos") def hello(): + payload = app.current_event.json_body + + body = payload.get("body", "Hello World") + status_code = payload.get("status_code", 200) + headers = payload.get("headers", {}) + cookies = payload.get("cookies", []) + content_type = headers.get("Content-Type", content_types.TEXT_PLAIN) + return Response( - status_code=200, - content_type=content_types.TEXT_PLAIN, - body="Hello world", - cookies=["CookieMonster", "MonsterCookie"], - headers={"Foo": ["bar", "zbr"]}, + status_code=status_code, + content_type=content_type, + body=body, + cookies=cookies, + headers=headers, ) diff --git a/tests/e2e/event_handler/infrastructure.py b/tests/e2e/event_handler/infrastructure.py index 62421b8aac9..735261138f3 100644 --- a/tests/e2e/event_handler/infrastructure.py +++ b/tests/e2e/event_handler/infrastructure.py @@ -61,7 +61,7 @@ def _create_api_gateway_http(self, function: Function): apigw = apigwv2.HttpApi(self.stack, "APIGatewayHTTP", create_default_stage=True) apigw.add_routes( path="/todos", - methods=[apigwv2.HttpMethod.GET], + methods=[apigwv2.HttpMethod.POST], integration=apigwv2integrations.HttpLambdaIntegration("TodosIntegration", function), ) @@ -71,7 +71,7 @@ def _create_api_gateway_rest(self, function: Function): apigw = apigwv1.RestApi(self.stack, "APIGatewayRest", deploy_options=apigwv1.StageOptions(stage_name="dev")) todos = apigw.root.add_resource("todos") - todos.add_method("GET", apigwv1.LambdaIntegration(function, proxy=True)) + todos.add_method("POST", apigwv1.LambdaIntegration(function, proxy=True)) CfnOutput(self.stack, "APIGatewayRestUrl", value=apigw.url) diff --git a/tests/e2e/event_handler/test_header_serializer.py b/tests/e2e/event_handler/test_header_serializer.py index 2b1d51bfb3d..eedb69ccaad 100644 --- a/tests/e2e/event_handler/test_header_serializer.py +++ b/tests/e2e/event_handler/test_header_serializer.py @@ -1,6 +1,9 @@ +from uuid import uuid4 + import pytest from requests import Request +from aws_lambda_powertools.shared.cookies import Cookie from tests.e2e.utils import data_fetcher @@ -36,106 +39,179 @@ def lambda_function_url_endpoint(infrastructure: dict) -> str: def test_alb_headers_serializer(alb_basic_listener_endpoint): # GIVEN url = f"{alb_basic_listener_endpoint}/todos" + body = "Hello World" + status_code = 200 + headers = {"Content-Type": "text/plain", "Vary": ["Accept-Encoding", "User-Agent"]} + cookies = [ + Cookie(name="session_id", value=str(uuid4()), secure=True, http_only=True), + Cookie(name="ab_experiment", value="3"), + ] + last_cookie = cookies[-1] # WHEN - response = data_fetcher.get_http_response(Request(method="GET", url=url)) + response = data_fetcher.get_http_response( + Request( + method="POST", + url=url, + json={"body": body, "status_code": status_code, "headers": headers, "cookies": list(map(str, cookies))}, + ) + ) # THEN - assert response.status_code == 200 - assert response.content == b"Hello world" - assert response.headers["content-type"] == "text/plain" + assert response.status_code == status_code + # response.content is a binary string, needs to be decoded to compare with the real string + assert response.content.decode("ascii") == body - # Only the last header for key "Foo" should be set - assert response.headers["Foo"] == "zbr" + # Only the last header should be set + for key, value in headers.items(): + assert key in response.headers + value = value if isinstance(value, str) else sorted(value)[-1] + assert response.headers[key] == value # Only the last cookie should be set - assert "MonsterCookie" in response.cookies.keys() - assert "CookieMonster" not in response.cookies.keys() + assert len(response.cookies.items()) == 1 + assert last_cookie.name in response.cookies + assert response.cookies.get(last_cookie.name) == last_cookie.value def test_alb_multi_value_headers_serializer(alb_multi_value_header_listener_endpoint): # GIVEN url = f"{alb_multi_value_header_listener_endpoint}/todos" + body = "Hello World" + status_code = 200 + headers = {"Content-Type": "text/plain", "Vary": ["Accept-Encoding", "User-Agent"]} + cookies = [ + Cookie(name="session_id", value=str(uuid4()), secure=True, http_only=True), + Cookie(name="ab_experiment", value="3"), + ] # WHEN - response = data_fetcher.get_http_response(Request(method="GET", url=url)) + response = data_fetcher.get_http_response( + Request( + method="POST", + url=url, + json={"body": body, "status_code": status_code, "headers": headers, "cookies": list(map(str, cookies))}, + ) + ) # THEN - assert response.status_code == 200 - assert response.content == b"Hello world" - assert response.headers["content-type"] == "text/plain" + assert response.status_code == status_code + # response.content is a binary string, needs to be decoded to compare with the real string + assert response.content.decode("ascii") == body - # Only the last header for key "Foo" should be set - assert "Foo" in response.headers - foo_headers = [x.strip() for x in response.headers["Foo"].split(",")] - assert sorted(foo_headers) == ["bar", "zbr"] + for key, value in headers.items(): + assert key in response.headers + value = value if isinstance(value, str) else ", ".join(sorted(value)) - # Only the last cookie should be set - assert "MonsterCookie" in response.cookies.keys() - assert "CookieMonster" in response.cookies.keys() + # ALB sorts the header values randomly, so we have to re-order them for comparison here + returned_value = ", ".join(sorted(response.headers[key].split(", "))) + assert returned_value == value + + for cookie in cookies: + assert cookie.name in response.cookies + assert response.cookies.get(cookie.name) == cookie.value def test_api_gateway_rest_headers_serializer(apigw_rest_endpoint): # GIVEN - url = f"{apigw_rest_endpoint}/todos" + url = f"{apigw_rest_endpoint}todos" + body = "Hello World" + status_code = 200 + headers = {"Content-Type": "text/plain", "Vary": ["Accept-Encoding", "User-Agent"]} + cookies = [ + Cookie(name="session_id", value=str(uuid4()), secure=True, http_only=True), + Cookie(name="ab_experiment", value="3"), + ] # WHEN - response = data_fetcher.get_http_response(Request(method="GET", url=url)) + response = data_fetcher.get_http_response( + Request( + method="POST", + url=url, + json={"body": body, "status_code": status_code, "headers": headers, "cookies": list(map(str, cookies))}, + ) + ) # THEN - assert response.status_code == 200 - assert response.content == b"Hello world" - assert response.headers["content-type"] == "text/plain" + assert response.status_code == status_code + # response.content is a binary string, needs to be decoded to compare with the real string + assert response.content.decode("ascii") == body - # Only the last header for key "Foo" should be set - assert "Foo" in response.headers - foo_headers = [x.strip() for x in response.headers["Foo"].split(",")] - assert sorted(foo_headers) == ["bar", "zbr"] + for key, value in headers.items(): + assert key in response.headers + value = value if isinstance(value, str) else ", ".join(sorted(value)) + assert response.headers[key] == value - # Only the last cookie should be set - assert "MonsterCookie" in response.cookies.keys() - assert "CookieMonster" in response.cookies.keys() + for cookie in cookies: + assert cookie.name in response.cookies + assert response.cookies.get(cookie.name) == cookie.value def test_api_gateway_http_headers_serializer(apigw_http_endpoint): # GIVEN - url = f"{apigw_http_endpoint}/todos" + url = f"{apigw_http_endpoint}todos" + body = "Hello World" + status_code = 200 + headers = {"Content-Type": "text/plain", "Vary": ["Accept-Encoding", "User-Agent"]} + cookies = [ + Cookie(name="session_id", value=str(uuid4()), secure=True, http_only=True), + Cookie(name="ab_experiment", value="3"), + ] # WHEN - response = data_fetcher.get_http_response(Request(method="GET", url=url)) + response = data_fetcher.get_http_response( + Request( + method="POST", + url=url, + json={"body": body, "status_code": status_code, "headers": headers, "cookies": list(map(str, cookies))}, + ) + ) # THEN - assert response.status_code == 200 - assert response.content == b"Hello world" - assert response.headers["content-type"] == "text/plain" + assert response.status_code == status_code + # response.content is a binary string, needs to be decoded to compare with the real string + assert response.content.decode("ascii") == body - # Only the last header for key "Foo" should be set - assert "Foo" in response.headers - foo_headers = [x.strip() for x in response.headers["Foo"].split(",")] - assert sorted(foo_headers) == ["bar", "zbr"] + for key, value in headers.items(): + assert key in response.headers + value = value if isinstance(value, str) else ", ".join(sorted(value)) + assert response.headers[key] == value - # Only the last cookie should be set - assert "MonsterCookie" in response.cookies.keys() - assert "CookieMonster" in response.cookies.keys() + for cookie in cookies: + assert cookie.name in response.cookies + assert response.cookies.get(cookie.name) == cookie.value def test_lambda_function_url_headers_serializer(lambda_function_url_endpoint): # GIVEN url = f"{lambda_function_url_endpoint}todos" # the function url endpoint already has the trailing / + body = "Hello World" + status_code = 200 + headers = {"Content-Type": "text/plain", "Vary": ["Accept-Encoding", "User-Agent"]} + cookies = [ + Cookie(name="session_id", value=str(uuid4()), secure=True, http_only=True), + Cookie(name="ab_experiment", value="3"), + ] # WHEN - response = data_fetcher.get_http_response(Request(method="GET", url=url)) + response = data_fetcher.get_http_response( + Request( + method="POST", + url=url, + json={"body": body, "status_code": status_code, "headers": headers, "cookies": list(map(str, cookies))}, + ) + ) # THEN - assert response.status_code == 200 - assert response.content == b"Hello world" - assert response.headers["content-type"] == "text/plain" - - # Only the last header for key "Foo" should be set - assert "Foo" in response.headers - foo_headers = [x.strip() for x in response.headers["Foo"].split(",")] - assert sorted(foo_headers) == ["bar", "zbr"] - - # Only the last cookie should be set - assert "MonsterCookie" in response.cookies.keys() - assert "CookieMonster" in response.cookies.keys() + assert response.status_code == status_code + # response.content is a binary string, needs to be decoded to compare with the real string + assert response.content.decode("ascii") == body + + for key, value in headers.items(): + assert key in response.headers + value = value if isinstance(value, str) else ", ".join(sorted(value)) + assert response.headers[key] == value + + for cookie in cookies: + assert cookie.name in response.cookies + assert response.cookies.get(cookie.name) == cookie.value diff --git a/tests/functional/event_handler/test_api_gateway.py b/tests/functional/event_handler/test_api_gateway.py index daf31e58e59..8491754b65e 100644 --- a/tests/functional/event_handler/test_api_gateway.py +++ b/tests/functional/event_handler/test_api_gateway.py @@ -30,6 +30,7 @@ UnauthorizedError, ) from aws_lambda_powertools.shared import constants +from aws_lambda_powertools.shared.cookies import Cookie from aws_lambda_powertools.shared.json_encoder import Encoder from aws_lambda_powertools.utilities.data_classes import ( ALBEvent, @@ -98,7 +99,7 @@ def get_lambda() -> Response: def test_api_gateway_v1_cookies(): # GIVEN a Http API V1 proxy type event app = APIGatewayRestResolver() - cookie = "CookieMonster" + cookie = Cookie(name="CookieMonster", value="MonsterCookie") @app.get("/my/path") def get_lambda() -> Response: @@ -111,7 +112,7 @@ def get_lambda() -> Response: # THEN process event correctly # AND set the current_event type as APIGatewayProxyEvent assert result["statusCode"] == 200 - assert result["multiValueHeaders"]["Set-Cookie"] == [cookie] + assert result["multiValueHeaders"]["Set-Cookie"] == ["CookieMonster=MonsterCookie; Secure"] def test_api_gateway(): @@ -158,7 +159,7 @@ def my_path() -> Response: def test_api_gateway_v2_cookies(): # GIVEN a Http API V2 proxy type event app = APIGatewayHttpResolver() - cookie = "CookieMonster" + cookie = Cookie(name="CookieMonster", value="MonsterCookie") @app.post("/my/path") def my_path() -> Response: @@ -172,7 +173,7 @@ def my_path() -> Response: # AND set the current_event type as APIGatewayProxyEventV2 assert result["statusCode"] == 200 assert result["headers"]["Content-Type"] == content_types.TEXT_PLAIN - assert result["cookies"] == [cookie] + assert result["cookies"] == ["CookieMonster=MonsterCookie; Secure"] def test_include_rule_matching(): diff --git a/tests/functional/event_handler/test_lambda_function_url.py b/tests/functional/event_handler/test_lambda_function_url.py index 69465cba7f4..c87d0ecb854 100644 --- a/tests/functional/event_handler/test_lambda_function_url.py +++ b/tests/functional/event_handler/test_lambda_function_url.py @@ -1,8 +1,5 @@ -from aws_lambda_powertools.event_handler import ( - LambdaFunctionUrlResolver, - Response, - content_types, -) +from aws_lambda_powertools.event_handler import LambdaFunctionUrlResolver, Response, content_types +from aws_lambda_powertools.shared.cookies import Cookie from aws_lambda_powertools.utilities.data_classes import LambdaFunctionUrlEvent from tests.functional.utils import load_event @@ -32,7 +29,7 @@ def foo(): def test_lambda_function_url_event_with_cookies(): # GIVEN a Lambda Function Url type event app = LambdaFunctionUrlResolver() - cookie = "CookieMonster" + cookie = Cookie(name="CookieMonster", value="MonsterCookie") @app.get("/") def foo(): @@ -46,7 +43,7 @@ def foo(): # THEN process event correctly # AND set the current_event type as LambdaFunctionUrlEvent assert result["statusCode"] == 200 - assert result["cookies"] == [cookie] + assert result["cookies"] == ["CookieMonster=MonsterCookie; Secure"] def test_lambda_function_url_no_matches(): From 8cfa3b30e989e6b2f5bd8755ac804cb5c009eb3c Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Thu, 8 Sep 2022 14:32:47 +0100 Subject: [PATCH 05/30] feat(tracer): support methods with the same name (ABCs) by including fully qualified name in v2 (#1486) Co-authored-by: Heitor Lessa Co-authored-by: Ruben Fonseca --- aws_lambda_powertools/tracing/tracer.py | 6 +- docs/media/tracer_utility_showcase.png | Bin 131394 -> 99807 bytes .../e2e/tracer/handlers/same_function_name.py | 33 ++++++++++ tests/e2e/tracer/test_tracer.py | 42 +++++++++++-- tests/unit/test_tracing.py | 58 ++++++++++++++---- 5 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 tests/e2e/tracer/handlers/same_function_name.py diff --git a/aws_lambda_powertools/tracing/tracer.py b/aws_lambda_powertools/tracing/tracer.py index 8d9ad16a3d0..7053497ae6d 100644 --- a/aws_lambda_powertools/tracing/tracer.py +++ b/aws_lambda_powertools/tracing/tracer.py @@ -354,7 +354,8 @@ def capture_method( """Decorator to create subsegment for arbitrary functions It also captures both response and exceptions as metadata - and creates a subsegment named `## ` + and creates a subsegment named `## ` + # see here: [Qualified name for classes and functions](https://peps.python.org/pep-3155/) When running [async functions concurrently](https://docs.python.org/3/library/asyncio-task.html#id6), methods may impact each others subsegment, and can trigger @@ -508,7 +509,8 @@ async def async_tasks(): functools.partial(self.capture_method, capture_response=capture_response, capture_error=capture_error), ) - method_name = f"{method.__name__}" + # Example: app.ClassA.get_all # noqa E800 + method_name = f"{method.__module__}.{method.__qualname__}" capture_response = resolve_truthy_env_var_choice( env=os.getenv(constants.TRACER_CAPTURE_RESPONSE_ENV, "true"), choice=capture_response diff --git a/docs/media/tracer_utility_showcase.png b/docs/media/tracer_utility_showcase.png index 55d7d5d0bf81135fda888b74a2ebbd983d9f9570..b3d3568a01c3ac10268e312dcde6c2a7aade9613 100644 GIT binary patch literal 99807 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 str: return infrastructure.get("BasicHandler", "") +@pytest.fixture +def same_function_name_fn(infrastructure: dict) -> str: + return infrastructure.get("SameFunctionName", "") + + +@pytest.fixture +def same_function_name_arn(infrastructure: dict) -> str: + return infrastructure.get("SameFunctionNameArn", "") + + @pytest.fixture def async_fn_arn(infrastructure: dict) -> str: return infrastructure.get("AsyncCaptureArn", "") @@ -31,9 +41,9 @@ def test_lambda_handler_trace_is_visible(basic_handler_fn_arn: str, basic_handle handler_subsegment = f"## {handler_name}" handler_metadata_key = f"{handler_name} response" - method_name = basic_handler.get_todos.__name__ + method_name = f"basic_handler.{basic_handler.get_todos.__name__}" method_subsegment = f"## {method_name}" - handler_metadata_key = f"{method_name} response" + method_metadata_key = f"{method_name} response" trace_query = data_builder.build_trace_default_query(function_name=basic_handler_fn) @@ -46,14 +56,38 @@ def test_lambda_handler_trace_is_visible(basic_handler_fn_arn: str, basic_handle assert len(trace.get_annotation(key="ColdStart", value=True)) == 1 assert len(trace.get_metadata(key=handler_metadata_key, namespace=TracerStack.SERVICE_NAME)) == 2 - assert len(trace.get_metadata(key=handler_metadata_key, namespace=TracerStack.SERVICE_NAME)) == 2 + assert len(trace.get_metadata(key=method_metadata_key, namespace=TracerStack.SERVICE_NAME)) == 2 assert len(trace.get_subsegment(name=handler_subsegment)) == 2 assert len(trace.get_subsegment(name=method_subsegment)) == 2 +def test_lambda_handler_trace_multiple_functions_same_name(same_function_name_arn: str, same_function_name_fn: str): + # GIVEN + method_name_todos = "same_function_name.Todos.get_all" + method_subsegment_todos = f"## {method_name_todos}" + method_metadata_key_todos = f"{method_name_todos} response" + + method_name_comments = "same_function_name.Comments.get_all" + method_subsegment_comments = f"## {method_name_comments}" + method_metadata_key_comments = f"{method_name_comments} response" + + trace_query = data_builder.build_trace_default_query(function_name=same_function_name_fn) + + # WHEN + _, execution_time = data_fetcher.get_lambda_response(lambda_arn=same_function_name_arn) + + # THEN + trace = data_fetcher.get_traces(start_date=execution_time, filter_expression=trace_query) + + assert len(trace.get_metadata(key=method_metadata_key_todos, namespace=TracerStack.SERVICE_NAME)) == 1 + assert len(trace.get_metadata(key=method_metadata_key_comments, namespace=TracerStack.SERVICE_NAME)) == 1 + assert len(trace.get_subsegment(name=method_subsegment_todos)) == 1 + assert len(trace.get_subsegment(name=method_subsegment_comments)) == 1 + + def test_async_trace_is_visible(async_fn_arn: str, async_fn: str): # GIVEN - async_fn_name = async_capture.async_get_users.__name__ + async_fn_name = f"async_capture.{async_capture.async_get_users.__name__}" async_fn_name_subsegment = f"## {async_fn_name}" async_fn_name_metadata_key = f"{async_fn_name} response" diff --git a/tests/unit/test_tracing.py b/tests/unit/test_tracing.py index d9c5b91214a..a40301a44c2 100644 --- a/tests/unit/test_tracing.py +++ b/tests/unit/test_tracing.py @@ -10,6 +10,8 @@ # Maintenance: This should move to Functional tests and use Fake over mocks. +MODULE_PREFIX = "unit.test_tracing" + @pytest.fixture def dummy_response(): @@ -125,9 +127,13 @@ def greeting(name, message): # and add its response as trace metadata # and use service name as a metadata namespace assert in_subsegment_mock.in_subsegment.call_count == 1 - assert in_subsegment_mock.in_subsegment.call_args == mocker.call(name="## greeting") + assert in_subsegment_mock.in_subsegment.call_args == mocker.call( + name=f"## {MODULE_PREFIX}.test_tracer_method..greeting" + ) assert in_subsegment_mock.put_metadata.call_args == mocker.call( - key="greeting response", value=dummy_response, namespace="booking" + key=f"{MODULE_PREFIX}.test_tracer_method..greeting response", + value=dummy_response, + namespace="booking", ) @@ -253,7 +259,10 @@ def greeting(name, message): # THEN we should add the exception using method name as key plus error # and their service name as the namespace put_metadata_mock_args = in_subsegment_mock.put_metadata.call_args[1] - assert put_metadata_mock_args["key"] == "greeting error" + assert ( + put_metadata_mock_args["key"] + == f"{MODULE_PREFIX}.test_tracer_method_exception_metadata..greeting error" + ) assert put_metadata_mock_args["namespace"] == "booking" @@ -305,15 +314,23 @@ async def greeting(name, message): # THEN we should add metadata for each response like we would for a sync decorated method assert in_subsegment_mock.in_subsegment.call_count == 2 - assert in_subsegment_greeting_call_args == mocker.call(name="## greeting") - assert in_subsegment_greeting2_call_args == mocker.call(name="## greeting_2") + assert in_subsegment_greeting_call_args == mocker.call( + name=f"## {MODULE_PREFIX}.test_tracer_method_nested_async..greeting" + ) + assert in_subsegment_greeting2_call_args == mocker.call( + name=f"## {MODULE_PREFIX}.test_tracer_method_nested_async..greeting_2" + ) assert in_subsegment_mock.put_metadata.call_count == 2 assert put_metadata_greeting2_call_args == mocker.call( - key="greeting_2 response", value=dummy_response, namespace="booking" + key=f"{MODULE_PREFIX}.test_tracer_method_nested_async..greeting_2 response", + value=dummy_response, + namespace="booking", ) assert put_metadata_greeting_call_args == mocker.call( - key="greeting response", value=dummy_response, namespace="booking" + key=f"{MODULE_PREFIX}.test_tracer_method_nested_async..greeting response", + value=dummy_response, + namespace="booking", ) @@ -355,7 +372,10 @@ async def greeting(name, message): # THEN we should add the exception using method name as key plus error # and their service name as the namespace put_metadata_mock_args = in_subsegment_mock.put_metadata.call_args[1] - assert put_metadata_mock_args["key"] == "greeting error" + assert ( + put_metadata_mock_args["key"] + == f"{MODULE_PREFIX}.test_tracer_method_exception_metadata_async..greeting error" + ) assert put_metadata_mock_args["namespace"] == "booking" @@ -387,7 +407,9 @@ def handler(event, context): assert "test result" in in_subsegment_mock.put_metadata.call_args[1]["value"] assert in_subsegment_mock.in_subsegment.call_count == 2 assert handler_trace == mocker.call(name="## handler") - assert yield_function_trace == mocker.call(name="## yield_with_capture") + assert yield_function_trace == mocker.call( + name=f"## {MODULE_PREFIX}.test_tracer_yield_from_context_manager..yield_with_capture" + ) assert "test result" in result @@ -411,7 +433,10 @@ def yield_with_capture(): # THEN we should add the exception using method name as key plus error # and their service name as the namespace put_metadata_mock_args = in_subsegment_mock.put_metadata.call_args[1] - assert put_metadata_mock_args["key"] == "yield_with_capture error" + assert ( + put_metadata_mock_args["key"] + == f"{MODULE_PREFIX}.test_tracer_yield_from_context_manager_exception_metadata..yield_with_capture error" # noqa E501 + ) assert isinstance(put_metadata_mock_args["value"], ValueError) assert put_metadata_mock_args["namespace"] == "booking" @@ -453,7 +478,9 @@ def handler(event, context): assert "test result" in in_subsegment_mock.put_metadata.call_args[1]["value"] assert in_subsegment_mock.in_subsegment.call_count == 2 assert handler_trace == mocker.call(name="## handler") - assert yield_function_trace == mocker.call(name="## yield_with_capture") + assert yield_function_trace == mocker.call( + name=f"## {MODULE_PREFIX}.test_tracer_yield_from_nested_context_manager..yield_with_capture" + ) assert "test result" in result @@ -483,7 +510,9 @@ def handler(event, context): assert "test result" in in_subsegment_mock.put_metadata.call_args[1]["value"] assert in_subsegment_mock.in_subsegment.call_count == 2 assert handler_trace == mocker.call(name="## handler") - assert generator_fn_trace == mocker.call(name="## generator_fn") + assert generator_fn_trace == mocker.call( + name=f"## {MODULE_PREFIX}.test_tracer_yield_from_generator..generator_fn" + ) assert "test result" in result @@ -506,7 +535,10 @@ def generator_fn(): # THEN we should add the exception using method name as key plus error # and their service name as the namespace put_metadata_mock_args = in_subsegment_mock.put_metadata.call_args[1] - assert put_metadata_mock_args["key"] == "generator_fn error" + assert ( + put_metadata_mock_args["key"] + == f"{MODULE_PREFIX}.test_tracer_yield_from_generator_exception_metadata..generator_fn error" + ) assert put_metadata_mock_args["namespace"] == "booking" assert isinstance(put_metadata_mock_args["value"], ValueError) assert str(put_metadata_mock_args["value"]) == "test" From 0c8453f805a15c68728e83f45be2748cd6fc6e8a Mon Sep 17 00:00:00 2001 From: kt-hr <25603933+kt-hr@users.noreply.github.com> Date: Thu, 15 Sep 2022 21:44:19 +0900 Subject: [PATCH 06/30] chore(core): expose modules in the Top-level package (#1517) --- aws_lambda_powertools/__init__.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/aws_lambda_powertools/__init__.py b/aws_lambda_powertools/__init__.py index 750ae92c4d1..574c9b257f1 100644 --- a/aws_lambda_powertools/__init__.py +++ b/aws_lambda_powertools/__init__.py @@ -1,14 +1,21 @@ # -*- coding: utf-8 -*- +"""Top-level package for Lambda Python Powertools.""" + from pathlib import Path -"""Top-level package for Lambda Python Powertools.""" -from .logging import Logger # noqa: F401 -from .metrics import Metrics, single_metric # noqa: F401 +from .logging import Logger +from .metrics import Metrics, single_metric from .package_logger import set_package_logger_handler -from .tracing import Tracer # noqa: F401 +from .tracing import Tracer __author__ = """Amazon Web Services""" +__all__ = [ + "Logger", + "Metrics", + "single_metric", + "Tracer", +] PACKAGE_PATH = Path(__file__).parent From e201bd287b066a9769cfcf9f0487aaace0bf5f65 Mon Sep 17 00:00:00 2001 From: Heitor Lessa Date: Tue, 20 Sep 2022 17:12:10 +0200 Subject: [PATCH 07/30] chore(ci): migrate E2E tests to CDK CLI and off Docker (#1501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rúben Fonseca --- .github/workflows/run-e2e-tests.yml | 12 +- .gitignore | 4 + MAINTAINERS.md | 393 +++++++++++++++--- aws_lambda_powertools/tracing/tracer.py | 25 +- package-lock.json | 58 +++ package.json | 7 + parallel_run_e2e.py | 1 - poetry.lock | 102 ++--- pyproject.toml | 3 +- tests/e2e/conftest.py | 28 +- tests/e2e/event_handler/conftest.py | 13 +- tests/e2e/event_handler/infrastructure.py | 13 +- tests/e2e/logger/conftest.py | 13 +- tests/e2e/logger/infrastructure.py | 7 - tests/e2e/metrics/conftest.py | 13 +- tests/e2e/metrics/infrastructure.py | 7 - tests/e2e/tracer/conftest.py | 12 +- tests/e2e/tracer/handlers/async_capture.py | 1 + tests/e2e/tracer/handlers/basic_handler.py | 1 + .../e2e/tracer/handlers/same_function_name.py | 2 + tests/e2e/tracer/infrastructure.py | 14 +- tests/e2e/tracer/test_tracer.py | 27 +- tests/e2e/utils/Dockerfile | 14 - tests/e2e/utils/asset.py | 147 ------- tests/e2e/utils/base.py | 20 + tests/e2e/utils/constants.py | 8 + tests/e2e/utils/infrastructure.py | 289 +++++++------ tests/e2e/utils/lambda_layer/__init__.py | 0 tests/e2e/utils/lambda_layer/base.py | 32 ++ .../utils/lambda_layer/powertools_layer.py | 48 +++ 30 files changed, 751 insertions(+), 563 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json delete mode 100644 tests/e2e/utils/Dockerfile delete mode 100644 tests/e2e/utils/asset.py create mode 100644 tests/e2e/utils/base.py create mode 100644 tests/e2e/utils/constants.py create mode 100644 tests/e2e/utils/lambda_layer/__init__.py create mode 100644 tests/e2e/utils/lambda_layer/base.py create mode 100644 tests/e2e/utils/lambda_layer/powertools_layer.py diff --git a/.github/workflows/run-e2e-tests.yml b/.github/workflows/run-e2e-tests.yml index 86176968839..ef9305373ac 100644 --- a/.github/workflows/run-e2e-tests.yml +++ b/.github/workflows/run-e2e-tests.yml @@ -28,8 +28,8 @@ jobs: strategy: matrix: # Maintenance: disabled until we discover concurrency lock issue with multiple versions and tmp - # version: ["3.7", "3.8", "3.9"] - version: ["3.7"] + version: ["3.7", "3.8", "3.9"] + # version: ["3.7"] steps: - name: "Checkout" uses: actions/checkout@v3 @@ -41,6 +41,14 @@ jobs: python-version: ${{ matrix.version }} architecture: "x64" cache: "poetry" + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: "16.12" + - name: Install CDK CLI + run: | + npm install + cdk --version - name: Install dependencies run: make dev - name: Configure AWS credentials diff --git a/.gitignore b/.gitignore index cc01240a405..a69b4eaf618 100644 --- a/.gitignore +++ b/.gitignore @@ -310,3 +310,7 @@ site/ !.github/workflows/lib examples/**/sam/.aws-sam + +cdk.out +# NOTE: different accounts will be used for E2E thus creating unnecessary git clutter +cdk.context.json diff --git a/MAINTAINERS.md b/MAINTAINERS.md index fb94090f762..260f6628aa3 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -15,8 +15,6 @@ - [Releasing a new version](#releasing-a-new-version) - [Drafting release notes](#drafting-release-notes) - [Run end to end tests](#run-end-to-end-tests) - - [Structure](#structure) - - [Workflow](#workflow) - [Releasing a documentation hotfix](#releasing-a-documentation-hotfix) - [Maintain Overall Health of the Repo](#maintain-overall-health-of-the-repo) - [Manage Roadmap](#manage-roadmap) @@ -30,6 +28,16 @@ - [Is that a bug?](#is-that-a-bug) - [Mentoring contributions](#mentoring-contributions) - [Long running issues or PRs](#long-running-issues-or-prs) +- [E2E framework](#e2e-framework) + - [Structure](#structure) + - [Mechanics](#mechanics) + - [Authoring a new feature E2E test](#authoring-a-new-feature-e2e-test) + - [1. Define infrastructure](#1-define-infrastructure) + - [2. Deploy/Delete infrastructure when tests run](#2-deploydelete-infrastructure-when-tests-run) + - [3. Access stack outputs for E2E tests](#3-access-stack-outputs-for-e2e-tests) + - [Internals](#internals) + - [Test runner parallelization](#test-runner-parallelization) + - [CDK CLI parallelization](#cdk-cli-parallelization) ## Overview @@ -218,18 +226,88 @@ E2E tests are run on every push to `develop` or manually via [run-e2e-tests work To run locally, you need [AWS CDK CLI](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html#getting_started_prerequisites) and an [account bootstrapped](https://docs.aws.amazon.com/cdk/v2/guide/bootstrapping.html) (`cdk bootstrap`). With a default AWS CLI profile configured, or `AWS_PROFILE` environment variable set, run `make e2e tests`. -#### Structure +### Releasing a documentation hotfix + +You can rebuild the latest documentation without a full release via this [GitHub Actions Workflow](https://github.com/awslabs/aws-lambda-powertools-python/actions/workflows/rebuild_latest_docs.yml). Choose `Run workflow`, keep `develop` as the branch, and input the latest Powertools version available. + +This workflow will update both user guide and API documentation. + +### Maintain Overall Health of the Repo + +> TODO: Coordinate renaming `develop` to `main` + +Keep the `develop` branch at production quality at all times. Backport features as needed. Cut release branches and tags to enable future patches. + +### Manage Roadmap + +See [Roadmap section](https://awslabs.github.io/aws-lambda-powertools-python/latest/roadmap/) + +Ensure the repo highlights features that should be elevated to the project roadmap. Be clear about the feature’s status, priority, target version, and whether or not it should be elevated to the roadmap. + +### Add Continuous Integration Checks + +Add integration checks that validate pull requests and pushes to ease the burden on Pull Request reviewers. Continuously revisit areas of improvement to reduce operational burden in all parties involved. + +### Negative Impact on the Project + +Actions that negatively impact the project will be handled by the admins, in coordination with other maintainers, in balance with the urgency of the issue. Examples would be [Code of Conduct](CODE_OF_CONDUCT.md) violations, deliberate harmful or malicious actions, spam, monopolization, and security risks. + +### Becoming a maintainer + +In 2023, we will revisit this. We need to improve our understanding of how other projects are doing, their mechanisms to promote key contributors, and how they interact daily. + +We suspect this process might look similar to the [OpenSearch project](https://github.com/opensearch-project/.github/blob/main/MAINTAINERS.md#becoming-a-maintainer). + +## Common scenarios + +These are recurring ambiguous situations that new and existing maintainers may encounter. They serve as guidance. It is up to each maintainer to follow, adjust, or handle in a different manner as long as [our conduct is consistent](#uphold-code-of-conduct) + +### Contribution is stuck + +A contribution can get stuck often due to lack of bandwidth and language barrier. For bandwidth issues, check whether the author needs help. Make sure you get their permission before pushing code into their existing PR - do not create a new PR unless strictly necessary. + +For language barrier and others, offer a 1:1 chat to get them unblocked. Often times, English might not be their primary language, and writing in public might put them off, or come across not the way they intended to be. + +In other cases, you may have constrained capacity. Use `help wanted` label when you want to signal other maintainers and external contributors that you could use a hand to move it forward. -Our E2E framework relies on pytest fixtures to coordinate infrastructure and test parallelization (see [Workflow](#workflow)). You'll notice multiple `conftest.py`, `infrastructure.py`, and `handlers`. +### Insufficient feedback or information + +When in doubt, use `need-more-information` or `need-customer-feedback` labels to signal more context and feedback are necessary before proceeding. You can also use `revisit-in-3-months` label when you expect it might take a while to gather enough information before you can decide. + +### Crediting contributions + +We credit all contributions as part of each [release note](https://github.com/awslabs/aws-lambda-powertools-python/releases) as an automated process. If you find contributors are missing from the release note you're producing, please add them manually. + +### Is that a bug? + +A bug produces incorrect or unexpected results at runtime that differ from its intended behavior. Bugs must be reproducible. They directly affect customers experience at runtime despite following its recommended usage. -- **`infrastructure`**. Uses CDK to define what a Stack for a given feature should look like. It inherits from `BaseInfrastructure` to handle all boilerplate and deployment logic necessary. -- **`conftest.py`**. Imports and deploys a given feature Infrastructure. Hierarchy matters. Top-level `conftest` deploys stacks only once and blocks I/O across all CPUs. Feature-level `conftest` deploys stacks in parallel, and once complete run all tests in parallel. -- **`handlers`**. Lambda function handlers that will be automatically deployed and exported as PascalCase for later use. +Documentation snippets, use of internal components, or unadvertised functionalities are not considered bugs. + +### Mentoring contributions + +Always favor mentoring issue authors to contribute, unless they're not interested or the implementation is sensitive (_e.g., complexity, time to release, etc._). + +Make use of `help wanted` and `good first issue` to signal additional contributions the community can help. + +### Long running issues or PRs + +Try offering a 1:1 call in the attempt to get to a mutual understanding and clarify areas that maintainers could help. + +In the rare cases where both parties don't have the bandwidth or expertise to continue, it's best to use the `revisit-in-3-months` label. By then, see if it's possible to break the PR or issue in smaller chunks, and eventually close if there is no progress. + +## E2E framework + +### Structure + +Our E2E framework relies on [Pytest fixtures](https://docs.pytest.org/en/6.2.x/fixture.html) to coordinate infrastructure and test parallelization - see [Test Parallelization](#test-runner-parallelization) and [CDK CLI Parallelization](#cdk-cli-parallelization). + +**tests/e2e structure** ```shell . ├── __init__.py -├── conftest.py # deploys Lambda Layer stack +├── conftest.py # builds Lambda Layer once ├── logger │ ├── __init__.py │ ├── conftest.py # deploys LoggerStack @@ -254,112 +332,293 @@ Our E2E framework relies on pytest fixtures to coordinate infrastructure and tes │ ├── infrastructure.py # TracerStack definition │ └── test_tracer.py └── utils - ├── Dockerfile ├── __init__.py ├── data_builder # build_service_name(), build_add_dimensions_input, etc. ├── data_fetcher # get_traces(), get_logs(), get_lambda_response(), etc. - ├── infrastructure.py # base infrastructure like deploy logic, Layer Stack, etc. + ├── infrastructure.py # base infrastructure like deploy logic, etc. ``` -#### Workflow +Where: -We parallelize our end-to-end tests to benefit from speed and isolate Lambda functions to ease assessing side effects (e.g., traces, logs, etc.). The following diagram demonstrates the process we take every time you use `make e2e`: +- **`/infrastructure.py`**. Uses CDK to define the infrastructure a given feature needs. +- **`/handlers/`**. Lambda function handlers to build, deploy, and exposed as stack output in PascalCase (e.g., `BasicHandler`). +- **`utils/`**. Test utilities to build data and fetch AWS data to ease assertion +- **`conftest.py`**. Deploys and deletes a given feature infrastructure. Hierarchy matters: + - **Top-level (`e2e/conftest`)**. Builds Lambda Layer only once and blocks I/O across all CPU workers. + - **Feature-level (`e2e//conftest`)**. Deploys stacks in parallel and make them independent of each other. + +### Mechanics + +Under [`BaseInfrastructure`](https://github.com/awslabs/aws-lambda-powertools-python/blob/develop/tests/e2e/utils/infrastructure.py), we hide the complexity of deployment and delete coordination under `deploy`, `delete`, and `create_lambda_functions` methods. + +This allows us to benefit from test and deployment parallelization, use IDE step-through debugging for a single test, run one, subset, or all tests and only deploy their related infrastructure, without any custom configuration. + +> Class diagram to understand abstraction built when defining a new stack (`LoggerStack`) ```mermaid -graph TD - A[make e2e test] -->Spawn{"Split and group tests
by feature and CPU"} +classDiagram + class InfrastructureProvider { + <> + +deploy() Dict + +delete() + +create_resources() + +create_lambda_functions() Dict~Functions~ + } + + class BaseInfrastructure { + +deploy() Dict + +delete() + +create_lambda_functions() Dict~Functions~ + +add_cfn_output() + } + + class TracerStack { + +create_resources() + } + + class LoggerStack { + +create_resources() + } + + class MetricsStack { + +create_resources() + } + + class EventHandlerStack { + +create_resources() + } + + InfrastructureProvider <|-- BaseInfrastructure : implement + BaseInfrastructure <|-- TracerStack : inherit + BaseInfrastructure <|-- LoggerStack : inherit + BaseInfrastructure <|-- MetricsStack : inherit + BaseInfrastructure <|-- EventHandlerStack : inherit +``` - Spawn -->|Worker0| Worker0_Start["Load tests"] - Spawn -->|Worker1| Worker1_Start["Load tests"] - Spawn -->|WorkerN| WorkerN_Start["Load tests"] +### Authoring a new feature E2E test - Worker0_Start -->|Wait| LambdaLayerStack["Lambda Layer Stack Deployment"] - Worker1_Start -->|Wait| LambdaLayerStack["Lambda Layer Stack Deployment"] - WorkerN_Start -->|Wait| LambdaLayerStack["Lambda Layer Stack Deployment"] +Imagine you're going to create E2E for Event Handler feature for the first time. Keep the following mental model when reading: - LambdaLayerStack -->|Worker0| Worker0_Deploy["Launch feature stack"] - LambdaLayerStack -->|Worker1| Worker1_Deploy["Launch feature stack"] - LambdaLayerStack -->|WorkerN| WorkerN_Deploy["Launch feature stack"] +```mermaid +graph LR + A["1. Define infrastructure"]-->B["2. Deploy/Delete infrastructure"]-->C["3.Access Stack outputs" ] +``` - Worker0_Deploy -->|Worker0| Worker0_Tests["Run tests"] - Worker1_Deploy -->|Worker1| Worker1_Tests["Run tests"] - WorkerN_Deploy -->|WorkerN| WorkerN_Tests["Run tests"] +#### 1. Define infrastructure - Worker0_Tests --> ResultCollection - Worker1_Tests --> ResultCollection - WorkerN_Tests --> ResultCollection +We use CDK as our Infrastructure as Code tool of choice. Before you start using CDK, you'd take the following steps: - ResultCollection{"Wait for workers
Collect test results"} - ResultCollection --> TestEnd["Report results"] - ResultCollection --> DeployEnd["Delete Stacks"] +1. Create `tests/e2e/event_handler/infrastructure.py` file +2. Create a new class `EventHandlerStack` and inherit from `BaseInfrastructure` +3. Override `create_resources` method and define your infrastructure using CDK +4. (Optional) Create a Lambda function under `handlers/alb_handler.py` + +> Excerpt `tests/e2e/event_handler/infrastructure.py` + +```python +class EventHandlerStack(BaseInfrastructure): + def create_resources(self): + functions = self.create_lambda_functions() + + self._create_alb(function=functions["AlbHandler"]) + ... + + def _create_alb(self, function: Function): + vpc = ec2.Vpc.from_lookup( + self.stack, + "VPC", + is_default=True, + region=self.region, + ) + + alb = elbv2.ApplicationLoadBalancer(self.stack, "ALB", vpc=vpc, internet_facing=True) + CfnOutput(self.stack, "ALBDnsName", value=alb.load_balancer_dns_name) + ... ``` -### Releasing a documentation hotfix +> Excerpt `tests/e2e/event_handler/handlers/alb_handler.py` -You can rebuild the latest documentation without a full release via this [GitHub Actions Workflow](https://github.com/awslabs/aws-lambda-powertools-python/actions/workflows/rebuild_latest_docs.yml). Choose `Run workflow`, keep `develop` as the branch, and input the latest Powertools version available. +```python +from aws_lambda_powertools.event_handler import ALBResolver, Response, content_types -This workflow will update both user guide and API documentation. +app = ALBResolver() -### Maintain Overall Health of the Repo -> TODO: Coordinate renaming `develop` to `main` +@app.get("/todos") +def hello(): + return Response( + status_code=200, + content_type=content_types.TEXT_PLAIN, + body="Hello world", + cookies=["CookieMonster", "MonsterCookie"], + headers={"Foo": ["bar", "zbr"]}, + ) -Keep the `develop` branch at production quality at all times. Backport features as needed. Cut release branches and tags to enable future patches. -### Manage Roadmap +def lambda_handler(event, context): + return app.resolve(event, context) +``` -See [Roadmap section](https://awslabs.github.io/aws-lambda-powertools-python/latest/roadmap/) +#### 2. Deploy/Delete infrastructure when tests run -Ensure the repo highlights features that should be elevated to the project roadmap. Be clear about the feature’s status, priority, target version, and whether or not it should be elevated to the roadmap. +We need to create a Pytest fixture for our new feature under `tests/e2e/event_handler/conftest.py`. -### Add Continuous Integration Checks +This will instruct Pytest to deploy our infrastructure when our tests start, and delete it when they complete whether tests are successful or not. Note that this file will not need any modification in the future. -Add integration checks that validate pull requests and pushes to ease the burden on Pull Request reviewers. Continuously revisit areas of improvement to reduce operational burden in all parties involved. +> Excerpt `conftest.py` for Event Handler -### Negative Impact on the Project +```python +import pytest -Actions that negatively impact the project will be handled by the admins, in coordination with other maintainers, in balance with the urgency of the issue. Examples would be [Code of Conduct](CODE_OF_CONDUCT.md) violations, deliberate harmful or malicious actions, spam, monopolization, and security risks. +from tests.e2e.event_handler.infrastructure import EventHandlerStack -### Becoming a maintainer -In 2023, we will revisit this. We need to improve our understanding of how other projects are doing, their mechanisms to promote key contributors, and how they interact daily. +@pytest.fixture(autouse=True, scope="module") +def infrastructure(): + """Setup and teardown logic for E2E test infrastructure -We suspect this process might look similar to the [OpenSearch project](https://github.com/opensearch-project/.github/blob/main/MAINTAINERS.md#becoming-a-maintainer). + Yields + ------ + Dict[str, str] + CloudFormation Outputs from deployed infrastructure + """ + stack = EventHandlerStack() + try: + yield stack.deploy() + finally: + stack.delete() -## Common scenarios +``` -These are recurring ambiguous situations that new and existing maintainers may encounter. They serve as guidance. It is up to each maintainer to follow, adjust, or handle in a different manner as long as [our conduct is consistent](#uphold-code-of-conduct) +#### 3. Access stack outputs for E2E tests -### Contribution is stuck +Within our tests, we should now have access to the `infrastructure` fixture we defined earlier in `tests/e2e/event_handler/conftest.py`. -A contribution can get stuck often due to lack of bandwidth and language barrier. For bandwidth issues, check whether the author needs help. Make sure you get their permission before pushing code into their existing PR - do not create a new PR unless strictly necessary. +We can access any Stack Output using pytest dependency injection. -For language barrier and others, offer a 1:1 chat to get them unblocked. Often times, English might not be their primary language, and writing in public might put them off, or come across not the way they intended to be. +> Excerpt `tests/e2e/event_handler/test_header_serializer.py` -In other cases, you may have constrained capacity. Use `help wanted` label when you want to signal other maintainers and external contributors that you could use a hand to move it forward. +```python +@pytest.fixture +def alb_basic_listener_endpoint(infrastructure: dict) -> str: + dns_name = infrastructure.get("ALBDnsName") + port = infrastructure.get("ALBBasicListenerPort", "") + return f"http://{dns_name}:{port}" -### Insufficient feedback or information -When in doubt, use `need-more-information` or `need-customer-feedback` labels to signal more context and feedback are necessary before proceeding. You can also use `revisit-in-3-months` label when you expect it might take a while to gather enough information before you can decide. +def test_alb_headers_serializer(alb_basic_listener_endpoint): + # GIVEN + url = f"{alb_basic_listener_endpoint}/todos" + ... +``` -### Crediting contributions +### Internals -We credit all contributions as part of each [release note](https://github.com/awslabs/aws-lambda-powertools-python/releases) as an automated process. If you find contributors are missing from the release note you're producing, please add them manually. +#### Test runner parallelization -### Is that a bug? +Besides speed, we parallelize our end-to-end tests to ease asserting async side-effects may take a while per test too, _e.g., traces to become available_. -A bug produces incorrect or unexpected results at runtime that differ from its intended behavior. Bugs must be reproducible. They directly affect customers experience at runtime despite following its recommended usage. +The following diagram demonstrates the process we take every time you use `make e2e` locally or at CI: -Documentation snippets, use of internal components, or unadvertised functionalities are not considered bugs. +```mermaid +graph TD + A[make e2e test] -->Spawn{"Split and group tests
by feature and CPU"} -### Mentoring contributions + Spawn -->|Worker0| Worker0_Start["Load tests"] + Spawn -->|Worker1| Worker1_Start["Load tests"] + Spawn -->|WorkerN| WorkerN_Start["Load tests"] -Always favor mentoring issue authors to contribute, unless they're not interested or the implementation is sensitive (_e.g., complexity, time to release, etc._). + Worker0_Start -->|Wait| LambdaLayer["Lambda Layer build"] + Worker1_Start -->|Wait| LambdaLayer["Lambda Layer build"] + WorkerN_Start -->|Wait| LambdaLayer["Lambda Layer build"] -Make use of `help wanted` and `good first issue` to signal additional contributions the community can help. + LambdaLayer -->|Worker0| Worker0_Deploy["Launch feature stack"] + LambdaLayer -->|Worker1| Worker1_Deploy["Launch feature stack"] + LambdaLayer -->|WorkerN| WorkerN_Deploy["Launch feature stack"] -### Long running issues or PRs + Worker0_Deploy -->|Worker0| Worker0_Tests["Run tests"] + Worker1_Deploy -->|Worker1| Worker1_Tests["Run tests"] + WorkerN_Deploy -->|WorkerN| WorkerN_Tests["Run tests"] -Try offering a 1:1 call in the attempt to get to a mutual understanding and clarify areas that maintainers could help. + Worker0_Tests --> ResultCollection + Worker1_Tests --> ResultCollection + WorkerN_Tests --> ResultCollection -In the rare cases where both parties don't have the bandwidth or expertise to continue, it's best to use the `revisit-in-3-months` label. By then, see if it's possible to break the PR or issue in smaller chunks, and eventually close if there is no progress. + ResultCollection{"Wait for workers
Collect test results"} + ResultCollection --> TestEnd["Report results"] + ResultCollection --> DeployEnd["Delete Stacks"] +``` + +#### CDK CLI parallelization + +For CDK CLI to work with [independent CDK Apps](https://docs.aws.amazon.com/cdk/v2/guide/apps.html), we specify an output directory when synthesizing our stack and deploy from said output directory. + +```mermaid +flowchart TD + subgraph "Deploying distinct CDK Apps" + EventHandlerInfra["Event Handler CDK App"] --> EventHandlerSynth + TracerInfra["Tracer CDK App"] --> TracerSynth + EventHandlerSynth["cdk synth --out cdk.out/event_handler"] --> EventHandlerDeploy["cdk deploy --app cdk.out/event_handler"] + + TracerSynth["cdk synth --out cdk.out/tracer"] --> TracerDeploy["cdk deploy --app cdk.out/tracer"] + end +``` + +We create the typical CDK `app.py` at runtime when tests run, since we know which feature and Python version we're dealing with (locally or at CI). + +> Excerpt `cdk_app_V39.py` for Event Handler created at deploy phase + +```python +from tests.e2e.event_handler.infrastructure import EventHandlerStack +stack = EventHandlerStack() +stack.create_resources() +stack.app.synth() +``` + +When we run E2E tests for a single feature or all of them, our `cdk.out` looks like this: + +```shell +total 8 +drwxr-xr-x 18 lessa staff 576B Sep 6 15:38 event-handler +drwxr-xr-x 3 lessa staff 96B Sep 6 15:08 layer_build +-rw-r--r-- 1 lessa staff 32B Sep 6 15:08 layer_build.diff +drwxr-xr-x 18 lessa staff 576B Sep 6 15:38 logger +drwxr-xr-x 18 lessa staff 576B Sep 6 15:38 metrics +drwxr-xr-x 22 lessa staff 704B Sep 9 10:52 tracer +``` + +```mermaid +classDiagram + class CdkOutDirectory { + feature_name/ + layer_build/ + layer_build.diff + } + + class EventHandler { + manifest.json + stack_outputs.json + cdk_app_V39.py + asset.uuid/ + ... + } + + class StackOutputsJson { + BasicHandlerArn: str + ALBDnsName: str + ... + } + + CdkOutDirectory <|-- EventHandler : feature_name/ + StackOutputsJson <|-- EventHandler +``` + +Where: + +- **``**. Contains CDK Assets, CDK `manifest.json`, our `cdk_app_.py` and `stack_outputs.json` +- **`layer_build`**. Contains our Lambda Layer source code built once, used by all stacks independently +- **`layer_build.diff`**. Contains a hash on whether our source code has changed to speed up further deployments and E2E tests + +Together, all of this allows us to use Pytest like we would for any project, use CDK CLI and its [context methods](https://docs.aws.amazon.com/cdk/v2/guide/context.html#context_methods) (`from_lookup`), and use step-through debugging for a single E2E test without any extra configuration. + +> NOTE: VSCode doesn't support debugging processes spawning sub-processes (like CDK CLI does w/ shell and CDK App). Maybe [this works](https://stackoverflow.com/a/65339352). PyCharm works just fine. diff --git a/aws_lambda_powertools/tracing/tracer.py b/aws_lambda_powertools/tracing/tracer.py index 7053497ae6d..0523d53c41d 100644 --- a/aws_lambda_powertools/tracing/tracer.py +++ b/aws_lambda_powertools/tracing/tracer.py @@ -300,16 +300,6 @@ def handler(event, context): @functools.wraps(lambda_handler) def decorate(event, context, **kwargs): with self.provider.in_subsegment(name=f"## {lambda_handler_name}") as subsegment: - global is_cold_start - logger.debug("Annotating cold start") - subsegment.put_annotation(key="ColdStart", value=is_cold_start) - - if is_cold_start: - is_cold_start = False - - if self.service: - subsegment.put_annotation(key="Service", value=self.service) - try: logger.debug("Calling lambda handler") response = lambda_handler(event, context, **kwargs) @@ -325,7 +315,18 @@ def decorate(event, context, **kwargs): self._add_full_exception_as_metadata( method_name=lambda_handler_name, error=err, subsegment=subsegment, capture_error=capture_error ) + raise + finally: + global is_cold_start + logger.debug("Annotating cold start") + subsegment.put_annotation(key="ColdStart", value=is_cold_start) + + if is_cold_start: + is_cold_start = False + + if self.service: + subsegment.put_annotation(key="Service", value=self.service) return response @@ -672,7 +673,7 @@ def _add_response_as_metadata( if data is None or not capture_response or subsegment is None: return - subsegment.put_metadata(key=f"{method_name} response", value=data, namespace=self._config["service"]) + subsegment.put_metadata(key=f"{method_name} response", value=data, namespace=self.service) def _add_full_exception_as_metadata( self, @@ -697,7 +698,7 @@ def _add_full_exception_as_metadata( if not capture_error: return - subsegment.put_metadata(key=f"{method_name} error", value=error, namespace=self._config["service"]) + subsegment.put_metadata(key=f"{method_name} error", value=error, namespace=self.service) @staticmethod def _disable_tracer_provider(): diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000000..5a72aa1ad10 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,58 @@ +{ + "name": "aws-lambda-powertools-python-e2e", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "aws-lambda-powertools-python-e2e", + "version": "1.0.0", + "devDependencies": { + "aws-cdk": "2.40.0" + } + }, + "node_modules/aws-cdk": { + "version": "2.40.0", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.40.0.tgz", + "integrity": "sha512-oHacGkLFDELwhpJsZSAhFHWDxIeZW3DgKkwiXlNO81JxNfjcHgPR2rsbh/Gz+n4ErAEzOV6WfuWVMe68zv+iPg==", + "bin": { + "cdk": "bin/cdk" + }, + "engines": { + "node": ">= 14.15.0" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + } + }, + "dependencies": { + "aws-cdk": { + "version": "2.40.0", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.40.0.tgz", + "integrity": "sha512-oHacGkLFDELwhpJsZSAhFHWDxIeZW3DgKkwiXlNO81JxNfjcHgPR2rsbh/Gz+n4ErAEzOV6WfuWVMe68zv+iPg==", + "requires": { + "fsevents": "2.3.2" + } + }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "optional": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000000..6e3a2c1b216 --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "name": "aws-lambda-powertools-python-e2e", + "version": "1.0.0", + "devDependencies": { + "aws-cdk": "2.40.0" + } +} diff --git a/parallel_run_e2e.py b/parallel_run_e2e.py index b9603701e5e..745f1392f67 100755 --- a/parallel_run_e2e.py +++ b/parallel_run_e2e.py @@ -8,7 +8,6 @@ def main(): workers = len(list(features)) - 1 command = f"poetry run pytest -n {workers} --dist loadfile -o log_cli=true tests/e2e" - print(f"Running E2E tests with: {command}") subprocess.run(command.split(), shell=False) diff --git a/poetry.lock b/poetry.lock index 7d95f6b9f8e..93770dd60cd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -175,6 +175,14 @@ python-versions = ">=3.6.0" [package.extras] unicode_backport = ["unicodedata2"] +[[package]] +name = "checksumdir" +version = "1.2.0" +description = "Compute a single hash of the file contents of a directory." +category = "dev" +optional = false +python-versions = ">=3.6,<4.0" + [[package]] name = "click" version = "8.1.3" @@ -637,7 +645,7 @@ python-versions = ">=3.6" [[package]] name = "mike" -version = "0.6.0" +version = "1.1.2" description = "Manage multiple versions of your MkDocs-powered documentation" category = "dev" optional = false @@ -646,12 +654,12 @@ python-versions = "*" [package.dependencies] jinja2 = "*" mkdocs = ">=1.0" -packaging = "*" -"ruamel.yaml" = "*" +pyyaml = ">=5.1" +verspec = "*" [package.extras] -test = ["flake8 (>=3.0)", "coverage"] -dev = ["pypandoc (>=1.4)", "flake8 (>=3.0)", "coverage"] +test = ["shtab", "flake8 (>=3.0)", "coverage"] +dev = ["shtab", "flake8 (>=3.0)", "coverage"] [[package]] name = "mkdocs" @@ -1198,29 +1206,6 @@ python-versions = "*" decorator = ">=3.4.2" py = ">=1.4.26,<2.0.0" -[[package]] -name = "ruamel.yaml" -version = "0.17.21" -description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" -category = "dev" -optional = false -python-versions = ">=3" - -[package.dependencies] -"ruamel.yaml.clib" = {version = ">=0.2.6", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.11\""} - -[package.extras] -docs = ["ryd"] -jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] - -[[package]] -name = "ruamel.yaml.clib" -version = "0.2.6" -description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" -category = "dev" -optional = false -python-versions = ">=3.5" - [[package]] name = "s3transfer" version = "0.6.0" @@ -1331,6 +1316,17 @@ brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] +[[package]] +name = "verspec" +version = "0.1.0" +description = "Flexible version handling" +category = "dev" +optional = false +python-versions = "*" + +[package.extras] +test = ["pytest", "pretend", "mypy", "flake8 (>=3.7)", "coverage"] + [[package]] name = "watchdog" version = "2.1.9" @@ -1381,7 +1377,7 @@ pydantic = ["pydantic", "email-validator"] [metadata] lock-version = "1.1" python-versions = "^3.7.4" -content-hash = "1500a968030f6adae44497fbb31beaef774fa53f7020ee264a4f5971b38fc597" +content-hash = "0ef937932afc677f409d634770d46aefbc62c1befe060ce1b9fb0e4f263e3ec8" [metadata.files] attrs = [ @@ -1451,6 +1447,10 @@ charset-normalizer = [ {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, ] +checksumdir = [ + {file = "checksumdir-1.2.0-py3-none-any.whl", hash = "sha256:77687e16da95970c94061c74ef2e13666c4b6e0e8c90a5eaf0c8f7591332cf01"}, + {file = "checksumdir-1.2.0.tar.gz", hash = "sha256:10bfd7518da5a14b0e9ac03e9ad105f0e70f58bba52b6e9aa2f21a3f73c7b5a8"}, +] click = [ {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, @@ -1692,8 +1692,8 @@ mergedeep = [ {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, ] mike = [ - {file = "mike-0.6.0-py3-none-any.whl", hash = "sha256:cef9b9c803ff5c3fbb410f51f5ceb00902a9fe16d9fabd93b69c65cf481ab5a1"}, - {file = "mike-0.6.0.tar.gz", hash = "sha256:6d6239de2a60d733da2f34617e9b9a14c4b5437423b47e524f14dc96d6ce5f2f"}, + {file = "mike-1.1.2-py3-none-any.whl", hash = "sha256:4c307c28769834d78df10f834f57f810f04ca27d248f80a75f49c6fa2d1527ca"}, + {file = "mike-1.1.2.tar.gz", hash = "sha256:56c3f1794c2d0b5fdccfa9b9487beb013ca813de2e3ad0744724e9d34d40b77b"}, ] mkdocs = [ {file = "mkdocs-1.4.0-py3-none-any.whl", hash = "sha256:ce057e9992f017b8e1496b591b6c242cbd34c2d406e2f9af6a19b97dd6248faa"}, @@ -2012,42 +2012,6 @@ retry = [ {file = "retry-0.9.2-py2.py3-none-any.whl", hash = "sha256:ccddf89761fa2c726ab29391837d4327f819ea14d244c232a1d24c67a2f98606"}, {file = "retry-0.9.2.tar.gz", hash = "sha256:f8bfa8b99b69c4506d6f5bd3b0aabf77f98cdb17f3c9fc3f5ca820033336fba4"}, ] -"ruamel.yaml" = [ - {file = "ruamel.yaml-0.17.21-py3-none-any.whl", hash = "sha256:742b35d3d665023981bd6d16b3d24248ce5df75fdb4e2924e93a05c1f8b61ca7"}, - {file = "ruamel.yaml-0.17.21.tar.gz", hash = "sha256:8b7ce697a2f212752a35c1ac414471dc16c424c9573be4926b56ff3f5d23b7af"}, -] -"ruamel.yaml.clib" = [ - {file = "ruamel.yaml.clib-0.2.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6e7be2c5bcb297f5b82fee9c665eb2eb7001d1050deaba8471842979293a80b0"}, - {file = "ruamel.yaml.clib-0.2.6-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:066f886bc90cc2ce44df8b5f7acfc6a7e2b2e672713f027136464492b0c34d7c"}, - {file = "ruamel.yaml.clib-0.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:221eca6f35076c6ae472a531afa1c223b9c29377e62936f61bc8e6e8bdc5f9e7"}, - {file = "ruamel.yaml.clib-0.2.6-cp310-cp310-win32.whl", hash = "sha256:1070ba9dd7f9370d0513d649420c3b362ac2d687fe78c6e888f5b12bf8bc7bee"}, - {file = "ruamel.yaml.clib-0.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:77df077d32921ad46f34816a9a16e6356d8100374579bc35e15bab5d4e9377de"}, - {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:cfdb9389d888c5b74af297e51ce357b800dd844898af9d4a547ffc143fa56751"}, - {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7b2927e92feb51d830f531de4ccb11b320255ee95e791022555971c466af4527"}, - {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-win32.whl", hash = "sha256:ada3f400d9923a190ea8b59c8f60680c4ef8a4b0dfae134d2f2ff68429adfab5"}, - {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-win_amd64.whl", hash = "sha256:de9c6b8a1ba52919ae919f3ae96abb72b994dd0350226e28f3686cb4f142165c"}, - {file = "ruamel.yaml.clib-0.2.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d67f273097c368265a7b81e152e07fb90ed395df6e552b9fa858c6d2c9f42502"}, - {file = "ruamel.yaml.clib-0.2.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:72a2b8b2ff0a627496aad76f37a652bcef400fd861721744201ef1b45199ab78"}, - {file = "ruamel.yaml.clib-0.2.6-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:d3c620a54748a3d4cf0bcfe623e388407c8e85a4b06b8188e126302bcab93ea8"}, - {file = "ruamel.yaml.clib-0.2.6-cp36-cp36m-win32.whl", hash = "sha256:9efef4aab5353387b07f6b22ace0867032b900d8e91674b5d8ea9150db5cae94"}, - {file = "ruamel.yaml.clib-0.2.6-cp36-cp36m-win_amd64.whl", hash = "sha256:846fc8336443106fe23f9b6d6b8c14a53d38cef9a375149d61f99d78782ea468"}, - {file = "ruamel.yaml.clib-0.2.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0847201b767447fc33b9c235780d3aa90357d20dd6108b92be544427bea197dd"}, - {file = "ruamel.yaml.clib-0.2.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:78988ed190206672da0f5d50c61afef8f67daa718d614377dcd5e3ed85ab4a99"}, - {file = "ruamel.yaml.clib-0.2.6-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:210c8fcfeff90514b7133010bf14e3bad652c8efde6b20e00c43854bf94fa5a6"}, - {file = "ruamel.yaml.clib-0.2.6-cp37-cp37m-win32.whl", hash = "sha256:a49e0161897901d1ac9c4a79984b8410f450565bbad64dbfcbf76152743a0cdb"}, - {file = "ruamel.yaml.clib-0.2.6-cp37-cp37m-win_amd64.whl", hash = "sha256:bf75d28fa071645c529b5474a550a44686821decebdd00e21127ef1fd566eabe"}, - {file = "ruamel.yaml.clib-0.2.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a32f8d81ea0c6173ab1b3da956869114cae53ba1e9f72374032e33ba3118c233"}, - {file = "ruamel.yaml.clib-0.2.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7f7ecb53ae6848f959db6ae93bdff1740e651809780822270eab111500842a84"}, - {file = "ruamel.yaml.clib-0.2.6-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:61bc5e5ca632d95925907c569daa559ea194a4d16084ba86084be98ab1cec1c6"}, - {file = "ruamel.yaml.clib-0.2.6-cp38-cp38-win32.whl", hash = "sha256:89221ec6d6026f8ae859c09b9718799fea22c0e8da8b766b0b2c9a9ba2db326b"}, - {file = "ruamel.yaml.clib-0.2.6-cp38-cp38-win_amd64.whl", hash = "sha256:31ea73e564a7b5fbbe8188ab8b334393e06d997914a4e184975348f204790277"}, - {file = "ruamel.yaml.clib-0.2.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dc6a613d6c74eef5a14a214d433d06291526145431c3b964f5e16529b1842bed"}, - {file = "ruamel.yaml.clib-0.2.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:1866cf2c284a03b9524a5cc00daca56d80057c5ce3cdc86a52020f4c720856f0"}, - {file = "ruamel.yaml.clib-0.2.6-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:1b4139a6ffbca8ef60fdaf9b33dec05143ba746a6f0ae0f9d11d38239211d335"}, - {file = "ruamel.yaml.clib-0.2.6-cp39-cp39-win32.whl", hash = "sha256:3fb9575a5acd13031c57a62cc7823e5d2ff8bc3835ba4d94b921b4e6ee664104"}, - {file = "ruamel.yaml.clib-0.2.6-cp39-cp39-win_amd64.whl", hash = "sha256:825d5fccef6da42f3c8eccd4281af399f21c02b32d98e113dbc631ea6a6ecbc7"}, - {file = "ruamel.yaml.clib-0.2.6.tar.gz", hash = "sha256:4ff604ce439abb20794f05613c374759ce10e3595d1867764dd1ae675b85acbd"}, -] s3transfer = [ {file = "s3transfer-0.6.0-py3-none-any.whl", hash = "sha256:06176b74f3a15f61f1b4f25a1fc29a4429040b7647133a463da8fa5bd28d5ecd"}, {file = "s3transfer-0.6.0.tar.gz", hash = "sha256:2ed07d3866f523cc561bf4a00fc5535827981b117dd7876f036b0c1aca42c947"}, @@ -2114,6 +2078,10 @@ urllib3 = [ {file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"}, {file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"}, ] +verspec = [ + {file = "verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31"}, + {file = "verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e"}, +] watchdog = [ {file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330"}, {file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b17d302850c8d412784d9246cfe8d7e3af6bcd45f958abb2d08a6f8bedf695d"}, diff --git a/pyproject.toml b/pyproject.toml index 74b7bffeb3e..e244a656be3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ xenon = "^0.9.0" flake8-eradicate = "^1.2.1" flake8-bugbear = "^22.9.23" mkdocs-git-revision-date-plugin = "^0.3.2" -mike = "^0.6.0" +mike = "^1.1.2" mypy = "^0.971" retry = "^0.9.2" pytest-xdist = "^2.5.0" @@ -73,6 +73,7 @@ types-requests = "^2.28.11" typing-extensions = "^4.4.0" mkdocs-material = "^8.5.4" filelock = "^3.8.0" +checksumdir = "^1.2.0" [tool.poetry.extras] pydantic = ["pydantic", "email-validator"] diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index ac55d373e63..f59eea9a33b 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -1,21 +1,15 @@ import pytest -from tests.e2e.utils.infrastructure import LambdaLayerStack, deploy_once +from tests.e2e.utils.infrastructure import call_once +from tests.e2e.utils.lambda_layer.powertools_layer import LocalLambdaPowertoolsLayer -@pytest.fixture(scope="session") -def lambda_layer_arn(lambda_layer_deployment): - yield lambda_layer_deployment.get("LayerArn") - - -@pytest.fixture(scope="session") -def lambda_layer_deployment(request: pytest.FixtureRequest, tmp_path_factory: pytest.TempPathFactory, worker_id: str): - """Setup and teardown logic for E2E test infrastructure +@pytest.fixture(scope="session", autouse=True) +def lambda_layer_build(tmp_path_factory: pytest.TempPathFactory, worker_id: str) -> str: + """Build Lambda Layer once before stacks are created Parameters ---------- - request : pytest.FixtureRequest - pytest request fixture to introspect absolute path to test being executed tmp_path_factory : pytest.TempPathFactory pytest temporary path factory to discover shared tmp when multiple CPU processes are spun up worker_id : str @@ -23,13 +17,13 @@ def lambda_layer_deployment(request: pytest.FixtureRequest, tmp_path_factory: py Yields ------ - Dict[str, str] - CloudFormation Outputs from deployed infrastructure + str + Lambda Layer artefact location """ - yield from deploy_once( - stack=LambdaLayerStack, - request=request, + + layer = LocalLambdaPowertoolsLayer() + yield from call_once( + task=layer.build, tmp_path_factory=tmp_path_factory, worker_id=worker_id, - layer_arn="", ) diff --git a/tests/e2e/event_handler/conftest.py b/tests/e2e/event_handler/conftest.py index 207ec443456..43941946ac7 100644 --- a/tests/e2e/event_handler/conftest.py +++ b/tests/e2e/event_handler/conftest.py @@ -1,27 +1,18 @@ -from pathlib import Path - import pytest from tests.e2e.event_handler.infrastructure import EventHandlerStack @pytest.fixture(autouse=True, scope="module") -def infrastructure(request: pytest.FixtureRequest, lambda_layer_arn: str): +def infrastructure(): """Setup and teardown logic for E2E test infrastructure - Parameters - ---------- - request : pytest.FixtureRequest - pytest request fixture to introspect absolute path to test being executed - lambda_layer_arn : str - Lambda Layer ARN - Yields ------ Dict[str, str] CloudFormation Outputs from deployed infrastructure """ - stack = EventHandlerStack(handlers_dir=Path(f"{request.path.parent}/handlers"), layer_arn=lambda_layer_arn) + stack = EventHandlerStack() try: yield stack.deploy() finally: diff --git a/tests/e2e/event_handler/infrastructure.py b/tests/e2e/event_handler/infrastructure.py index 735261138f3..da456038a25 100644 --- a/tests/e2e/event_handler/infrastructure.py +++ b/tests/e2e/event_handler/infrastructure.py @@ -1,4 +1,3 @@ -from pathlib import Path from typing import Dict, Optional from aws_cdk import CfnOutput @@ -14,11 +13,6 @@ class EventHandlerStack(BaseInfrastructure): - FEATURE_NAME = "event-handlers" - - def __init__(self, handlers_dir: Path, feature_name: str = FEATURE_NAME, layer_arn: str = "") -> None: - super().__init__(feature_name, handlers_dir, layer_arn) - def create_resources(self): functions = self.create_lambda_functions() @@ -28,7 +22,12 @@ def create_resources(self): self._create_lambda_function_url(function=functions["LambdaFunctionUrlHandler"]) def _create_alb(self, function: Function): - vpc = ec2.Vpc(self.stack, "EventHandlerVPC", max_azs=2) + vpc = ec2.Vpc.from_lookup( + self.stack, + "VPC", + is_default=True, + region=self.region, + ) alb = elbv2.ApplicationLoadBalancer(self.stack, "ALB", vpc=vpc, internet_facing=True) CfnOutput(self.stack, "ALBDnsName", value=alb.load_balancer_dns_name) diff --git a/tests/e2e/logger/conftest.py b/tests/e2e/logger/conftest.py index 82a89314258..a31be77031b 100644 --- a/tests/e2e/logger/conftest.py +++ b/tests/e2e/logger/conftest.py @@ -1,27 +1,18 @@ -from pathlib import Path - import pytest from tests.e2e.logger.infrastructure import LoggerStack @pytest.fixture(autouse=True, scope="module") -def infrastructure(request: pytest.FixtureRequest, lambda_layer_arn: str): +def infrastructure(tmp_path_factory, worker_id): """Setup and teardown logic for E2E test infrastructure - Parameters - ---------- - request : pytest.FixtureRequest - pytest request fixture to introspect absolute path to test being executed - lambda_layer_arn : str - Lambda Layer ARN - Yields ------ Dict[str, str] CloudFormation Outputs from deployed infrastructure """ - stack = LoggerStack(handlers_dir=Path(f"{request.path.parent}/handlers"), layer_arn=lambda_layer_arn) + stack = LoggerStack() try: yield stack.deploy() finally: diff --git a/tests/e2e/logger/infrastructure.py b/tests/e2e/logger/infrastructure.py index 68aaa8eb38a..242b3c10892 100644 --- a/tests/e2e/logger/infrastructure.py +++ b/tests/e2e/logger/infrastructure.py @@ -1,13 +1,6 @@ -from pathlib import Path - from tests.e2e.utils.infrastructure import BaseInfrastructure class LoggerStack(BaseInfrastructure): - FEATURE_NAME = "logger" - - def __init__(self, handlers_dir: Path, feature_name: str = FEATURE_NAME, layer_arn: str = "") -> None: - super().__init__(feature_name, handlers_dir, layer_arn) - def create_resources(self): self.create_lambda_functions() diff --git a/tests/e2e/metrics/conftest.py b/tests/e2e/metrics/conftest.py index 663c8845be4..2f72e7950be 100644 --- a/tests/e2e/metrics/conftest.py +++ b/tests/e2e/metrics/conftest.py @@ -1,27 +1,18 @@ -from pathlib import Path - import pytest from tests.e2e.metrics.infrastructure import MetricsStack @pytest.fixture(autouse=True, scope="module") -def infrastructure(request: pytest.FixtureRequest, lambda_layer_arn: str): +def infrastructure(tmp_path_factory, worker_id): """Setup and teardown logic for E2E test infrastructure - Parameters - ---------- - request : pytest.FixtureRequest - pytest request fixture to introspect absolute path to test being executed - lambda_layer_arn : str - Lambda Layer ARN - Yields ------ Dict[str, str] CloudFormation Outputs from deployed infrastructure """ - stack = MetricsStack(handlers_dir=Path(f"{request.path.parent}/handlers"), layer_arn=lambda_layer_arn) + stack = MetricsStack() try: yield stack.deploy() finally: diff --git a/tests/e2e/metrics/infrastructure.py b/tests/e2e/metrics/infrastructure.py index 9afa59bb5cd..7cc1eb8c498 100644 --- a/tests/e2e/metrics/infrastructure.py +++ b/tests/e2e/metrics/infrastructure.py @@ -1,13 +1,6 @@ -from pathlib import Path - from tests.e2e.utils.infrastructure import BaseInfrastructure class MetricsStack(BaseInfrastructure): - FEATURE_NAME = "metrics" - - def __init__(self, handlers_dir: Path, feature_name: str = FEATURE_NAME, layer_arn: str = "") -> None: - super().__init__(feature_name, handlers_dir, layer_arn) - def create_resources(self): self.create_lambda_functions() diff --git a/tests/e2e/tracer/conftest.py b/tests/e2e/tracer/conftest.py index 3b724bf1247..afb34ffee2b 100644 --- a/tests/e2e/tracer/conftest.py +++ b/tests/e2e/tracer/conftest.py @@ -1,27 +1,19 @@ -from pathlib import Path - import pytest from tests.e2e.tracer.infrastructure import TracerStack @pytest.fixture(autouse=True, scope="module") -def infrastructure(request: pytest.FixtureRequest, lambda_layer_arn: str): +def infrastructure(): """Setup and teardown logic for E2E test infrastructure - Parameters - ---------- - request : pytest.FixtureRequest - pytest request fixture to introspect absolute path to test being executed - lambda_layer_arn : str - Lambda Layer ARN Yields ------ Dict[str, str] CloudFormation Outputs from deployed infrastructure """ - stack = TracerStack(handlers_dir=Path(f"{request.path.parent}/handlers"), layer_arn=lambda_layer_arn) + stack = TracerStack() try: yield stack.deploy() finally: diff --git a/tests/e2e/tracer/handlers/async_capture.py b/tests/e2e/tracer/handlers/async_capture.py index b19840a6f69..814e0b92e02 100644 --- a/tests/e2e/tracer/handlers/async_capture.py +++ b/tests/e2e/tracer/handlers/async_capture.py @@ -13,4 +13,5 @@ async def async_get_users(): def lambda_handler(event: dict, context: LambdaContext): + tracer.service = event.get("service") return asyncio.run(async_get_users()) diff --git a/tests/e2e/tracer/handlers/basic_handler.py b/tests/e2e/tracer/handlers/basic_handler.py index ba94c845ace..89a6b062423 100644 --- a/tests/e2e/tracer/handlers/basic_handler.py +++ b/tests/e2e/tracer/handlers/basic_handler.py @@ -13,4 +13,5 @@ def get_todos(): @tracer.capture_lambda_handler def lambda_handler(event: dict, context: LambdaContext): + tracer.service = event.get("service") return get_todos() diff --git a/tests/e2e/tracer/handlers/same_function_name.py b/tests/e2e/tracer/handlers/same_function_name.py index 78ef99d42fa..240e3329bc8 100644 --- a/tests/e2e/tracer/handlers/same_function_name.py +++ b/tests/e2e/tracer/handlers/same_function_name.py @@ -26,6 +26,8 @@ def get_all(self): def lambda_handler(event: dict, context: LambdaContext): + # Maintenance: create a public method to set these explicitly + tracer.service = event["service"] todos = Todos() comments = Comments() diff --git a/tests/e2e/tracer/infrastructure.py b/tests/e2e/tracer/infrastructure.py index 9b388558c0b..8562359acf0 100644 --- a/tests/e2e/tracer/infrastructure.py +++ b/tests/e2e/tracer/infrastructure.py @@ -1,18 +1,6 @@ -from pathlib import Path - -from tests.e2e.utils.data_builder import build_service_name from tests.e2e.utils.infrastructure import BaseInfrastructure class TracerStack(BaseInfrastructure): - # Maintenance: Tracer doesn't support dynamic service injection (tracer.py L310) - # we could move after handler response or adopt env vars usage in e2e tests - SERVICE_NAME: str = build_service_name() - FEATURE_NAME = "tracer" - - def __init__(self, handlers_dir: Path, feature_name: str = FEATURE_NAME, layer_arn: str = "") -> None: - super().__init__(feature_name, handlers_dir, layer_arn) - def create_resources(self) -> None: - env_vars = {"POWERTOOLS_SERVICE_NAME": self.SERVICE_NAME} - self.create_lambda_functions(function_props={"environment": env_vars}) + self.create_lambda_functions() diff --git a/tests/e2e/tracer/test_tracer.py b/tests/e2e/tracer/test_tracer.py index de25bc02ebf..e2abc5af6bc 100644 --- a/tests/e2e/tracer/test_tracer.py +++ b/tests/e2e/tracer/test_tracer.py @@ -1,7 +1,8 @@ +import json + import pytest from tests.e2e.tracer.handlers import async_capture, basic_handler -from tests.e2e.tracer.infrastructure import TracerStack from tests.e2e.utils import data_builder, data_fetcher @@ -37,6 +38,7 @@ def async_fn(infrastructure: dict) -> str: def test_lambda_handler_trace_is_visible(basic_handler_fn_arn: str, basic_handler_fn: str): # GIVEN + service = data_builder.build_service_name() handler_name = basic_handler.lambda_handler.__name__ handler_subsegment = f"## {handler_name}" handler_metadata_key = f"{handler_name} response" @@ -48,21 +50,23 @@ def test_lambda_handler_trace_is_visible(basic_handler_fn_arn: str, basic_handle trace_query = data_builder.build_trace_default_query(function_name=basic_handler_fn) # WHEN - _, execution_time = data_fetcher.get_lambda_response(lambda_arn=basic_handler_fn_arn) - data_fetcher.get_lambda_response(lambda_arn=basic_handler_fn_arn) + event = json.dumps({"service": service}) + _, execution_time = data_fetcher.get_lambda_response(lambda_arn=basic_handler_fn_arn, payload=event) + data_fetcher.get_lambda_response(lambda_arn=basic_handler_fn_arn, payload=event) # THEN trace = data_fetcher.get_traces(start_date=execution_time, filter_expression=trace_query, minimum_traces=2) assert len(trace.get_annotation(key="ColdStart", value=True)) == 1 - assert len(trace.get_metadata(key=handler_metadata_key, namespace=TracerStack.SERVICE_NAME)) == 2 - assert len(trace.get_metadata(key=method_metadata_key, namespace=TracerStack.SERVICE_NAME)) == 2 + assert len(trace.get_metadata(key=handler_metadata_key, namespace=service)) == 2 + assert len(trace.get_metadata(key=method_metadata_key, namespace=service)) == 2 assert len(trace.get_subsegment(name=handler_subsegment)) == 2 assert len(trace.get_subsegment(name=method_subsegment)) == 2 def test_lambda_handler_trace_multiple_functions_same_name(same_function_name_arn: str, same_function_name_fn: str): # GIVEN + service = data_builder.build_service_name() method_name_todos = "same_function_name.Todos.get_all" method_subsegment_todos = f"## {method_name_todos}" method_metadata_key_todos = f"{method_name_todos} response" @@ -74,19 +78,21 @@ def test_lambda_handler_trace_multiple_functions_same_name(same_function_name_ar trace_query = data_builder.build_trace_default_query(function_name=same_function_name_fn) # WHEN - _, execution_time = data_fetcher.get_lambda_response(lambda_arn=same_function_name_arn) + event = json.dumps({"service": service}) + _, execution_time = data_fetcher.get_lambda_response(lambda_arn=same_function_name_arn, payload=event) # THEN trace = data_fetcher.get_traces(start_date=execution_time, filter_expression=trace_query) - assert len(trace.get_metadata(key=method_metadata_key_todos, namespace=TracerStack.SERVICE_NAME)) == 1 - assert len(trace.get_metadata(key=method_metadata_key_comments, namespace=TracerStack.SERVICE_NAME)) == 1 + assert len(trace.get_metadata(key=method_metadata_key_todos, namespace=service)) == 1 + assert len(trace.get_metadata(key=method_metadata_key_comments, namespace=service)) == 1 assert len(trace.get_subsegment(name=method_subsegment_todos)) == 1 assert len(trace.get_subsegment(name=method_subsegment_comments)) == 1 def test_async_trace_is_visible(async_fn_arn: str, async_fn: str): # GIVEN + service = data_builder.build_service_name() async_fn_name = f"async_capture.{async_capture.async_get_users.__name__}" async_fn_name_subsegment = f"## {async_fn_name}" async_fn_name_metadata_key = f"{async_fn_name} response" @@ -94,10 +100,11 @@ def test_async_trace_is_visible(async_fn_arn: str, async_fn: str): trace_query = data_builder.build_trace_default_query(function_name=async_fn) # WHEN - _, execution_time = data_fetcher.get_lambda_response(lambda_arn=async_fn_arn) + event = json.dumps({"service": service}) + _, execution_time = data_fetcher.get_lambda_response(lambda_arn=async_fn_arn, payload=event) # THEN trace = data_fetcher.get_traces(start_date=execution_time, filter_expression=trace_query) assert len(trace.get_subsegment(name=async_fn_name_subsegment)) == 1 - assert len(trace.get_metadata(key=async_fn_name_metadata_key, namespace=TracerStack.SERVICE_NAME)) == 1 + assert len(trace.get_metadata(key=async_fn_name_metadata_key, namespace=service)) == 1 diff --git a/tests/e2e/utils/Dockerfile b/tests/e2e/utils/Dockerfile deleted file mode 100644 index 586847bb3fa..00000000000 --- a/tests/e2e/utils/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -# Image used by CDK's LayerVersion construct to create Lambda Layer with Powertools -# library code. -# The correct AWS SAM build image based on the runtime of the function will be -# passed as build arg. The default allows to do `docker build .` when testing. -ARG IMAGE=public.ecr.aws/sam/build-python3.7 -FROM $IMAGE - -ARG PIP_INDEX_URL -ARG PIP_EXTRA_INDEX_URL -ARG HTTPS_PROXY - -RUN pip install --upgrade pip - -CMD [ "python" ] diff --git a/tests/e2e/utils/asset.py b/tests/e2e/utils/asset.py deleted file mode 100644 index db9e7299d1a..00000000000 --- a/tests/e2e/utils/asset.py +++ /dev/null @@ -1,147 +0,0 @@ -import io -import json -import logging -import zipfile -from pathlib import Path -from typing import Dict, List, Optional - -import boto3 -import botocore.exceptions -from mypy_boto3_s3 import S3Client -from pydantic import BaseModel, Field - -logger = logging.getLogger(__name__) - - -class AssetManifest(BaseModel): - path: str - packaging: str - - -class AssetTemplateConfigDestinationsAccount(BaseModel): - bucket_name: str = Field(str, alias="bucketName") - object_key: str = Field(str, alias="objectKey") - assume_role_arn: str = Field(str, alias="assumeRoleArn") - - -class AssetTemplateConfigDestinations(BaseModel): - current_account_current_region: AssetTemplateConfigDestinationsAccount = Field( - AssetTemplateConfigDestinationsAccount, alias="current_account-current_region" - ) - - -class AssetTemplateConfig(BaseModel): - source: AssetManifest - destinations: AssetTemplateConfigDestinations - - -class TemplateAssembly(BaseModel): - version: str - files: Dict[str, AssetTemplateConfig] - - -class Asset: - def __init__( - self, config: AssetTemplateConfig, account_id: str, region: str, boto3_client: Optional[S3Client] = None - ) -> None: - """CDK Asset logic to verify existence and resolve deeply nested configuration - - Parameters - ---------- - config : AssetTemplateConfig - CDK Asset configuration found in synthesized template - account_id : str - AWS Account ID - region : str - AWS Region - boto3_client : Optional["S3Client"], optional - S3 client instance for asset operations, by default None - """ - self.config = config - self.s3 = boto3_client or boto3.client("s3") - self.account_id = account_id - self.region = region - self.asset_path = config.source.path - self.asset_packaging = config.source.packaging - self.object_key = config.destinations.current_account_current_region.object_key - self._bucket = config.destinations.current_account_current_region.bucket_name - self.bucket_name = self._resolve_bucket_name() - - @property - def is_zip(self): - return self.asset_packaging == "zip" - - def exists_in_s3(self, key: str) -> bool: - try: - return self.s3.head_object(Bucket=self.bucket_name, Key=key) is not None - except botocore.exceptions.ClientError: - return False - - def _resolve_bucket_name(self) -> str: - return self._bucket.replace("${AWS::AccountId}", self.account_id).replace("${AWS::Region}", self.region) - - -class Assets: - def __init__( - self, asset_manifest: Path, account_id: str, region: str, boto3_client: Optional[S3Client] = None - ) -> None: - """CDK Assets logic to find each asset, compress, and upload - - Parameters - ---------- - asset_manifest : Path - Asset manifest JSON file (self.__synthesize) - account_id : str - AWS Account ID - region : str - AWS Region - boto3_client : Optional[S3Client], optional - S3 client instance for asset operations, by default None - """ - self.asset_manifest = asset_manifest - self.account_id = account_id - self.region = region - self.s3 = boto3_client or boto3.client("s3") - self.assets = self._find_assets_from_template() - self.assets_location = str(self.asset_manifest.parent) - - def upload(self): - """Drop-in replacement for cdk-assets package s3 upload part. - https://www.npmjs.com/package/cdk-assets. - We use custom solution to avoid dependencies from nodejs ecosystem. - We follow the same design cdk-assets: - https://github.com/aws/aws-cdk-rfcs/blob/master/text/0092-asset-publishing.md. - """ - logger.debug(f"Upload {len(self.assets)} assets") - for asset in self.assets: - if not asset.is_zip: - logger.debug(f"Asset '{asset.object_key}' is not zip. Skipping upload.") - continue - - if asset.exists_in_s3(key=asset.object_key): - logger.debug(f"Asset '{asset.object_key}' already exists in S3. Skipping upload.") - continue - - archive = self._compress_assets(asset) - logger.debug("Uploading archive to S3") - self.s3.upload_fileobj(Fileobj=archive, Bucket=asset.bucket_name, Key=asset.object_key) - logger.debug("Successfully uploaded") - - def _find_assets_from_template(self) -> List[Asset]: - data = json.loads(self.asset_manifest.read_text()) - template = TemplateAssembly(**data) - return [ - Asset(config=asset_config, account_id=self.account_id, region=self.region) - for asset_config in template.files.values() - ] - - def _compress_assets(self, asset: Asset) -> io.BytesIO: - buf = io.BytesIO() - asset_dir = f"{self.assets_location}/{asset.asset_path}" - asset_files = list(Path(asset_dir).rglob("*")) - with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as archive: - for asset_file in asset_files: - logger.debug(f"Adding file '{asset_file}' to the archive.") - archive.write(asset_file, arcname=asset_file.relative_to(asset_dir)) - buf.seek(0) - return buf diff --git a/tests/e2e/utils/base.py b/tests/e2e/utils/base.py new file mode 100644 index 00000000000..2a6e6032e52 --- /dev/null +++ b/tests/e2e/utils/base.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractmethod +from typing import Dict, Optional + + +class InfrastructureProvider(ABC): + @abstractmethod + def create_lambda_functions(self, function_props: Optional[Dict] = None) -> Dict: + pass + + @abstractmethod + def deploy(self) -> Dict[str, str]: + pass + + @abstractmethod + def delete(self): + pass + + @abstractmethod + def create_resources(self): + pass diff --git a/tests/e2e/utils/constants.py b/tests/e2e/utils/constants.py new file mode 100644 index 00000000000..445c9f00113 --- /dev/null +++ b/tests/e2e/utils/constants.py @@ -0,0 +1,8 @@ +import sys + +from aws_lambda_powertools import PACKAGE_PATH + +PYTHON_RUNTIME_VERSION = f"V{''.join(map(str, sys.version_info[:2]))}" +SOURCE_CODE_ROOT_PATH = PACKAGE_PATH.parent +CDK_OUT_PATH = SOURCE_CODE_ROOT_PATH / "cdk.out" +LAYER_BUILD_PATH = CDK_OUT_PATH / "layer_build" diff --git a/tests/e2e/utils/infrastructure.py b/tests/e2e/utils/infrastructure.py index 97714b95cfc..82d0463b2aa 100644 --- a/tests/e2e/utils/infrastructure.py +++ b/tests/e2e/utils/infrastructure.py @@ -1,73 +1,58 @@ import json import logging +import os +import subprocess import sys -from abc import ABC, abstractmethod -from enum import Enum +import textwrap from pathlib import Path -from typing import Dict, Generator, Optional, Tuple, Type +from typing import Callable, Dict, Generator, Optional from uuid import uuid4 import boto3 import pytest -import yaml -from aws_cdk import ( - App, - AssetStaging, - BundlingOptions, - CfnOutput, - DockerImage, - RemovalPolicy, - Stack, - aws_logs, -) +from aws_cdk import App, CfnOutput, Environment, RemovalPolicy, Stack, aws_logs from aws_cdk.aws_lambda import Code, Function, LayerVersion, Runtime, Tracing from filelock import FileLock from mypy_boto3_cloudformation import CloudFormationClient -from aws_lambda_powertools import PACKAGE_PATH -from tests.e2e.utils.asset import Assets - -PYTHON_RUNTIME_VERSION = f"V{''.join(map(str, sys.version_info[:2]))}" -SOURCE_CODE_ROOT_PATH = PACKAGE_PATH.parent +from tests.e2e.utils.base import InfrastructureProvider +from tests.e2e.utils.constants import CDK_OUT_PATH, PYTHON_RUNTIME_VERSION, SOURCE_CODE_ROOT_PATH +from tests.e2e.utils.lambda_layer.powertools_layer import LocalLambdaPowertoolsLayer logger = logging.getLogger(__name__) -class BaseInfrastructureStack(ABC): - @abstractmethod - def synthesize(self) -> Tuple[dict, str]: - ... - - @abstractmethod - def __call__(self) -> Tuple[dict, str]: - ... - - -class PythonVersion(Enum): - V37 = {"runtime": Runtime.PYTHON_3_7, "image": Runtime.PYTHON_3_7.bundling_image.image} - V38 = {"runtime": Runtime.PYTHON_3_8, "image": Runtime.PYTHON_3_8.bundling_image.image} - V39 = {"runtime": Runtime.PYTHON_3_9, "image": Runtime.PYTHON_3_9.bundling_image.image} +class BaseInfrastructure(InfrastructureProvider): + RANDOM_STACK_VALUE: str = f"{uuid4()}" - -class BaseInfrastructure(ABC): - def __init__(self, feature_name: str, handlers_dir: Path, layer_arn: str = "") -> None: - self.feature_name = feature_name - self.stack_name = f"test{PYTHON_RUNTIME_VERSION}-{feature_name}-{uuid4()}" - self.handlers_dir = handlers_dir - self.layer_arn = layer_arn + def __init__(self) -> None: + self.feature_path = Path(sys.modules[self.__class__.__module__].__file__).parent # absolute path to feature + self.feature_name = self.feature_path.parts[-1].replace("_", "-") # logger, tracer, event-handler, etc. + self.stack_name = f"test{PYTHON_RUNTIME_VERSION}-{self.feature_name}-{self.RANDOM_STACK_VALUE}" self.stack_outputs: Dict[str, str] = {} - # NOTE: Investigate why cdk.Environment in Stack - # changes synthesized asset (no object_key in asset manifest) - self.app = App(outdir=str(SOURCE_CODE_ROOT_PATH / ".cdk")) - self.stack = Stack(self.app, self.stack_name) + # NOTE: CDK stack account and region are tokens, we need to resolve earlier self.session = boto3.Session() self.cfn: CloudFormationClient = self.session.client("cloudformation") - - # NOTE: CDK stack account and region are tokens, we need to resolve earlier self.account_id = self.session.client("sts").get_caller_identity()["Account"] self.region = self.session.region_name + self.app = App() + self.stack = Stack(self.app, self.stack_name, env=Environment(account=self.account_id, region=self.region)) + + # NOTE: Introspect feature details to generate CDK App (_create_temp_cdk_app method), Synth and Deployment + self._feature_infra_class_name = self.__class__.__name__ + self._feature_infra_module_path = self.feature_path / "infrastructure" + self._feature_infra_file = self.feature_path / "infrastructure.py" + self._handlers_dir = self.feature_path / "handlers" + self._cdk_out_dir: Path = CDK_OUT_PATH / self.feature_name + self._stack_outputs_file = f'{self._cdk_out_dir / "stack_outputs.json"}' + + if not self._feature_infra_file.exists(): + raise FileNotFoundError( + "You must have your infrastructure defined in 'tests/e2e//infrastructure.py'." + ) + def create_lambda_functions(self, function_props: Optional[Dict] = None) -> Dict[str, Function]: """Create Lambda functions available under handlers_dir @@ -102,16 +87,28 @@ def create_lambda_functions(self, function_props: Optional[Dict] = None) -> Dict self.create_lambda_functions(function_props={"runtime": Runtime.PYTHON_3_7) ``` """ - handlers = list(self.handlers_dir.rglob("*.py")) - source = Code.from_asset(f"{self.handlers_dir}") + if not self._handlers_dir.exists(): + raise RuntimeError(f"Handlers dir '{self._handlers_dir}' must exist for functions to be created.") + + layer_build = LocalLambdaPowertoolsLayer().build() + layer = LayerVersion( + self.stack, + "aws-lambda-powertools-e2e-test", + layer_version_name="aws-lambda-powertools-e2e-test", + compatible_runtimes=[ + Runtime.PYTHON_3_7, + Runtime.PYTHON_3_8, + Runtime.PYTHON_3_9, + ], + code=Code.from_asset(path=layer_build), + ) + + # NOTE: Agree on a convention if we need to support multi-file handlers + # as we're simply taking any file under `handlers/` to be a Lambda function. + handlers = list(self._handlers_dir.rglob("*.py")) + source = Code.from_asset(f"{self._handlers_dir}") logger.debug(f"Creating functions for handlers: {handlers}") - if not self.layer_arn: - raise ValueError( - """Lambda Layer ARN cannot be empty when creating Lambda functions. - Make sure to inject `lambda_layer_arn` fixture and pass at the constructor level""" - ) - layer = LayerVersion.from_layer_version_arn(self.stack, "layer-arn", layer_version_arn=self.layer_arn) function_settings_override = function_props or {} output: Dict[str, Function] = {} @@ -147,25 +144,86 @@ def create_lambda_functions(self, function_props: Optional[Dict] = None) -> Dict return output def deploy(self) -> Dict[str, str]: - """Creates CloudFormation Stack and return stack outputs as dict + """Synthesize and deploy a CDK app, and return its stack outputs + + NOTE: It auto-generates a temporary CDK app to benefit from CDK CLI lookup features Returns ------- Dict[str, str] CloudFormation Stack Outputs with output key and value """ - template, asset_manifest_file = self._synthesize() - assets = Assets(asset_manifest=asset_manifest_file, account_id=self.account_id, region=self.region) - assets.upload() - self.stack_outputs = self._deploy_stack(self.stack_name, template) - return self.stack_outputs + stack_file = self._create_temp_cdk_app() + synth_command = f"npx cdk synth --app 'python {stack_file}' -o {self._cdk_out_dir}" + deploy_command = ( + f"npx cdk deploy --app '{self._cdk_out_dir}' -O {self._stack_outputs_file} --require-approval=never" + ) + + # CDK launches a background task, so we must wait + subprocess.check_output(synth_command, shell=True) + subprocess.check_output(deploy_command, shell=True) + return self._read_stack_output() def delete(self) -> None: """Delete CloudFormation Stack""" logger.debug(f"Deleting stack: {self.stack_name}") self.cfn.delete_stack(StackName=self.stack_name) - @abstractmethod + def _sync_stack_name(self, stack_output: Dict): + """Synchronize initial stack name with CDK final stack name + + When using `cdk synth` with context methods (`from_lookup`), + CDK can initialize the Stack multiple times until it resolves + the context. + + Parameters + ---------- + stack_output : Dict + CDK CloudFormation Outputs, where the key is the stack name + """ + self.stack_name = list(stack_output.keys())[0] + + def _read_stack_output(self): + content = Path(self._stack_outputs_file).read_text() + outputs: Dict = json.loads(content) + self._sync_stack_name(stack_output=outputs) + + # discard stack_name and get outputs as dict + self.stack_outputs = list(outputs.values())[0] + return self.stack_outputs + + def _create_temp_cdk_app(self): + """Autogenerate a CDK App with our Stack so that CDK CLI can deploy it + + This allows us to keep our BaseInfrastructure while supporting context lookups. + """ + # cdk.out/tracer/cdk_app_v39.py + temp_file = self._cdk_out_dir / f"cdk_app_{PYTHON_RUNTIME_VERSION}.py" + + if temp_file.exists(): + # no need to regenerate CDK app since it's just boilerplate + return temp_file + + # Convert from POSIX path to Python module: tests.e2e.tracer.infrastructure + infra_module = str(self._feature_infra_module_path.relative_to(SOURCE_CODE_ROOT_PATH)).replace(os.sep, ".") + + code = f""" + from {infra_module} import {self._feature_infra_class_name} + stack = {self._feature_infra_class_name}() + stack.create_resources() + stack.app.synth() + """ + + if not self._cdk_out_dir.is_dir(): + self._cdk_out_dir.mkdir(parents=True, exist_ok=True) + + with temp_file.open("w") as fd: + fd.write(textwrap.dedent(code)) + + # allow CDK to read/execute file for stack deployment + temp_file.chmod(0o755) + return temp_file + def create_resources(self) -> None: """Create any necessary CDK resources. It'll be called before deploy @@ -189,34 +247,7 @@ def created_resources(self): self.create_lambda_functions() ``` """ - ... - - def _synthesize(self) -> Tuple[Dict, Path]: - logger.debug("Creating CDK Stack resources") - self.create_resources() - logger.debug("Synthesizing CDK Stack into raw CloudFormation template") - cloud_assembly = self.app.synth() - cf_template: Dict = cloud_assembly.get_stack_by_name(self.stack_name).template - cloud_assembly_assets_manifest_path: str = ( - cloud_assembly.get_stack_by_name(self.stack_name).dependencies[0].file # type: ignore[attr-defined] - ) - return cf_template, Path(cloud_assembly_assets_manifest_path) - - def _deploy_stack(self, stack_name: str, template: Dict) -> Dict[str, str]: - logger.debug(f"Creating CloudFormation Stack: {stack_name}") - self.cfn.create_stack( - StackName=stack_name, - TemplateBody=yaml.dump(template), - TimeoutInMinutes=10, - OnFailure="ROLLBACK", - Capabilities=["CAPABILITY_IAM"], - ) - waiter = self.cfn.get_waiter("stack_create_complete") - waiter.wait(StackName=stack_name, WaiterConfig={"Delay": 10, "MaxAttempts": 50}) - - stack_details = self.cfn.describe_stacks(StackName=stack_name) - stack_outputs = stack_details["Stacks"][0]["Outputs"] - return {output["OutputKey"]: output["OutputValue"] for output in stack_outputs if output["OutputKey"]} + raise NotImplementedError() def add_cfn_output(self, name: str, value: str, arn: str = ""): """Create {Name} and optionally {Name}Arn CloudFormation Outputs. @@ -235,88 +266,50 @@ def add_cfn_output(self, name: str, value: str, arn: str = ""): CfnOutput(self.stack, f"{name}Arn", value=arn) -def deploy_once( - stack: Type[BaseInfrastructure], - request: pytest.FixtureRequest, +def call_once( + task: Callable, tmp_path_factory: pytest.TempPathFactory, worker_id: str, - layer_arn: str, -) -> Generator[Dict[str, str], None, None]: - """Deploys provided stack once whether CPU parallelization is enabled or not + callback: Optional[Callable] = None, +) -> Generator[object, None, None]: + """Call function and serialize results once whether CPU parallelization is enabled or not Parameters ---------- - stack : Type[BaseInfrastructure] - stack class to instantiate and deploy, for example MetricStack. - Not to be confused with class instance (MetricStack()). - request : pytest.FixtureRequest - pytest request fixture to introspect absolute path to test being executed + task : Callable + Function to call once and JSON serialize result whether parallel test is enabled or not. tmp_path_factory : pytest.TempPathFactory pytest temporary path factory to discover shared tmp when multiple CPU processes are spun up worker_id : str pytest-xdist worker identification to detect whether parallelization is enabled + callback : Callable + Function to call when job is complete. Yields ------ - Generator[Dict[str, str], None, None] - stack CloudFormation outputs + Generator[object, None, None] + Callable output when called """ - handlers_dir = f"{request.node.path.parent}/handlers" - stack = stack(handlers_dir=Path(handlers_dir), layer_arn=layer_arn) try: if worker_id == "master": - # no parallelization, deploy stack and let fixture be cached - yield stack.deploy() + # no parallelization, call and return + yield task() else: # tmp dir shared by all workers root_tmp_dir = tmp_path_factory.getbasetemp().parent cache = root_tmp_dir / f"{PYTHON_RUNTIME_VERSION}_cache.json" with FileLock(f"{cache}.lock"): - # If cache exists, return stack outputs back + # If cache exists, return task outputs back # otherwise it's the first run by the main worker - # deploy and return stack outputs so subsequent workers can reuse + # run and return task outputs for subsequent workers reuse if cache.is_file(): - stack_outputs = json.loads(cache.read_text()) + callable_result = json.loads(cache.read_text()) else: - stack_outputs: Dict = stack.deploy() - cache.write_text(json.dumps(stack_outputs)) - yield stack_outputs + callable_result: Dict = task() + cache.write_text(json.dumps(callable_result)) + yield callable_result finally: - stack.delete() - - -class LambdaLayerStack(BaseInfrastructure): - FEATURE_NAME = "lambda-layer" - - def __init__(self, handlers_dir: Path, feature_name: str = FEATURE_NAME, layer_arn: str = "") -> None: - super().__init__(feature_name, handlers_dir, layer_arn) - - def create_resources(self): - layer = self._create_layer() - CfnOutput(self.stack, "LayerArn", value=layer) - - def _create_layer(self) -> str: - logger.debug("Creating Lambda Layer with latest source code available") - output_dir = Path(str(AssetStaging.BUNDLING_OUTPUT_DIR), "python") - input_dir = Path(str(AssetStaging.BUNDLING_INPUT_DIR), "aws_lambda_powertools") - - build_commands = [f"pip install .[pydantic] -t {output_dir}", f"cp -R {input_dir} {output_dir}"] - layer = LayerVersion( - self.stack, - "aws-lambda-powertools-e2e-test", - layer_version_name="aws-lambda-powertools-e2e-test", - compatible_runtimes=[PythonVersion[PYTHON_RUNTIME_VERSION].value["runtime"]], - code=Code.from_asset( - path=str(SOURCE_CODE_ROOT_PATH), - bundling=BundlingOptions( - image=DockerImage.from_build( - str(Path(__file__).parent), - build_args={"IMAGE": PythonVersion[PYTHON_RUNTIME_VERSION].value["image"]}, - ), - command=["bash", "-c", " && ".join(build_commands)], - ), - ), - ) - return layer.layer_version_arn + if callback is not None: + callback() diff --git a/tests/e2e/utils/lambda_layer/__init__.py b/tests/e2e/utils/lambda_layer/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/utils/lambda_layer/base.py b/tests/e2e/utils/lambda_layer/base.py new file mode 100644 index 00000000000..280fe19d4f8 --- /dev/null +++ b/tests/e2e/utils/lambda_layer/base.py @@ -0,0 +1,32 @@ +from abc import ABC, abstractmethod +from pathlib import Path + + +class BaseLocalLambdaLayer(ABC): + def __init__(self, output_dir: Path): + self.output_dir = output_dir / "layer_build" + self.target_dir = f"{self.output_dir}/python" + + @abstractmethod + def build(self) -> str: + """Builds a Lambda Layer locally + + Returns + ------- + build_path : str + Path where newly built Lambda Layer is + """ + raise NotImplementedError() + + def before_build(self): + """Any step to run before build process begins. + + By default, it creates output dir and its parents if it doesn't exist. + """ + if not self.output_dir.exists(): + # Create missing parent directories if missing + self.output_dir.mkdir(parents=True, exist_ok=True) + + def after_build(self): + """Any step after a build succeed""" + ... diff --git a/tests/e2e/utils/lambda_layer/powertools_layer.py b/tests/e2e/utils/lambda_layer/powertools_layer.py new file mode 100644 index 00000000000..45a22547715 --- /dev/null +++ b/tests/e2e/utils/lambda_layer/powertools_layer.py @@ -0,0 +1,48 @@ +import logging +import subprocess +from pathlib import Path + +from checksumdir import dirhash + +from aws_lambda_powertools import PACKAGE_PATH +from tests.e2e.utils.constants import CDK_OUT_PATH, SOURCE_CODE_ROOT_PATH +from tests.e2e.utils.lambda_layer.base import BaseLocalLambdaLayer + +logger = logging.getLogger(__name__) + + +class LocalLambdaPowertoolsLayer(BaseLocalLambdaLayer): + IGNORE_EXTENSIONS = ["pyc"] + + def __init__(self, output_dir: Path = CDK_OUT_PATH): + super().__init__(output_dir) + self.package = f"{SOURCE_CODE_ROOT_PATH}[pydantic]" + self.build_args = "--platform manylinux1_x86_64 --only-binary=:all: --upgrade" + self.build_command = f"python -m pip install {self.package} {self.build_args} --target {self.target_dir}" + self.source_diff_file: Path = CDK_OUT_PATH / "layer_build.diff" + + def build(self) -> str: + self.before_build() + + if self._has_source_changed(): + subprocess.run(self.build_command, shell=True) + + self.after_build() + + return str(self.output_dir) + + def _has_source_changed(self) -> bool: + """Hashes source code and + + Returns + ------- + change : bool + Whether source code hash has changed + """ + diff = self.source_diff_file.read_text() if self.source_diff_file.exists() else "" + new_diff = dirhash(dirname=PACKAGE_PATH, excluded_extensions=self.IGNORE_EXTENSIONS) + if new_diff != diff or not self.output_dir.exists(): + self.source_diff_file.write_text(new_diff) + return True + + return False From 74d21f107ebd9e6b5a06f5db325a8571a3c1ad85 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 20 Sep 2022 17:17:21 +0200 Subject: [PATCH 08/30] fix(ci): workflow should use npx for CDK CLI --- .github/workflows/run-e2e-tests.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/run-e2e-tests.yml b/.github/workflows/run-e2e-tests.yml index ef9305373ac..e60aaf391ec 100644 --- a/.github/workflows/run-e2e-tests.yml +++ b/.github/workflows/run-e2e-tests.yml @@ -27,9 +27,7 @@ jobs: contents: read strategy: matrix: - # Maintenance: disabled until we discover concurrency lock issue with multiple versions and tmp version: ["3.7", "3.8", "3.9"] - # version: ["3.7"] steps: - name: "Checkout" uses: actions/checkout@v3 @@ -48,7 +46,7 @@ jobs: - name: Install CDK CLI run: | npm install - cdk --version + npx cdk --version - name: Install dependencies run: make dev - name: Configure AWS credentials From 839aecb7b9cd2eae8a77b64c52a54912a0157331 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Tue, 27 Sep 2022 14:39:29 +0100 Subject: [PATCH 09/30] feat(idempotency): support methods with the same name (ABCs) by including fully qualified name in v2 (#1535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rúben Fonseca --- .../utilities/idempotency/base.py | 2 +- docs/upgrade.md | 12 +++ docs/utilities/idempotency.md | 2 +- tests/e2e/idempotency/__init__.py | 0 tests/e2e/idempotency/conftest.py | 19 ++++ .../handlers/parallel_execution_handler.py | 13 +++ .../handlers/ttl_cache_expiration_handler.py | 14 +++ .../handlers/ttl_cache_timeout_handler.py | 15 +++ tests/e2e/idempotency/infrastructure.py | 29 ++++++ .../idempotency/test_idempotency_dynamodb.py | 96 +++++++++++++++++++ tests/e2e/utils/data_fetcher/__init__.py | 1 + tests/e2e/utils/data_fetcher/idempotency.py | 39 ++++++++ tests/e2e/utils/functions.py | 14 +++ tests/functional/idempotency/conftest.py | 14 ++- .../idempotency/test_idempotency.py | 28 ++++-- tests/functional/idempotency/utils.py | 12 ++- 16 files changed, 292 insertions(+), 18 deletions(-) create mode 100644 tests/e2e/idempotency/__init__.py create mode 100644 tests/e2e/idempotency/conftest.py create mode 100644 tests/e2e/idempotency/handlers/parallel_execution_handler.py create mode 100644 tests/e2e/idempotency/handlers/ttl_cache_expiration_handler.py create mode 100644 tests/e2e/idempotency/handlers/ttl_cache_timeout_handler.py create mode 100644 tests/e2e/idempotency/infrastructure.py create mode 100644 tests/e2e/idempotency/test_idempotency_dynamodb.py create mode 100644 tests/e2e/utils/data_fetcher/idempotency.py create mode 100644 tests/e2e/utils/functions.py diff --git a/aws_lambda_powertools/utilities/idempotency/base.py b/aws_lambda_powertools/utilities/idempotency/base.py index ddd054daa14..9281c77109a 100644 --- a/aws_lambda_powertools/utilities/idempotency/base.py +++ b/aws_lambda_powertools/utilities/idempotency/base.py @@ -76,7 +76,7 @@ def __init__( self.fn_kwargs = function_kwargs self.config = config - persistence_store.configure(config, self.function.__name__) + persistence_store.configure(config, f"{self.function.__module__}.{self.function.__qualname__}") self.persistence_store = persistence_store def handle(self) -> Any: diff --git a/docs/upgrade.md b/docs/upgrade.md index 3d1257f1c12..37e9a318522 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -12,6 +12,7 @@ Changes at a glance: * The API for **event handler's `Response`** has minor changes to support multi value headers and cookies. * The **legacy SQS batch processor** was removed. +* The **Idempotency key** format changed slightly, invalidating all the existing cached results. ???+ important Powertools for Python v2 drops suport for Python 3.6, following the Python 3.6 End-Of-Life (EOL) reached on December 23, 2021. @@ -142,3 +143,14 @@ You can migrate to the [native batch processing](https://aws.amazon.com/about-aw return processor.response() ``` + +## Idempotency key format + +The format of the Idempotency key was changed. This is used store the invocation results on a persistent store like DynamoDB. + +No changes are necessary in your code, but remember that existing Idempotency records will be ignored when you upgrade, as new executions generate keys with the new format. + +Prior to this change, the Idempotency key was generated using only the caller function name (e.g: `lambda_handler#282e83393862a613b612c00283fef4c8`). +After this change, the key is generated using the `module name` + `qualified function name` + `idempotency key` (e.g: `app.classExample.function#app.handler#282e83393862a613b612c00283fef4c8`). + +Using qualified names prevents distinct functions with the same name to contend for the same Idempotency key. diff --git a/docs/utilities/idempotency.md b/docs/utilities/idempotency.md index 7ba61fd3062..f02cd8700b8 100644 --- a/docs/utilities/idempotency.md +++ b/docs/utilities/idempotency.md @@ -42,7 +42,7 @@ If you're not [changing the default configuration for the DynamoDB persistence l | TTL attribute name | `expiration` | This can only be configured after your table is created if you're using AWS Console | ???+ tip "Tip: You can share a single state table for all functions" - You can reuse the same DynamoDB table to store idempotency state. We add your `function_name` in addition to the idempotency key as a hash key. + You can reuse the same DynamoDB table to store idempotency state. We add `module_name` and [qualified name for classes and functions](https://peps.python.org/pep-3155/) in addition to the idempotency key as a hash key. ```yaml hl_lines="5-13 21-23" title="AWS Serverless Application Model (SAM) example" Resources: diff --git a/tests/e2e/idempotency/__init__.py b/tests/e2e/idempotency/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/idempotency/conftest.py b/tests/e2e/idempotency/conftest.py new file mode 100644 index 00000000000..24a7c71c1f2 --- /dev/null +++ b/tests/e2e/idempotency/conftest.py @@ -0,0 +1,19 @@ +import pytest + +from tests.e2e.idempotency.infrastructure import IdempotencyDynamoDBStack + + +@pytest.fixture(autouse=True, scope="module") +def infrastructure(tmp_path_factory, worker_id): + """Setup and teardown logic for E2E test infrastructure + + Yields + ------ + Dict[str, str] + CloudFormation Outputs from deployed infrastructure + """ + stack = IdempotencyDynamoDBStack() + try: + yield stack.deploy() + finally: + stack.delete() diff --git a/tests/e2e/idempotency/handlers/parallel_execution_handler.py b/tests/e2e/idempotency/handlers/parallel_execution_handler.py new file mode 100644 index 00000000000..401097d4194 --- /dev/null +++ b/tests/e2e/idempotency/handlers/parallel_execution_handler.py @@ -0,0 +1,13 @@ +import time + +from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent + +persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") + + +@idempotent(persistence_store=persistence_layer) +def lambda_handler(event, context): + + time.sleep(10) + + return event diff --git a/tests/e2e/idempotency/handlers/ttl_cache_expiration_handler.py b/tests/e2e/idempotency/handlers/ttl_cache_expiration_handler.py new file mode 100644 index 00000000000..eabf11e7852 --- /dev/null +++ b/tests/e2e/idempotency/handlers/ttl_cache_expiration_handler.py @@ -0,0 +1,14 @@ +import time + +from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, IdempotencyConfig, idempotent + +persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") +config = IdempotencyConfig(expires_after_seconds=20) + + +@idempotent(config=config, persistence_store=persistence_layer) +def lambda_handler(event, context): + + time_now = time.time() + + return {"time": str(time_now)} diff --git a/tests/e2e/idempotency/handlers/ttl_cache_timeout_handler.py b/tests/e2e/idempotency/handlers/ttl_cache_timeout_handler.py new file mode 100644 index 00000000000..4de97a4afe4 --- /dev/null +++ b/tests/e2e/idempotency/handlers/ttl_cache_timeout_handler.py @@ -0,0 +1,15 @@ +import time + +from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, IdempotencyConfig, idempotent + +persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") +config = IdempotencyConfig(expires_after_seconds=1) + + +@idempotent(config=config, persistence_store=persistence_layer) +def lambda_handler(event, context): + + sleep_time: int = event.get("sleep") or 0 + time.sleep(sleep_time) + + return event diff --git a/tests/e2e/idempotency/infrastructure.py b/tests/e2e/idempotency/infrastructure.py new file mode 100644 index 00000000000..997cadc4943 --- /dev/null +++ b/tests/e2e/idempotency/infrastructure.py @@ -0,0 +1,29 @@ +from typing import Any + +from aws_cdk import CfnOutput, RemovalPolicy +from aws_cdk import aws_dynamodb as dynamodb + +from tests.e2e.utils.infrastructure import BaseInfrastructure + + +class IdempotencyDynamoDBStack(BaseInfrastructure): + def create_resources(self): + functions = self.create_lambda_functions() + self._create_dynamodb_table(function=functions) + + def _create_dynamodb_table(self, function: Any): + table = dynamodb.Table( + self.stack, + "Idempotency", + table_name="IdempotencyTable", + removal_policy=RemovalPolicy.DESTROY, + partition_key=dynamodb.Attribute(name="id", type=dynamodb.AttributeType.STRING), + time_to_live_attribute="expiration", + billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST, + ) + + table.grant_read_write_data(function["TtlCacheExpirationHandler"]) + table.grant_read_write_data(function["TtlCacheTimeoutHandler"]) + table.grant_read_write_data(function["ParallelExecutionHandler"]) + + CfnOutput(self.stack, "DynamoDBTable", value=table.table_name) diff --git a/tests/e2e/idempotency/test_idempotency_dynamodb.py b/tests/e2e/idempotency/test_idempotency_dynamodb.py new file mode 100644 index 00000000000..19369b141db --- /dev/null +++ b/tests/e2e/idempotency/test_idempotency_dynamodb.py @@ -0,0 +1,96 @@ +import json +from time import sleep + +import pytest + +from tests.e2e.utils import data_fetcher +from tests.e2e.utils.functions import execute_lambdas_in_parallel + + +@pytest.fixture +def ttl_cache_expiration_handler_fn_arn(infrastructure: dict) -> str: + return infrastructure.get("TtlCacheExpirationHandlerArn", "") + + +@pytest.fixture +def ttl_cache_timeout_handler_fn_arn(infrastructure: dict) -> str: + return infrastructure.get("TtlCacheTimeoutHandlerArn", "") + + +@pytest.fixture +def parallel_execution_handler_fn_arn(infrastructure: dict) -> str: + return infrastructure.get("ParallelExecutionHandlerArn", "") + + +@pytest.fixture +def idempotency_table_name(infrastructure: dict) -> str: + return infrastructure.get("DynamoDBTable", "") + + +def test_ttl_caching_expiration_idempotency(ttl_cache_expiration_handler_fn_arn: str): + # GIVEN + payload = json.dumps({"message": "Lambda Powertools - TTL 20s"}) + + # WHEN + # first execution + first_execution, _ = data_fetcher.get_lambda_response( + lambda_arn=ttl_cache_expiration_handler_fn_arn, payload=payload + ) + first_execution_response = first_execution["Payload"].read().decode("utf-8") + + # the second execution should return the same response as the first execution + second_execution, _ = data_fetcher.get_lambda_response( + lambda_arn=ttl_cache_expiration_handler_fn_arn, payload=payload + ) + second_execution_response = second_execution["Payload"].read().decode("utf-8") + + # wait 20s to expire ttl and execute again, this should return a new response value + sleep(20) + third_execution, _ = data_fetcher.get_lambda_response( + lambda_arn=ttl_cache_expiration_handler_fn_arn, payload=payload + ) + third_execution_response = third_execution["Payload"].read().decode("utf-8") + + # THEN + assert first_execution_response == second_execution_response + assert third_execution_response != second_execution_response + + +def test_ttl_caching_timeout_idempotency(ttl_cache_timeout_handler_fn_arn: str): + # GIVEN + payload_timeout_execution = json.dumps({"sleep": 10, "message": "Lambda Powertools - TTL 1s"}) + payload_working_execution = json.dumps({"sleep": 0, "message": "Lambda Powertools - TTL 1s"}) + + # WHEN + # first call should fail due to timeout + execution_with_timeout, _ = data_fetcher.get_lambda_response( + lambda_arn=ttl_cache_timeout_handler_fn_arn, payload=payload_timeout_execution + ) + execution_with_timeout_response = execution_with_timeout["Payload"].read().decode("utf-8") + + # the second call should work and return the payload + execution_working, _ = data_fetcher.get_lambda_response( + lambda_arn=ttl_cache_timeout_handler_fn_arn, payload=payload_working_execution + ) + execution_working_response = execution_working["Payload"].read().decode("utf-8") + + # THEN + assert "Task timed out after" in execution_with_timeout_response + assert payload_working_execution == execution_working_response + + +def test_parallel_execution_idempotency(parallel_execution_handler_fn_arn: str): + # GIVEN + arguments = json.dumps({"message": "Lambda Powertools - Parallel execution"}) + + # WHEN + # executing Lambdas in parallel + lambdas_arn = [parallel_execution_handler_fn_arn, parallel_execution_handler_fn_arn] + execution_result_list = execute_lambdas_in_parallel("data_fetcher.get_lambda_response", lambdas_arn, arguments) + + timeout_execution_response = execution_result_list[0][0]["Payload"].read().decode("utf-8") + error_idempotency_execution_response = execution_result_list[1][0]["Payload"].read().decode("utf-8") + + # THEN + assert "Execution already in progress with idempotency key" in error_idempotency_execution_response + assert "Task timed out after" in timeout_execution_response diff --git a/tests/e2e/utils/data_fetcher/__init__.py b/tests/e2e/utils/data_fetcher/__init__.py index be6909537e5..fdd1de5c515 100644 --- a/tests/e2e/utils/data_fetcher/__init__.py +++ b/tests/e2e/utils/data_fetcher/__init__.py @@ -1,4 +1,5 @@ from tests.e2e.utils.data_fetcher.common import get_http_response, get_lambda_response +from tests.e2e.utils.data_fetcher.idempotency import get_ddb_idempotency_record from tests.e2e.utils.data_fetcher.logs import get_logs from tests.e2e.utils.data_fetcher.metrics import get_metrics from tests.e2e.utils.data_fetcher.traces import get_traces diff --git a/tests/e2e/utils/data_fetcher/idempotency.py b/tests/e2e/utils/data_fetcher/idempotency.py new file mode 100644 index 00000000000..109e6735d3b --- /dev/null +++ b/tests/e2e/utils/data_fetcher/idempotency.py @@ -0,0 +1,39 @@ +import boto3 +from retry import retry + + +@retry(ValueError, delay=2, jitter=1.5, tries=10) +def get_ddb_idempotency_record( + function_name: str, + table_name: str, +) -> int: + """_summary_ + + Parameters + ---------- + function_name : str + Name of Lambda function to fetch dynamodb record + table_name : str + Name of DynamoDB table + + Returns + ------- + int + Count of records found + + Raises + ------ + ValueError + When no record is found within retry window + """ + ddb_client = boto3.resource("dynamodb") + table = ddb_client.Table(table_name) + ret = table.scan( + FilterExpression="contains (id, :functionName)", + ExpressionAttributeValues={":functionName": f"{function_name}#"}, + ) + + if not ret["Items"]: + raise ValueError("Empty response from DynamoDB Repeating...") + + return ret["Count"] diff --git a/tests/e2e/utils/functions.py b/tests/e2e/utils/functions.py new file mode 100644 index 00000000000..7b64c439298 --- /dev/null +++ b/tests/e2e/utils/functions.py @@ -0,0 +1,14 @@ +from concurrent.futures import ThreadPoolExecutor + +from tests.e2e.utils import data_fetcher # noqa F401 + + +def execute_lambdas_in_parallel(function_name: str, lambdas_arn: list, arguments: str): + result_list = [] + with ThreadPoolExecutor() as executor: + running_tasks = executor.map(lambda exec: eval(function_name)(*exec), [(arn, arguments) for arn in lambdas_arn]) + executor.shutdown(wait=True) + for running_task in running_tasks: + result_list.append(running_task) + + return result_list diff --git a/tests/functional/idempotency/conftest.py b/tests/functional/idempotency/conftest.py index b5cf79727b1..657a4b6bd13 100644 --- a/tests/functional/idempotency/conftest.py +++ b/tests/functional/idempotency/conftest.py @@ -172,18 +172,24 @@ def expected_params_put_item_with_validation(hashed_idempotency_key, hashed_vali @pytest.fixture -def hashed_idempotency_key(lambda_apigw_event, default_jmespath, lambda_context): +def hashed_idempotency_key(request, lambda_apigw_event, default_jmespath, lambda_context): compiled_jmespath = jmespath.compile(default_jmespath) data = compiled_jmespath.search(lambda_apigw_event) - return "test-func.lambda_handler#" + hash_idempotency_key(data) + return ( + f"test-func.{request.function.__module__}.{request.function.__qualname__}..lambda_handler#" + + hash_idempotency_key(data) + ) @pytest.fixture -def hashed_idempotency_key_with_envelope(lambda_apigw_event): +def hashed_idempotency_key_with_envelope(request, lambda_apigw_event): event = extract_data_from_envelope( data=lambda_apigw_event, envelope=envelopes.API_GATEWAY_HTTP, jmespath_options={} ) - return "test-func.lambda_handler#" + hash_idempotency_key(event) + return ( + f"test-func.{request.function.__module__}.{request.function.__qualname__}..lambda_handler#" + + hash_idempotency_key(event) + ) @pytest.fixture diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index f63d7347b1c..e5c5c777971 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -48,6 +48,7 @@ from tests.functional.utils import json_serialize, load_event TABLE_NAME = "TEST_TABLE" +TESTS_MODULE_PREFIX = "test-func.functional.idempotency.test_idempotency" def get_dataclasses_lib(): @@ -786,7 +787,7 @@ def lambda_handler(event, context): def test_idempotent_lambda_expires_in_progress_unavailable_remaining_time(): mock_event = {"data": "value"} - idempotency_key = "test-func.function#" + hash_idempotency_key(mock_event) + idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_lambda_expires_in_progress_unavailable_remaining_time..function#{hash_idempotency_key(mock_event)}" # noqa E501 persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key) expected_result = {"message": "Foo"} @@ -1125,7 +1126,8 @@ def _delete_record(self, data_record: DataRecord) -> None: def test_idempotent_lambda_event_source(lambda_context): # Scenario to validate that we can use the event_source decorator before or after the idempotent decorator mock_event = load_event("apiGatewayProxyV2Event.json") - persistence_layer = MockPersistenceLayer("test-func.lambda_handler#" + hash_idempotency_key(mock_event)) + idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_lambda_event_source..lambda_handler#{hash_idempotency_key(mock_event)}" # noqa E501 + persistence_layer = MockPersistenceLayer(idempotency_key) expected_result = {"message": "Foo"} # GIVEN an event_source decorator @@ -1145,7 +1147,9 @@ def lambda_handler(event, _): def test_idempotent_function(): # Scenario to validate we can use idempotent_function with any function mock_event = {"data": "value"} - idempotency_key = "test-func.record_handler#" + hash_idempotency_key(mock_event) + idempotency_key = ( + f"{TESTS_MODULE_PREFIX}.test_idempotent_function..record_handler#{hash_idempotency_key(mock_event)}" + ) persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key) expected_result = {"message": "Foo"} @@ -1163,7 +1167,7 @@ def test_idempotent_function_arbitrary_args_kwargs(): # Scenario to validate we can use idempotent_function with a function # with an arbitrary number of args and kwargs mock_event = {"data": "value"} - idempotency_key = "test-func.record_handler#" + hash_idempotency_key(mock_event) + idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_function_arbitrary_args_kwargs..record_handler#{hash_idempotency_key(mock_event)}" # noqa E501 persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key) expected_result = {"message": "Foo"} @@ -1179,7 +1183,7 @@ def record_handler(arg_one, arg_two, record, is_record): def test_idempotent_function_invalid_data_kwarg(): mock_event = {"data": "value"} - idempotency_key = "test-func.record_handler#" + hash_idempotency_key(mock_event) + idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_function_invalid_data_kwarg..record_handler#{hash_idempotency_key(mock_event)}" # noqa E501 persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key) expected_result = {"message": "Foo"} keyword_argument = "payload" @@ -1216,7 +1220,7 @@ def record_handler(record): def test_idempotent_function_and_lambda_handler(lambda_context): # Scenario to validate we can use both idempotent_function and idempotent decorators mock_event = {"data": "value"} - idempotency_key = "test-func.record_handler#" + hash_idempotency_key(mock_event) + idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_function_and_lambda_handler..record_handler#{hash_idempotency_key(mock_event)}" # noqa E501 persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key) expected_result = {"message": "Foo"} @@ -1224,7 +1228,9 @@ def test_idempotent_function_and_lambda_handler(lambda_context): def record_handler(record): return expected_result - persistence_layer = MockPersistenceLayer("test-func.lambda_handler#" + hash_idempotency_key(mock_event)) + persistence_layer = MockPersistenceLayer( + f"{TESTS_MODULE_PREFIX}.test_idempotent_function_and_lambda_handler..lambda_handler#{hash_idempotency_key(mock_event)}" # noqa E501 + ) @idempotent(persistence_store=persistence_layer) def lambda_handler(event, _): @@ -1245,7 +1251,9 @@ def test_idempotent_data_sorting(): # Scenario to validate same data in different order hashes to the same idempotency key data_one = {"data": "test message 1", "more_data": "more data 1"} data_two = {"more_data": "more data 1", "data": "test message 1"} - idempotency_key = "test-func.dummy#" + hash_idempotency_key(data_one) + idempotency_key = ( + f"{TESTS_MODULE_PREFIX}.test_idempotent_data_sorting..dummy#{hash_idempotency_key(data_one)}" + ) # Assertion will happen in MockPersistenceLayer persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key) @@ -1353,7 +1361,7 @@ def test_idempotent_function_dataclass_with_jmespath(): dataclasses = get_dataclasses_lib() config = IdempotencyConfig(event_key_jmespath="transaction_id", use_local_cache=True) mock_event = {"customer_id": "fake", "transaction_id": "fake-id"} - idempotency_key = "test-func.collect_payment#" + hash_idempotency_key(mock_event["transaction_id"]) + idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_function_dataclass_with_jmespath..collect_payment#{hash_idempotency_key(mock_event['transaction_id'])}" # noqa E501 persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key) @dataclasses.dataclass @@ -1378,7 +1386,7 @@ def test_idempotent_function_pydantic_with_jmespath(): # GIVEN config = IdempotencyConfig(event_key_jmespath="transaction_id", use_local_cache=True) mock_event = {"customer_id": "fake", "transaction_id": "fake-id"} - idempotency_key = "test-func.collect_payment#" + hash_idempotency_key(mock_event["transaction_id"]) + idempotency_key = f"{TESTS_MODULE_PREFIX}.test_idempotent_function_pydantic_with_jmespath..collect_payment#{hash_idempotency_key(mock_event['transaction_id'])}" # noqa E501 persistence_layer = MockPersistenceLayer(expected_idempotency_key=idempotency_key) class Payment(BaseModel): diff --git a/tests/functional/idempotency/utils.py b/tests/functional/idempotency/utils.py index 797b696aba4..f9cdaf05d0a 100644 --- a/tests/functional/idempotency/utils.py +++ b/tests/functional/idempotency/utils.py @@ -14,9 +14,13 @@ def hash_idempotency_key(data: Any): def build_idempotency_put_item_stub( data: Dict, function_name: str = "test-func", + function_qualified_name: str = "test_idempotent_lambda_first_execution_event_mutation.", + module_name: str = "functional.idempotency.test_idempotency", handler_name: str = "lambda_handler", ) -> Dict: - idempotency_key_hash = f"{function_name}.{handler_name}#{hash_idempotency_key(data)}" + idempotency_key_hash = ( + f"{function_name}.{module_name}.{function_qualified_name}.{handler_name}#{hash_idempotency_key(data)}" + ) return { "ConditionExpression": ( "attribute_not_exists(#id) OR #expiry < :now OR " @@ -43,9 +47,13 @@ def build_idempotency_update_item_stub( data: Dict, handler_response: Dict, function_name: str = "test-func", + function_qualified_name: str = "test_idempotent_lambda_first_execution_event_mutation.", + module_name: str = "functional.idempotency.test_idempotency", handler_name: str = "lambda_handler", ) -> Dict: - idempotency_key_hash = f"{function_name}.{handler_name}#{hash_idempotency_key(data)}" + idempotency_key_hash = ( + f"{function_name}.{module_name}.{function_qualified_name}.{handler_name}#{hash_idempotency_key(data)}" + ) serialized_lambda_response = json_serialize(handler_response) return { "ExpressionAttributeNames": { From 7d7e68fec723ff6e3a29d4d3dcdb6099fdd732ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BAben=20Fonseca?= Date: Tue, 4 Oct 2022 11:26:32 +0200 Subject: [PATCH 10/30] chore: bump pyproject version to 2.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e244a656be3..a8100f15f46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "aws_lambda_powertools" -version = "1.31.0" +version = "2.0.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"] From b0bc888812270df3a0095b72fefd5b5a2becf13e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BAben=20Fonseca?= Date: Tue, 4 Oct 2022 11:37:46 +0200 Subject: [PATCH 11/30] chore(deps): lock importlib to 4.x 5.x causes problem with our flake8 version --- poetry.lock | 8 ++++---- pyproject.toml | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 93770dd60cd..ab4aa340839 100644 --- a/poetry.lock +++ b/poetry.lock @@ -499,7 +499,7 @@ python-versions = ">=3.5" [[package]] name = "importlib-metadata" -version = "5.0.0" +version = "4.13.0" description = "Read metadata from Python packages" category = "dev" optional = false @@ -1377,7 +1377,7 @@ pydantic = ["pydantic", "email-validator"] [metadata] lock-version = "1.1" python-versions = "^3.7.4" -content-hash = "0ef937932afc677f409d634770d46aefbc62c1befe060ce1b9fb0e4f263e3ec8" +content-hash = "78c3d0650111a4404b357906c052a1892979125a48b00b3167ad331fa1d4c795" [metadata.files] attrs = [ @@ -1606,8 +1606,8 @@ idna = [ {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, ] importlib-metadata = [ - {file = "importlib_metadata-5.0.0-py3-none-any.whl", hash = "sha256:ddb0e35065e8938f867ed4928d0ae5bf2a53b7773871bfe6bcc7e4fcdc7dea43"}, - {file = "importlib_metadata-5.0.0.tar.gz", hash = "sha256:da31db32b304314d044d3c12c79bd59e307889b287ad12ff387b3500835fc2ab"}, + {file = "importlib_metadata-4.13.0-py3-none-any.whl", hash = "sha256:8a8a81bcf996e74fee46f0d16bd3eaa382a7eb20fd82445c3ad11f4090334116"}, + {file = "importlib_metadata-4.13.0.tar.gz", hash = "sha256:dd0173e8f150d6815e098fd354f6414b0f079af4644ddfe90c71e2fc6174346d"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, diff --git a/pyproject.toml b/pyproject.toml index a8100f15f46..b7fba743f01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,7 @@ typing-extensions = "^4.4.0" mkdocs-material = "^8.5.4" filelock = "^3.8.0" checksumdir = "^1.2.0" +importlib-metadata = "^4.13" [tool.poetry.extras] pydantic = ["pydantic", "email-validator"] From bbc1c140c4dac8db1d33a1e1e92da92a910f24f3 Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Fri, 7 Oct 2022 14:47:34 +0200 Subject: [PATCH 12/30] feat(layers): add support for publishing v2 layer (#1558) Co-authored-by: Heitor Lessa --- .github/workflows/publish_v2_layer.yml | 79 ++++++++- .../reusable_deploy_v2_layer_stack.yml | 102 +++++++++++ Makefile | 4 +- layer/app.py | 5 +- layer/layer/canary/app.py | 46 ++++- layer/layer/canary_stack.py | 87 +++++++-- layer/layer/layer_stack.py | 53 +++++- layer/poetry.lock | 165 +++++++----------- layer/pyproject.toml | 6 +- package-lock.json | 18 +- package.json | 2 +- poetry.lock | 14 +- pyproject.toml | 21 ++- tests/e2e/utils/infrastructure.py | 16 +- .../utils/lambda_layer/powertools_layer.py | 35 +++- 15 files changed, 489 insertions(+), 164 deletions(-) create mode 100644 .github/workflows/reusable_deploy_v2_layer_stack.yml diff --git a/.github/workflows/publish_v2_layer.yml b/.github/workflows/publish_v2_layer.yml index 850063098cd..469a94ad876 100644 --- a/.github/workflows/publish_v2_layer.yml +++ b/.github/workflows/publish_v2_layer.yml @@ -9,11 +9,84 @@ on: inputs: latest_published_version: description: "Latest PyPi published version to rebuild latest docs for, e.g. v1.22.0" + default: "v2.0.0" required: true + # workflow_run: + # workflows: ["Publish to PyPi"] + # types: + # - completed jobs: - dummy: + build-layer: runs-on: ubuntu-latest + if: ${{ (github.event.workflow_run.conclusion == 'success') || (github.event_name == 'workflow_dispatch') }} + defaults: + run: + working-directory: ./layer steps: - - name: Hello world - run: echo "hello world" + - name: checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Install poetry + run: pipx install poetry + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: "16.12" + - name: Setup python + uses: actions/setup-python@v4 + with: + python-version: "3.9" + cache: "pip" + - name: Resolve and install project dependencies + # CDK spawns system python when compiling stack + # therefore it ignores both activated virtual env and cached interpreter by GH + run: | + poetry export --format requirements.txt --output requirements.txt + pip install -r requirements.txt + - name: Set release notes tag + run: | + RELEASE_INPUT=${{ inputs.latest_published_version }} + LATEST_TAG=$(git describe --tag --abbrev=0) + RELEASE_TAG_VERSION=${RELEASE_INPUT:-$LATEST_TAG} + echo RELEASE_TAG_VERSION="${RELEASE_TAG_VERSION:1}" >> "$GITHUB_ENV" + - name: Set up QEMU + uses: docker/setup-qemu-action@8b122486cedac8393e77aa9734c3528886e4a1a8 # v2.0.0 + # NOTE: we need QEMU to build Layer against a different architecture (e.g., ARM) + - name: Set up Docker Buildx + id: builder + uses: docker/setup-buildx-action@dc7b9719a96d48369863986a06765841d7ea23f6 # v2.0.0 + - name: install cdk and deps + run: | + npm install -g aws-cdk@2.44.0 + cdk --version + - name: CDK build + run: cdk synth --context version="$RELEASE_TAG_VERSION" -o cdk.out + - name: zip output + run: zip -r cdk.out.zip cdk.out + - name: Archive CDK artifacts + uses: actions/upload-artifact@v3 + with: + name: cdk-layer-artefact + path: layer/cdk.out.zip + + deploy-beta: + needs: + - build-layer + uses: ./.github/workflows/reusable_deploy_v2_layer_stack.yml + secrets: inherit + with: + stage: "BETA" + artefact-name: "cdk-layer-artefact" + environment: "layer-beta" + + # deploy-prod: + # needs: + # - deploy-beta + # uses: ./.github/workflows/reusable_deploy_layer_stack.yml + # secrets: inherit + # with: + # stage: "PROD" + # artefact-name: "cdk-layer-artefact" + # environment: "layer-prod" diff --git a/.github/workflows/reusable_deploy_v2_layer_stack.yml b/.github/workflows/reusable_deploy_v2_layer_stack.yml new file mode 100644 index 00000000000..8c4d45c7708 --- /dev/null +++ b/.github/workflows/reusable_deploy_v2_layer_stack.yml @@ -0,0 +1,102 @@ +name: Deploy CDK Layer v2 stack + +permissions: + id-token: write + contents: read + +env: + CDK_VERSION: 2.44.0 + +on: + workflow_call: + inputs: + stage: + description: "Deployment stage (BETA, PROD)" + required: true + type: string + artefact-name: + description: "CDK Layer Artefact name to download" + required: true + type: string + environment: + description: "GitHub Environment to use for encrypted secrets" + required: true + type: string + +jobs: + deploy-cdk-stack: + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + defaults: + run: + working-directory: ./layer + strategy: + fail-fast: false + matrix: + region: + [ + "af-south-1", + "eu-central-1", + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ap-east-1", + "ap-south-1", + "ap-northeast-1", + "ap-northeast-2", + "ap-southeast-1", + "ap-southeast-2", + "ca-central-1", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-south-1", + "eu-north-1", + "sa-east-1", + "ap-southeast-3", + "ap-northeast-3", + "me-south-1", + ] + steps: + - name: checkout + uses: actions/checkout@v3 + - name: Install poetry + run: pipx install poetry + - name: aws credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-region: ${{ matrix.region }} + role-to-assume: ${{ secrets.AWS_LAYERS_ROLE_ARN }} + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: "16.12" + - name: Setup python + uses: actions/setup-python@v4 + with: + python-version: "3.9" + cache: "pip" + - name: Resolve and install project dependencies + # CDK spawns system python when compiling stack + # therefore it ignores both activated virtual env and cached interpreter by GH + run: | + poetry export --format requirements.txt --output requirements.txt + pip install -r requirements.txt + - name: install cdk and deps + run: | + npm install -g "aws-cdk@$CDK_VERSION" + cdk --version + - name: install deps + run: poetry install + - name: Download artifact + uses: actions/download-artifact@v3 + with: + name: ${{ inputs.artefact-name }} + path: layer + - name: unzip artefact + run: unzip cdk.out.zip + - name: CDK Deploy Layer + run: cdk deploy --app cdk.out --context region=${{ matrix.region }} 'LayerStack' --require-approval never --verbose + - name: CDK Deploy Canary + run: cdk deploy --app cdk.out --context region=${{ matrix.region}} --parameters DeployStage="${{ inputs.stage }}" 'CanaryStack' --require-approval never --verbose diff --git a/Makefile b/Makefile index 7a212738c53..ba4c2943f84 100644 --- a/Makefile +++ b/Makefile @@ -6,12 +6,12 @@ target: dev: pip install --upgrade pip pre-commit poetry - poetry install --extras "pydantic" + poetry install --extras "all" pre-commit install dev-gitpod: pip install --upgrade pip poetry - poetry install --extras "pydantic" + poetry install --extras "all" pre-commit install format: diff --git a/layer/app.py b/layer/app.py index 50f8090482e..e44b05d453d 100644 --- a/layer/app.py +++ b/layer/app.py @@ -8,7 +8,8 @@ app = cdk.App() POWERTOOLS_VERSION: str = app.node.try_get_context("version") -SSM_PARAM_LAYER_ARN: str = "/layers/powertools-layer-arn" +SSM_PARAM_LAYER_ARN: str = "/layers/powertools-layer-v2-arn" +SSM_PARAM_LAYER_ARM64_ARN: str = "/layers/powertools-layer-v2-arm64-arn" if not POWERTOOLS_VERSION: raise ValueError( @@ -21,6 +22,7 @@ "LayerStack", powertools_version=POWERTOOLS_VERSION, ssm_paramter_layer_arn=SSM_PARAM_LAYER_ARN, + ssm_parameter_layer_arm64_arn=SSM_PARAM_LAYER_ARM64_ARN, ) CanaryStack( @@ -28,6 +30,7 @@ "CanaryStack", powertools_version=POWERTOOLS_VERSION, ssm_paramter_layer_arn=SSM_PARAM_LAYER_ARN, + ssm_parameter_layer_arm64_arn=SSM_PARAM_LAYER_ARM64_ARN, ) app.synth() diff --git a/layer/layer/canary/app.py b/layer/layer/canary/app.py index 1011fc654c2..b577eff7fa5 100644 --- a/layer/layer/canary/app.py +++ b/layer/layer/canary/app.py @@ -1,14 +1,19 @@ import datetime import json import os +import platform from importlib.metadata import version import boto3 +from pydantic import EmailStr from aws_lambda_powertools import Logger, Metrics, Tracer +from aws_lambda_powertools.utilities.parser import BaseModel, envelopes, event_parser +from aws_lambda_powertools.utilities.typing import LambdaContext +from aws_lambda_powertools.utilities.validation import validator logger = Logger(service="version-track") -tracer = Tracer() +tracer = Tracer() # this checks for aws-xray-sdk presence metrics = Metrics(namespace="powertools-layer-canary", service="PowertoolsLayerCanary") layer_arn = os.getenv("POWERTOOLS_LAYER_ARN") @@ -17,6 +22,26 @@ event_bus_arn = os.getenv("VERSION_TRACKING_EVENT_BUS_ARN") +# Model to check parser imports correctly, tests for pydantic and email-validator +class OrderItem(BaseModel): + order_id: int + quantity: int + description: str + email: EmailStr + + +# Tests for jmespath presence +@event_parser(model=OrderItem, envelope=envelopes.EventBridgeEnvelope) +def envelope_handler(event: OrderItem, context: LambdaContext): + assert event.order_id != 1 + + +# Tests for fastjsonschema presence +@validator(inbound_schema={}, envelope="detail") +def validator_handler(event, context: LambdaContext): + pass + + def handler(event): logger.info("Running checks") check_envs() @@ -42,9 +67,7 @@ def on_create(event): def check_envs(): - logger.info( - 'Checking required envs ["POWERTOOLS_LAYER_ARN", "AWS_REGION", "STAGE"]' - ) + logger.info('Checking required envs ["POWERTOOLS_LAYER_ARN", "AWS_REGION", "STAGE"]') if not layer_arn: raise ValueError("POWERTOOLS_LAYER_ARN is not set. Aborting...") if not powertools_version: @@ -66,9 +89,9 @@ def verify_powertools_version() -> None: current_version = version("aws_lambda_powertools") if powertools_version != current_version: raise ValueError( - f'Expected powertoosl version is "{powertools_version}", but layer contains version "{current_version}"' + f'Expected Powertools version is "{powertools_version}", but layer contains version "{current_version}"' ) - logger.info(f"Current Powertools version is: {current_version}") + logger.info(f"Current Powertools version is: {current_version} [{_get_architecture()}]") def send_notification(): @@ -76,10 +99,9 @@ def send_notification(): sends an event to version tracking event bridge """ if stage != "PROD": - logger.info( - "Not sending notification to event bus, because this is not the PROD stage" - ) + logger.info("Not sending notification to event bus, because this is not the PROD stage") return + event = { "Time": datetime.datetime.now(), "Source": "powertools.layer.canary", @@ -90,6 +112,7 @@ def send_notification(): "version": powertools_version, "region": os.environ["AWS_REGION"], "layerArn": layer_arn, + "architecture": _get_architecture(), } ), } @@ -102,3 +125,8 @@ def send_notification(): if resp["FailedEntryCount"] != 0: logger.error(resp) raise ValueError("Failed to send deployment notification to version tracking") + + +def _get_architecture() -> str: + """Returns aarch64, x86_64""" + return platform.uname()[4] diff --git a/layer/layer/canary_stack.py b/layer/layer/canary_stack.py index 1f903f91c74..fda9ebff3ad 100644 --- a/layer/layer/canary_stack.py +++ b/layer/layer/canary_stack.py @@ -2,12 +2,16 @@ from aws_cdk import CfnParameter, CustomResource, Duration, Stack from aws_cdk.aws_iam import Effect, ManagedPolicy, PolicyStatement, Role, ServicePrincipal -from aws_cdk.aws_lambda import Code, Function, LayerVersion, Runtime +from aws_cdk.aws_lambda import Architecture, Code, Function, LayerVersion, Runtime from aws_cdk.aws_logs import RetentionDays from aws_cdk.aws_ssm import StringParameter from aws_cdk.custom_resources import Provider from constructs import Construct +VERSION_TRACKING_EVENT_BUS_ARN: str = ( + "arn:aws:events:eu-central-1:027876851704:event-bus/VersionTrackingEventBus" +) + class CanaryStack(Stack): def __init__( @@ -16,25 +20,74 @@ def __init__( construct_id: str, powertools_version: str, ssm_paramter_layer_arn: str, + ssm_parameter_layer_arm64_arn: str, **kwargs, ) -> None: super().__init__(scope, construct_id, **kwargs) - VERSION_TRACKING_EVENT_BUS_ARN: str = ( - "arn:aws:events:eu-central-1:027876851704:event-bus/VersionTrackingEventBus" - ) + deploy_stage = CfnParameter( + self, "DeployStage", description="Deployment stage for canary" + ).value_as_string layer_arn = StringParameter.from_string_parameter_attributes( self, "LayerVersionArnParam", parameter_name=ssm_paramter_layer_arn ).string_value + Canary( + self, + "Canary-x86-64", + layer_arn=layer_arn, + powertools_version=powertools_version, + architecture=Architecture.X86_64, + stage=deploy_stage, + ) + + layer_arm64_arn = StringParameter.from_string_parameter_attributes( + self, + "LayerArm64VersionArnParam", + parameter_name=ssm_parameter_layer_arm64_arn, + ).string_value + Canary( + self, + "Canary-arm64", + layer_arn=layer_arm64_arn, + powertools_version=powertools_version, + architecture=Architecture.ARM_64, + stage=deploy_stage, + ) + + +class Canary(Construct): + def __init__( + self, + scope: Construct, + construct_id: str, + layer_arn: str, + powertools_version: str, + architecture: Architecture, + stage: str, + ): + super().__init__(scope, construct_id) - layer = LayerVersion.from_layer_version_arn(self, "PowertoolsLayer", layer_version_arn=layer_arn) - deploy_stage = CfnParameter(self, "DeployStage", description="Deployment stage for canary").value_as_string + layer = LayerVersion.from_layer_version_arn( + self, "PowertoolsLayer", layer_version_arn=layer_arn + ) - execution_role = Role(self, "LambdaExecutionRole", assumed_by=ServicePrincipal("lambda.amazonaws.com")) + execution_role = Role( + self, + "LambdaExecutionRole", + assumed_by=ServicePrincipal("lambda.amazonaws.com"), + ) execution_role.add_managed_policy( - ManagedPolicy.from_aws_managed_policy_name("service-role/AWSLambdaBasicExecutionRole") + ManagedPolicy.from_aws_managed_policy_name( + "service-role/AWSLambdaBasicExecutionRole" + ) + ) + + execution_role.add_to_policy( + PolicyStatement( + effect=Effect.ALLOW, actions=["lambda:GetFunction"], resources=["*"] + ) ) canary_lambda = Function( @@ -46,25 +99,35 @@ def __init__( memory_size=512, timeout=Duration.seconds(10), runtime=Runtime.PYTHON_3_9, + architecture=architecture, log_retention=RetentionDays.ONE_MONTH, role=execution_role, environment={ "POWERTOOLS_VERSION": powertools_version, "POWERTOOLS_LAYER_ARN": layer_arn, "VERSION_TRACKING_EVENT_BUS_ARN": VERSION_TRACKING_EVENT_BUS_ARN, - "LAYER_PIPELINE_STAGE": deploy_stage, + "LAYER_PIPELINE_STAGE": stage, }, ) canary_lambda.add_to_role_policy( PolicyStatement( - effect=Effect.ALLOW, actions=["events:PutEvents"], resources=[VERSION_TRACKING_EVENT_BUS_ARN] + effect=Effect.ALLOW, + actions=["events:PutEvents"], + resources=[VERSION_TRACKING_EVENT_BUS_ARN], ) ) # custom resource provider configuration provider = Provider( - self, "CanaryCustomResource", on_event_handler=canary_lambda, log_retention=RetentionDays.ONE_MONTH + self, + "CanaryCustomResource", + on_event_handler=canary_lambda, + log_retention=RetentionDays.ONE_MONTH, ) # force to recreate resource on each deployment with randomized name - CustomResource(self, f"CanaryTrigger-{str(uuid.uuid4())[0:7]}", service_token=provider.service_token) + CustomResource( + self, + f"CanaryTrigger-{str(uuid.uuid4())[0:7]}", + service_token=provider.service_token, + ) diff --git a/layer/layer/layer_stack.py b/layer/layer/layer_stack.py index f15232eb560..6a92e1fa408 100644 --- a/layer/layer/layer_stack.py +++ b/layer/layer/layer_stack.py @@ -1,18 +1,38 @@ from aws_cdk import CfnOutput, RemovalPolicy, Stack -from aws_cdk.aws_lambda import CfnLayerVersionPermission +from aws_cdk.aws_lambda import Architecture, CfnLayerVersionPermission from aws_cdk.aws_ssm import StringParameter -from cdk_lambda_powertools_python_layer import LambdaPowertoolsLayer +from cdk_aws_lambda_powertools_layer import LambdaPowertoolsLayer from constructs import Construct class LayerStack(Stack): def __init__( - self, scope: Construct, construct_id: str, powertools_version: str, ssm_paramter_layer_arn: str, **kwargs + self, + scope: Construct, + construct_id: str, + powertools_version: str, + ssm_paramter_layer_arn: str, + ssm_parameter_layer_arm64_arn: str, + **kwargs ) -> None: super().__init__(scope, construct_id, **kwargs) layer = LambdaPowertoolsLayer( - self, "Layer", layer_version_name="AWSLambdaPowertoolsPython", version=powertools_version + self, + "Layer", + layer_version_name="AWSLambdaPowertoolsPythonV2", + version=powertools_version, + include_extras=True, + compatible_architectures=[Architecture.X86_64], + ) + + layer_arm64 = LambdaPowertoolsLayer( + self, + "Layer-ARM64", + layer_version_name="AWSLambdaPowertoolsPythonV2-Arm64", + version=powertools_version, + include_extras=True, + compatible_architectures=[Architecture.ARM_64], ) layer_permission = CfnLayerVersionPermission( @@ -23,9 +43,32 @@ def __init__( principal="*", ) + layer_permission_arm64 = CfnLayerVersionPermission( + self, + "PublicLayerAccessArm64", + action="lambda:GetLayerVersion", + layer_version_arn=layer_arm64.layer_version_arn, + principal="*", + ) + layer_permission.apply_removal_policy(RemovalPolicy.RETAIN) + layer_permission_arm64.apply_removal_policy(RemovalPolicy.RETAIN) + layer.apply_removal_policy(RemovalPolicy.RETAIN) + layer_arm64.apply_removal_policy(RemovalPolicy.RETAIN) - StringParameter(self, "VersionArn", parameter_name=ssm_paramter_layer_arn, string_value=layer.layer_version_arn) + StringParameter( + self, + "VersionArn", + parameter_name=ssm_paramter_layer_arn, + string_value=layer.layer_version_arn, + ) + StringParameter( + self, + "Arm64VersionArn", + parameter_name=ssm_parameter_layer_arm64_arn, + string_value=layer_arm64.layer_version_arn, + ) CfnOutput(self, "LatestLayerArn", value=layer.layer_version_arn) + CfnOutput(self, "LatestLayerArm64Arn", value=layer_arm64.layer_version_arn) diff --git a/layer/poetry.lock b/layer/poetry.lock index 182094a8b9d..6720fc5b96a 100644 --- a/layer/poetry.lock +++ b/layer/poetry.lock @@ -1,28 +1,20 @@ -[[package]] -name = "atomicwrites" -version = "1.4.1" -description = "Atomic file writes." -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - [[package]] name = "attrs" -version = "21.4.0" +version = "22.1.0" description = "Classes Without Boilerplate" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.5" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "cloudpickle"] [[package]] name = "aws-cdk-lib" -version = "2.35.0" +version = "2.45.0" description = "Version 2 of the AWS Cloud Development Kit library" category = "main" optional = false @@ -30,20 +22,20 @@ python-versions = "~=3.7" [package.dependencies] constructs = ">=10.0.0,<11.0.0" -jsii = ">=1.63.2,<2.0.0" +jsii = ">=1.68.0,<2.0.0" publication = ">=0.0.3" typeguard = ">=2.13.3,<2.14.0" [[package]] name = "boto3" -version = "1.24.46" +version = "1.24.88" description = "The AWS SDK for Python" category = "dev" optional = false python-versions = ">= 3.7" [package.dependencies] -botocore = ">=1.27.46,<1.28.0" +botocore = ">=1.27.88,<1.28.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.6.0,<0.7.0" @@ -52,7 +44,7 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.27.46" +version = "1.27.88" description = "Low-level, data-driven core of boto 3." category = "dev" optional = false @@ -64,7 +56,7 @@ python-dateutil = ">=2.1,<3.0.0" urllib3 = ">=1.25.4,<1.27" [package.extras] -crt = ["awscrt (==0.13.8)"] +crt = ["awscrt (==0.14.0)"] [[package]] name = "cattrs" @@ -79,18 +71,19 @@ attrs = ">=20" exceptiongroup = {version = "*", markers = "python_version <= \"3.10\""} [[package]] -name = "cdk-lambda-powertools-python-layer" -version = "2.0.49" -description = "A lambda layer for AWS Powertools for python" +name = "cdk-aws-lambda-powertools-layer" +version = "3.1.0" +description = "A lambda layer for AWS Powertools for python and typescript" category = "main" optional = false python-versions = "~=3.7" [package.dependencies] -aws-cdk-lib = ">=2.2.0,<3.0.0" +aws-cdk-lib = ">=2.44.0,<3.0.0" constructs = ">=10.0.5,<11.0.0" -jsii = ">=1.61.0,<2.0.0" +jsii = ">=1.69.0,<2.0.0" publication = ">=0.0.3" +typeguard = ">=2.13.3,<2.14.0" [[package]] name = "colorama" @@ -102,20 +95,20 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "constructs" -version = "10.1.67" +version = "10.1.124" description = "A programming model for software-defined state" category = "main" optional = false python-versions = "~=3.7" [package.dependencies] -jsii = ">=1.63.2,<2.0.0" +jsii = ">=1.69.0,<2.0.0" publication = ">=0.0.3" typeguard = ">=2.13.3,<2.14.0" [[package]] name = "exceptiongroup" -version = "1.0.0rc8" +version = "1.0.0rc9" description = "Backport of PEP 654 (exception groups)" category = "main" optional = false @@ -142,14 +135,14 @@ python-versions = ">=3.7" [[package]] name = "jsii" -version = "1.63.2" +version = "1.69.0" description = "Python client for jsii runtime" category = "main" optional = false python-versions = "~=3.7" [package.dependencies] -attrs = ">=21.2,<22.0" +attrs = ">=21.2,<23.0" cattrs = ">=1.8,<22.2" publication = ">=0.0.3" python-dateutil = "*" @@ -176,8 +169,8 @@ optional = false python-versions = ">=3.6" [package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] +testing = ["pytest-benchmark", "pytest"] +dev = ["tox", "pre-commit"] [[package]] name = "publication" @@ -208,14 +201,13 @@ diagrams = ["railroad-diagrams", "jinja2"] [[package]] name = "pytest" -version = "7.1.2" +version = "7.1.3" description = "pytest: simple powerful testing with Python" category = "dev" optional = false python-versions = ">=3.7" [package.dependencies] -atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} iniconfig = "*" @@ -277,12 +269,12 @@ optional = false python-versions = ">=3.5.3" [package.extras] -doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] -test = ["pytest", "typing-extensions", "mypy"] +test = ["mypy", "typing-extensions", "pytest"] +doc = ["sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] [[package]] name = "typing-extensions" -version = "4.3.0" +version = "4.4.0" description = "Backported and Experimental Type Hints for Python 3.7+" category = "main" optional = false @@ -290,7 +282,7 @@ python-versions = ">=3.7" [[package]] name = "urllib3" -version = "1.26.11" +version = "1.26.12" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "dev" optional = false @@ -298,94 +290,64 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, [package.extras] brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] -secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "a68a9649808efb49529ace7d990559e6569be096bf2d86234f3bd056bae0fdc3" +content-hash = "cceb24edf99719274b946c811ad41ebbbdc0181593d4f07555c0977767cdd19a" [metadata.files] -atomicwrites = [ - {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, -] attrs = [ - {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, - {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, + {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, + {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, ] aws-cdk-lib = [ - {file = "aws-cdk-lib-2.35.0.tar.gz", hash = "sha256:fc9cba4df0b60a9ab7f17ceb3b1c447d27e96cec9eb9e8c5b7ecfd1275878930"}, - {file = "aws_cdk_lib-2.35.0-py3-none-any.whl", hash = "sha256:ee481dca9335c32b5871e58ba697e27e2f1e92d9b81cf9341cfc6cc36127a2b0"}, + {file = "aws-cdk-lib-2.45.0.tar.gz", hash = "sha256:ed4166498205a6507666a9fdb69f5dbeffa11cd69bf7e98b279ec305e4970374"}, + {file = "aws_cdk_lib-2.45.0-py3-none-any.whl", hash = "sha256:9463fe6d84563c4c23ae96810be0ea0ff0a260eebb85a4a7afe0c3747eca18a8"}, ] boto3 = [ - {file = "boto3-1.24.46-py3-none-any.whl", hash = "sha256:44026e44549148dbc5b261ead5f6b339e785680c350ef621bf85f7e2fca05b49"}, - {file = "boto3-1.24.46.tar.gz", hash = "sha256:b2d9d55f123a9a91eea2fd8e379d90abf37634420fbb45c22d67e10b324ec71b"}, + {file = "boto3-1.24.88-py3-none-any.whl", hash = "sha256:6b4cf1cd0be65202c4cf0e4c69099bac3620bcd4049ca25a5e223c668401dd69"}, + {file = "boto3-1.24.88.tar.gz", hash = "sha256:93934343cac76084600a520e5be70c52152364d0c410681c2e25c2290f0e151c"}, ] botocore = [ - {file = "botocore-1.27.46-py3-none-any.whl", hash = "sha256:747b7e94aef41498f063fc0be79c5af102d940beea713965179e1ead89c7e9ec"}, - {file = "botocore-1.27.46.tar.gz", hash = "sha256:f66d8305d1f59d83334df9b11b6512bb1e14698ec4d5d6d42f833f39f3304ca7"}, -] -cattrs = [ - {file = "cattrs-22.1.0-py3-none-any.whl", hash = "sha256:d55c477b4672f93606e992049f15d526dc7867e6c756cd6256d4af92e2b1e364"}, - {file = "cattrs-22.1.0.tar.gz", hash = "sha256:94b67b64cf92c994f8784c40c082177dc916e0489a73a9a36b24eb18a9db40c6"}, + {file = "botocore-1.27.88-py3-none-any.whl", hash = "sha256:de4e087b24cd3bc369eb2e27f8fe94a6499f7dea08c919fba13cefb2496bd2bb"}, + {file = "botocore-1.27.88.tar.gz", hash = "sha256:ded0a4035baf91eb358ef501c92a8512543f5ab7658f459df3077a70a555b5cd"}, ] -cdk-lambda-powertools-python-layer = [ - {file = "cdk-lambda-powertools-python-layer-2.0.49.tar.gz", hash = "sha256:8055fc691539f16e22a40e3d3df9c3f59fb28012437b08c47c639aefb001f1b2"}, - {file = "cdk_lambda_powertools_python_layer-2.0.49-py3-none-any.whl", hash = "sha256:9b0a7b7344f9ccb486564af728cefeac743687bfb131631e6d9171a55800dbac"}, -] -colorama = [ - {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, - {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, +cattrs = [] +cdk-aws-lambda-powertools-layer = [ + {file = "cdk-aws-lambda-powertools-layer-3.1.0.tar.gz", hash = "sha256:1e1457edbebfbb62ab2d21ae0f9b991db1b6c8508730fab8c99c3a7d3dfd99cb"}, + {file = "cdk_aws_lambda_powertools_layer-3.1.0-py3-none-any.whl", hash = "sha256:c66cc722c924a50458418600df4060a1396e134df25b3cc85de59d5914541b31"}, ] +colorama = [] constructs = [ - {file = "constructs-10.1.67-py3-none-any.whl", hash = "sha256:d597d8d5387328c1e95fa674d5d64969b1c1a479e63544e53a067a5d95b5c46b"}, - {file = "constructs-10.1.67.tar.gz", hash = "sha256:8b9fdf5040dde63545c08b8cc86fcd019512e0d16ee599c82b1201a5806f0066"}, + {file = "constructs-10.1.124-py3-none-any.whl", hash = "sha256:76ef2e6776cfdd1c4131a18b0e83c1b154d462b3ebb37ddbeaf3043b3b0de301"}, + {file = "constructs-10.1.124.tar.gz", hash = "sha256:5e4bfcca275867e5cfb4636ef65ed334c861a816f287ce1136f082ad0e0c1c2c"}, ] exceptiongroup = [ - {file = "exceptiongroup-1.0.0rc8-py3-none-any.whl", hash = "sha256:ab0a968e1ef769e55d9a596f4a89f7be9ffedbc9fdefdb77cc68cf5c33ce1035"}, - {file = "exceptiongroup-1.0.0rc8.tar.gz", hash = "sha256:6990c24f06b8d33c8065cfe43e5e8a4bfa384e0358be036af9cc60b6321bd11a"}, -] -iniconfig = [ - {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, - {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, + {file = "exceptiongroup-1.0.0rc9-py3-none-any.whl", hash = "sha256:2e3c3fc1538a094aab74fad52d6c33fc94de3dfee3ee01f187c0e0c72aec5337"}, + {file = "exceptiongroup-1.0.0rc9.tar.gz", hash = "sha256:9086a4a21ef9b31c72181c77c040a074ba0889ee56a7b289ff0afb0d97655f96"}, ] +iniconfig = [] jmespath = [ {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, ] jsii = [ - {file = "jsii-1.63.2-py3-none-any.whl", hash = "sha256:ae8cbc84c633382c317dc367e1441bb2afd8b74ed82b3557b8df15e05316b14d"}, - {file = "jsii-1.63.2.tar.gz", hash = "sha256:6f68dcd82395ccd12606b31383f611adfefd246082750350891a2a277562f34b"}, -] -packaging = [ - {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, - {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, -] -pluggy = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, -] -publication = [ - {file = "publication-0.0.3-py2.py3-none-any.whl", hash = "sha256:0248885351febc11d8a1098d5c8e3ab2dabcf3e8c0c96db1e17ecd12b53afbe6"}, - {file = "publication-0.0.3.tar.gz", hash = "sha256:68416a0de76dddcdd2930d1c8ef853a743cc96c82416c4e4d3b5d901c6276dc4"}, -] -py = [ - {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, - {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, -] -pyparsing = [ - {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, - {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, + {file = "jsii-1.69.0-py3-none-any.whl", hash = "sha256:f3ae5cdf5e854b4d59256dc1f8818cd3fabb8eb43fbd3134a8e8aef962643005"}, + {file = "jsii-1.69.0.tar.gz", hash = "sha256:7c7ed2a913372add17d63322a640c6435324770eb78c6b89e4c701e07d9c84db"}, ] +packaging = [] +pluggy = [] +publication = [] +py = [] +pyparsing = [] pytest = [ - {file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"}, - {file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"}, -] -python-dateutil = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, + {file = "pytest-7.1.3-py3-none-any.whl", hash = "sha256:1377bda3466d70b55e3f5cecfa55bb7cfcf219c7964629b967c37cf0bda818b7"}, + {file = "pytest-7.1.3.tar.gz", hash = "sha256:4f365fec2dff9c1162f834d9f18af1ba13062db0c708bf7b946f8a5c76180c39"}, ] +python-dateutil = [] s3transfer = [ {file = "s3transfer-0.6.0-py3-none-any.whl", hash = "sha256:06176b74f3a15f61f1b4f25a1fc29a4429040b7647133a463da8fa5bd28d5ecd"}, {file = "s3transfer-0.6.0.tar.gz", hash = "sha256:2ed07d3866f523cc561bf4a00fc5535827981b117dd7876f036b0c1aca42c947"}, @@ -403,7 +365,10 @@ typeguard = [ {file = "typeguard-2.13.3.tar.gz", hash = "sha256:00edaa8da3a133674796cf5ea87d9f4b4c367d77476e185e80251cc13dfbb8c4"}, ] typing-extensions = [ - {file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"}, - {file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"}, + {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, + {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, +] +urllib3 = [ + {file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"}, + {file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"}, ] -urllib3 = [] diff --git a/layer/pyproject.toml b/layer/pyproject.toml index 7f219453a72..4b0cada589f 100644 --- a/layer/pyproject.toml +++ b/layer/pyproject.toml @@ -1,14 +1,14 @@ [tool.poetry] name = "aws-lambda-powertools-python-layer" -version = "0.1.0" +version = "1.1.0" description = "AWS Lambda Powertools for Python Lambda Layers" authors = ["DevAx "] license = "MIT" [tool.poetry.dependencies] python = "^3.9" -cdk-lambda-powertools-python-layer = "^2.0.49" -aws-cdk-lib = "^2.35.0" +cdk-aws-lambda-powertools-layer = "^3.1.0" +aws-cdk-lib = "^2.44.0" [tool.poetry.dev-dependencies] pytest = "^7.1.2" diff --git a/package-lock.json b/package-lock.json index 5a72aa1ad10..1764eda669e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,13 +8,14 @@ "name": "aws-lambda-powertools-python-e2e", "version": "1.0.0", "devDependencies": { - "aws-cdk": "2.40.0" + "aws-cdk": "2.44.0" } }, "node_modules/aws-cdk": { - "version": "2.40.0", - "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.40.0.tgz", - "integrity": "sha512-oHacGkLFDELwhpJsZSAhFHWDxIeZW3DgKkwiXlNO81JxNfjcHgPR2rsbh/Gz+n4ErAEzOV6WfuWVMe68zv+iPg==", + "version": "2.44.0", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.44.0.tgz", + "integrity": "sha512-9hbK4Yc1GQ28zSjZE2ajidt7sRrTLYpijkI7HT7JcDhXLe2ZGP9EOZrqKy5EEsOv0wDQ7cdXB3/oMiMGSmSQ5A==", + "dev": true, "bin": { "cdk": "bin/cdk" }, @@ -29,6 +30,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -41,9 +43,10 @@ }, "dependencies": { "aws-cdk": { - "version": "2.40.0", - "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.40.0.tgz", - "integrity": "sha512-oHacGkLFDELwhpJsZSAhFHWDxIeZW3DgKkwiXlNO81JxNfjcHgPR2rsbh/Gz+n4ErAEzOV6WfuWVMe68zv+iPg==", + "version": "2.44.0", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.44.0.tgz", + "integrity": "sha512-9hbK4Yc1GQ28zSjZE2ajidt7sRrTLYpijkI7HT7JcDhXLe2ZGP9EOZrqKy5EEsOv0wDQ7cdXB3/oMiMGSmSQ5A==", + "dev": true, "requires": { "fsevents": "2.3.2" } @@ -52,6 +55,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "optional": true } } diff --git a/package.json b/package.json index 6e3a2c1b216..6d5eb3f5bee 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,6 @@ "name": "aws-lambda-powertools-python-e2e", "version": "1.0.0", "devDependencies": { - "aws-cdk": "2.40.0" + "aws-cdk": "2.44.0" } } diff --git a/poetry.lock b/poetry.lock index ab4aa340839..9bbd6f8e450 100644 --- a/poetry.lock +++ b/poetry.lock @@ -62,7 +62,7 @@ name = "aws-xray-sdk" version = "2.10.0" description = "The AWS X-Ray SDK for Python (the SDK) enables Python developers to record and emit information from within their applications to the AWS X-Ray service." category = "main" -optional = false +optional = true python-versions = "*" [package.dependencies] @@ -301,7 +301,7 @@ name = "fastjsonschema" version = "2.16.2" description = "Fastest Python implementation of JSON schema" category = "main" -optional = false +optional = true python-versions = "*" [package.extras] @@ -1343,7 +1343,7 @@ name = "wrapt" version = "1.14.1" description = "Module for decorators, wrappers and monkey patching." category = "main" -optional = false +optional = true python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [[package]] @@ -1372,12 +1372,16 @@ docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo" testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "jaraco.functools", "more-itertools", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] [extras] -pydantic = ["pydantic", "email-validator"] +all = ["pydantic", "email-validator", "aws-xray-sdk", "fastjsonschema"] +aws-sdk = ["boto3"] +parser = ["pydantic", "email-validator"] +tracer = ["aws-xray-sdk"] +validation = ["fastjsonschema"] [metadata] lock-version = "1.1" python-versions = "^3.7.4" -content-hash = "78c3d0650111a4404b357906c052a1892979125a48b00b3167ad331fa1d4c795" +content-hash = "1e4e1c94d7bb2e4dc1bf4d5631c5548db3e4f2912ef454903816c97f09e04cc1" [metadata.files] attrs = [ diff --git a/pyproject.toml b/pyproject.toml index b7fba743f01..d33a5dd8338 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,18 +20,17 @@ license = "MIT-0" [tool.poetry.dependencies] python = "^3.7.4" -aws-xray-sdk = "^2.8.0" -fastjsonschema = "^2.14.5" -boto3 = "^1.18" -pydantic = {version = "^1.8.2", optional = true } -email-validator = {version = "*", optional = true } +aws-xray-sdk = { version = "^2.8.0", optional = true } +fastjsonschema = { version = "^2.14.5", optional = true } +pydantic = { version = "^1.8.2", optional = true } +email-validator = { version = "^1.3.0", optional = true } +boto3 = { version = "^1.20.32", optional = true } [tool.poetry.dev-dependencies] -# Maintenance: 2022-04-21 jmespath was removed, to be re-added once we drop python 3.6. -# issue #1148 coverage = {extras = ["toml"], version = "^6.2"} pytest = "^7.0.1" black = "^22.8" +boto3 = "^1.18" flake8-builtins = "^1.5.3" flake8-comprehensions = "^3.7.0" flake8-debugger = "^4.0.0" @@ -77,8 +76,12 @@ checksumdir = "^1.2.0" importlib-metadata = "^4.13" [tool.poetry.extras] -pydantic = ["pydantic", "email-validator"] - +parser = ["pydantic", "email-validator"] +validation = ["fastjsonschema"] +tracer = ["aws-xray-sdk"] +all = ["pydantic", "email-validator", "aws-xray-sdk", "fastjsonschema"] +# allow customers to run code locally without emulators (SAM CLI, etc.) +aws-sdk = ["boto3"] [tool.coverage.run] source = ["aws_lambda_powertools"] omit = ["tests/*", "aws_lambda_powertools/exceptions/*", "aws_lambda_powertools/utilities/parser/types.py", "aws_lambda_powertools/utilities/jmespath_utils/envelopes.py"] diff --git a/tests/e2e/utils/infrastructure.py b/tests/e2e/utils/infrastructure.py index 82d0463b2aa..67f2af623f8 100644 --- a/tests/e2e/utils/infrastructure.py +++ b/tests/e2e/utils/infrastructure.py @@ -11,7 +11,7 @@ import boto3 import pytest from aws_cdk import App, CfnOutput, Environment, RemovalPolicy, Stack, aws_logs -from aws_cdk.aws_lambda import Code, Function, LayerVersion, Runtime, Tracing +from aws_cdk.aws_lambda import Architecture, Code, Function, LayerVersion, Runtime, Tracing from filelock import FileLock from mypy_boto3_cloudformation import CloudFormationClient @@ -53,7 +53,9 @@ def __init__(self) -> None: "You must have your infrastructure defined in 'tests/e2e//infrastructure.py'." ) - def create_lambda_functions(self, function_props: Optional[Dict] = None) -> Dict[str, Function]: + def create_lambda_functions( + self, function_props: Optional[Dict] = None, architecture: Architecture = Architecture.X86_64 + ) -> Dict[str, Function]: """Create Lambda functions available under handlers_dir It creates CloudFormation Outputs for every function found in PascalCase. For example, @@ -65,6 +67,9 @@ def create_lambda_functions(self, function_props: Optional[Dict] = None) -> Dict function_props: Optional[Dict] Dictionary representing CDK Lambda FunctionProps to override defaults + architecture: Architecture + Used to create Lambda Layer and functions in a different architecture. Defaults to x86_64. + Returns ------- output: Dict[str, Function] @@ -90,7 +95,7 @@ def create_lambda_functions(self, function_props: Optional[Dict] = None) -> Dict if not self._handlers_dir.exists(): raise RuntimeError(f"Handlers dir '{self._handlers_dir}' must exist for functions to be created.") - layer_build = LocalLambdaPowertoolsLayer().build() + layer_build = LocalLambdaPowertoolsLayer(architecture=architecture).build() layer = LayerVersion( self.stack, "aws-lambda-powertools-e2e-test", @@ -100,6 +105,7 @@ def create_lambda_functions(self, function_props: Optional[Dict] = None) -> Dict Runtime.PYTHON_3_8, Runtime.PYTHON_3_9, ], + compatible_architectures=[architecture], code=Code.from_asset(path=layer_build), ) @@ -123,6 +129,7 @@ def create_lambda_functions(self, function_props: Optional[Dict] = None) -> Dict "tracing": Tracing.ACTIVE, "runtime": Runtime.PYTHON_3_9, "layers": [layer], + "architecture": architecture, **function_settings_override, } @@ -156,7 +163,8 @@ def deploy(self) -> Dict[str, str]: stack_file = self._create_temp_cdk_app() synth_command = f"npx cdk synth --app 'python {stack_file}' -o {self._cdk_out_dir}" deploy_command = ( - f"npx cdk deploy --app '{self._cdk_out_dir}' -O {self._stack_outputs_file} --require-approval=never" + f"npx cdk deploy --app '{self._cdk_out_dir}' -O {self._stack_outputs_file} " + "--require-approval=never --method=direct" ) # CDK launches a background task, so we must wait diff --git a/tests/e2e/utils/lambda_layer/powertools_layer.py b/tests/e2e/utils/lambda_layer/powertools_layer.py index 45a22547715..23eae521696 100644 --- a/tests/e2e/utils/lambda_layer/powertools_layer.py +++ b/tests/e2e/utils/lambda_layer/powertools_layer.py @@ -2,6 +2,7 @@ import subprocess from pathlib import Path +from aws_cdk.aws_lambda import Architecture from checksumdir import dirhash from aws_lambda_powertools import PACKAGE_PATH @@ -14,11 +15,21 @@ class LocalLambdaPowertoolsLayer(BaseLocalLambdaLayer): IGNORE_EXTENSIONS = ["pyc"] - def __init__(self, output_dir: Path = CDK_OUT_PATH): + def __init__(self, output_dir: Path = CDK_OUT_PATH, architecture: Architecture = Architecture.X86_64): super().__init__(output_dir) - self.package = f"{SOURCE_CODE_ROOT_PATH}[pydantic]" - self.build_args = "--platform manylinux1_x86_64 --only-binary=:all: --upgrade" + self.package = f"{SOURCE_CODE_ROOT_PATH}[all]" + + platform_name = self._resolve_platform(architecture) + self.build_args = f"--platform {platform_name} --only-binary=:all: --upgrade" self.build_command = f"python -m pip install {self.package} {self.build_args} --target {self.target_dir}" + self.cleanup_command = ( + f"rm -rf {self.target_dir}/boto* {self.target_dir}/s3transfer* && " + f"rm -rf {self.target_dir}/*dateutil* {self.target_dir}/urllib3* {self.target_dir}/six* && " + f"rm -rf {self.target_dir}/jmespath* && " + f"find {self.target_dir} -name '*.so' -type f -exec strip '{{}}' \\; && " + f"find {self.target_dir} -wholename '*/tests/*' -type f -delete && " + f"find {self.target_dir} -regex '^.*\\(__pycache__\\|\\.py[co]\\)$' -delete" + ) self.source_diff_file: Path = CDK_OUT_PATH / "layer_build.diff" def build(self) -> str: @@ -31,6 +42,9 @@ def build(self) -> str: return str(self.output_dir) + def after_build(self): + subprocess.run(self.cleanup_command, shell=True) + def _has_source_changed(self) -> bool: """Hashes source code and @@ -46,3 +60,18 @@ def _has_source_changed(self) -> bool: return True return False + + def _resolve_platform(self, architecture: Architecture) -> str: + """Returns the correct plaform name for the manylinux project (see PEP 599) + + Returns + ------- + platform_name : str + The platform tag + """ + if architecture.name == Architecture.X86_64.name: + return "manylinux1_x86_64" + elif architecture.name == Architecture.ARM_64.name: + return "manylinux2014_aarch64" + else: + raise ValueError(f"unknown architecture {architecture.name}") From 8ee62594dd381208ace782a355a41d377a552bcd Mon Sep 17 00:00:00 2001 From: Heitor Lessa Date: Fri, 7 Oct 2022 16:50:52 +0200 Subject: [PATCH 13/30] docs(v2): document optional dependencies and local dev (#1574) --- README.md | 4 +- docs/core/tracer.md | 8 +++ docs/index.md | 20 +++++++- docs/utilities/parser.md | 95 +++++++++--------------------------- docs/utilities/validation.md | 8 +++ 5 files changed, 61 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index c1845f43ce7..c0e32e6b6aa 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - + # AWS Lambda Powertools for Python [![Build](https://github.com/awslabs/aws-lambda-powertools-python/actions/workflows/python_build.yml/badge.svg)](https://github.com/awslabs/aws-lambda-powertools-python/actions/workflows/python_build.yml) @@ -6,7 +6,7 @@ ![PythonSupport](https://img.shields.io/static/v1?label=python&message=3.6%20|%203.7|%203.8|%203.9&color=blue?style=flat-square&logo=python) ![PyPI version](https://badge.fury.io/py/aws-lambda-powertools.svg) ![PyPi monthly downloads](https://img.shields.io/pypi/dm/aws-lambda-powertools) ![Lambda Layer](https://api.globadge.com/v1/badgen/aws/lambda/layer/latest-version/eu-central-1/017000801446/AWSLambdaPowertoolsPython) [![Join our Discord](https://dcbadge.vercel.app/api/server/B8zZKbbyET)](https://discord.gg/B8zZKbbyET) -A suite of Python utilities for AWS Lambda functions to ease adopting best practices such as tracing, structured logging, custom metrics, and more. (AWS Lambda Powertools [Java](https://github.com/awslabs/aws-lambda-powertools-java) and [Typescript](https://github.com/awslabs/aws-lambda-powertools-typescript) is also available). +A suite of Python utilities for AWS Lambda functions to ease adopting best practices such as tracing, structured logging, custom metrics, and more. (AWS Lambda Powertools for [Java](https://github.com/awslabs/aws-lambda-powertools-java), [Typescript](https://github.com/awslabs/aws-lambda-powertools-typescript), and [.NET](https://awslabs.github.io/aws-lambda-powertools-dotnet/){target="_blank"} are also available). **[📜Documentation](https://awslabs.github.io/aws-lambda-powertools-python/)** | **[🐍PyPi](https://pypi.org/project/aws-lambda-powertools/)** | **[Roadmap](https://awslabs.github.io/aws-lambda-powertools-python/latest/roadmap/)** | **[Detailed blog post](https://aws.amazon.com/blogs/opensource/simplifying-serverless-best-practices-with-lambda-powertools/)** diff --git a/docs/core/tracer.md b/docs/core/tracer.md index 8fbfc0e29f7..018af91797b 100644 --- a/docs/core/tracer.md +++ b/docs/core/tracer.md @@ -19,6 +19,14 @@ Tracer is an opinionated thin wrapper for [AWS X-Ray Python SDK](https://github. ???+ tip All examples shared in this documentation are available within the [project repository](https://github.com/awslabs/aws-lambda-powertools-python/tree/develop/examples){target="_blank"}. +### Install + +!!! info "This is not necessary if you're installing Powertools via [Lambda Layer](../index.md#lambda-layer){target="_blank"}" + +Add `aws-lambda-powertools[tracer]` as a dependency in your preferred tool: _e.g._, _requirements.txt_, _pyproject.toml_. + +This will ensure you have the required dependencies before using Tracer. + ### Permissions Before your use this utility, your AWS Lambda function [must have permissions](https://docs.aws.amazon.com/lambda/latest/dg/services-xray.html#services-xray-permissions) to send traces to AWS X-Ray. diff --git a/docs/index.md b/docs/index.md index dccfd49eb33..3626a13b47f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,7 @@ description: AWS Lambda Powertools for Python A suite of utilities for AWS Lambda functions to ease adopting best practices such as tracing, structured logging, custom metrics, idempotency, batching, and more. ???+ note - Lambda Powertools is also available for [Java](https://awslabs.github.io/aws-lambda-powertools-java/){target="_blank"} and [TypeScript](https://awslabs.github.io/aws-lambda-powertools-typescript/latest/){target="_blank"}. + Powertools is also available for [Java](https://awslabs.github.io/aws-lambda-powertools-java/){target="_blank"}, [TypeScript](https://awslabs.github.io/aws-lambda-powertools-typescript/latest/){target="_blank"}, and [.NET](https://awslabs.github.io/aws-lambda-powertools-dotnet/){target="_blank"} ## Install @@ -20,11 +20,29 @@ Powertools is available in the following formats: * **Lambda Layer**: [**arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPython:38**](#){: .copyMe}:clipboard: * **PyPi**: **`pip install aws-lambda-powertools`** +???+ info "Some utilities require additional dependencies" + You can stop reading if you're using Lambda Layer. + + [Tracer](./core/tracer.md){target="_blank"}, [Validation](./utilities/validation.md){target="_blank"} and [Parser](./utilities/parser.md){target="_blank"} require additional dependencies. If you prefer to install all of them, use `pip install aws-lambda-powertools[all]`. + ???+ hint "Support this project by using Lambda Layers :heart:" Lambda Layers allow us to understand who uses this library in a non-intrusive way. This helps us justify and gain future investments for other Lambda Powertools languages. When using Layers, you can add Lambda Powertools as a dev dependency (or as part of your virtual env) to not impact the development process. +### Local development + +Powertools relies on the AWS SDK bundled in the Lambda runtime. This helps us achieve an optimal package size and initialization. + +This means you need to add AWS SDK as a development dependency (not as a production dependency). + +* **Pip**: `pip install aws-lambda-powertools[aws-sdk]` +* **Poetry**: `poetry add aws-lambda-powertools[aws-sdk] --dev` +* **Pipenv**: `pipenv install --dev "aws-lambda-powertools[aws-sdk]"` + +???+ note "Local emulation" + If you're running your code locally with [AWS SAM CLI](https://github.com/aws/aws-sam-cli){target="_blank"}, and not with your Python/IDE interpreter directly, this is not necessary. SAM CLI already brings the AWS SDK in its emulation image. + ### Lambda Layer [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. diff --git a/docs/utilities/parser.md b/docs/utilities/parser.md index 48c244c8df2..fd157482c2c 100644 --- a/docs/utilities/parser.md +++ b/docs/utilities/parser.md @@ -1,5 +1,5 @@ --- -title: Parser +title: Parser (Pydantic) description: Utility --- @@ -12,20 +12,25 @@ This utility provides data parsing and deep validation using [Pydantic](https:// * Built-in envelopes to unwrap, extend, and validate popular event sources payloads * Enforces type hints at runtime with user-friendly errors -**Extra dependency** +## Getting started -???+ warning +### Install + +!!! info "This is not necessary if you're installing Powertools via [Lambda Layer](../index.md#lambda-layer){target="_blank"}" + +Add `aws-lambda-powertools[parser]` as a dependency in your preferred tool: _e.g._, _requirements.txt_, _pyproject.toml_. +This will ensure you have the required dependencies before using Parser. + +???+ warning This will increase the compressed package size by >10MB due to the Pydantic dependency. To reduce the impact on the package size at the expense of 30%-50% of its performance [Pydantic can also be installed without binary files](https://pydantic-docs.helpmanual.io/install/#performance-vs-package-size-trade-off): - `SKIP_CYTHON=1 pip install --no-binary pydantic aws-lambda-powertools[pydantic]` + Pip example: `SKIP_CYTHON=1 pip install --no-binary pydantic aws-lambda-powertools[parser]` -Install parser's extra dependencies using **`pip install aws-lambda-powertools[pydantic]`**. - -## Defining models +### Defining models You can define models to parse incoming events by inheriting from `BaseModel`. @@ -47,11 +52,11 @@ class Order(BaseModel): These are simply Python classes that inherit from BaseModel. **Parser** enforces type hints declared in your model at runtime. -## Parsing events +### Parsing events You can parse inbound events using **event_parser** decorator, or the standalone `parse` function. Both are also able to parse either dictionary or JSON string as an input. -### event_parser decorator +#### event_parser decorator Use the decorator for fail fast scenarios where you want your Lambda function to raise an exception in the event of a malformed payload. @@ -104,7 +109,7 @@ handler(event=payload, context=LambdaContext()) handler(event=json.dumps(payload), context=LambdaContext()) # also works if event is a JSON string ``` -### parse function +#### parse function Use this standalone function when you want more control over the data validation process, for example returning a 400 error for malformed payloads. @@ -149,7 +154,7 @@ def my_function(): } ``` -## Built-in models +### Built-in models Parser comes with the following built-in models: @@ -172,7 +177,7 @@ Parser comes with the following built-in models: | **KafkaSelfManagedEventModel** | Lambda Event Source payload for self managed Kafka payload | | **KafkaMskEventModel** | Lambda Event Source payload for AWS MSK payload | -### Extending built-in models +#### Extending built-in models You can extend them to include your own models, and yet have all other known fields parsed along the way. @@ -251,7 +256,7 @@ for order_item in ret.detail.items: --8<-- "examples/parser/src/extending_built_in_models_with_json_validator.py" ``` -## Envelopes +### Envelopes When trying to parse your payloads wrapped in a known structure, you might encounter the following situations: @@ -309,7 +314,7 @@ def handler(event: UserModel, context: LambdaContext): 3. Parser parsed the original event against the EventBridge model 4. Parser then parsed the `detail` key using `UserModel` -### Built-in envelopes +#### Built-in envelopes Parser comes with the following built-in envelopes, where `Model` in the return section is your given model. @@ -328,7 +333,7 @@ Parser comes with the following built-in envelopes, where `Model` in the return | **LambdaFunctionUrlEnvelope** | 1. Parses data using `LambdaFunctionUrlModel`.
2. Parses `body` key using your model and returns it. | `Model` | | **KafkaEnvelope** | 1. Parses data using `KafkaRecordModel`.
2. Parses `value` key using your model and returns it. | `Model` | -### Bringing your own envelope +#### Bringing your own envelope You can create your own Envelope model and logic by inheriting from `BaseEnvelope`, and implementing the `parse` method. @@ -393,7 +398,7 @@ Here's a snippet of how the EventBridge envelope we demonstrated previously is i 3. Then, we parsed the incoming data with our envelope to confirm it matches EventBridge's structure defined in `EventBridgeModel` 4. Lastly, we call `_parse` from `BaseEnvelope` to parse the data in our envelope (.detail) using the customer model -## Data model validation +### Data model validation ???+ warning This is radically different from the **Validator utility** which validates events against JSON Schema. @@ -410,7 +415,7 @@ Keep the following in mind regardless of which decorator you end up using it: * You must raise either `ValueError`, `TypeError`, or `AssertionError` when value is not compliant * You must return the value(s) itself if compliant -### validating fields +#### validating fields Quick validation to verify whether the field `message` has the value of `hello world`. @@ -455,7 +460,7 @@ class HelloWorldModel(BaseModel): parse(model=HelloWorldModel, event={"message": "hello universe", "sender": "universe"}) ``` -### validating entire model +#### validating entire model `root_validator` can help when you have a complex validation mechanism. For example finding whether data has been omitted, comparing field values, etc. @@ -486,7 +491,7 @@ parse(model=UserModel, event=payload) ???+ info You can read more about validating list items, reusing validators, validating raw inputs, and a lot more in Pydantic's documentation. -## Advanced use cases +### Advanced use cases ???+ tip "Tip: Looking to auto-generate models from JSON, YAML, JSON Schemas, OpenApi, etc?" Use Koudai Aono's [data model code generation tool for Pydantic](https://github.com/koxudaxi/datamodel-code-generator) @@ -551,55 +556,3 @@ If what you're trying to use isn't available as part of the high level import sy ```python title="Pydantic import escape hatch" from aws_lambda_powertools.utilities.parser.pydantic import ``` - -**What is the cold start impact in bringing this additional dependency?** - -No significant cold start impact. It does increase the final uncompressed package by **71M**, when you bring the additional dependency that parser requires. - -Artillery load test sample against a [hello world sample](https://github.com/aws-samples/cookiecutter-aws-sam-python) using Tracer, Metrics, and Logger with and without parser. - -**No parser** - -???+ info - **Uncompressed package size**: 55M, **p99**: 180.3ms - -```javascript -Summary report @ 14:36:07(+0200) 2020-10-23 -Scenarios launched: 10 -Scenarios completed: 10 -Requests completed: 2000 -Mean response/sec: 114.81 -Response time (msec): - min: 54.9 - max: 1684.9 - median: 68 - p95: 109.1 - p99: 180.3 -Scenario counts: - 0: 10 (100%) -Codes: - 200: 2000 -``` - -**With parser** - -???+ info - **Uncompressed package size**: 128M, **p99**: 193.1ms - -```javascript -Summary report @ 14:29:23(+0200) 2020-10-23 -Scenarios launched: 10 -Scenarios completed: 10 -Requests completed: 2000 -Mean response/sec: 111.67 -Response time (msec): - min: 54.3 - max: 1887.2 - median: 66.1 - p95: 113.3 - p99: 193.1 -Scenario counts: - 0: 10 (100%) -Codes: - 200: 2000 -``` diff --git a/docs/utilities/validation.md b/docs/utilities/validation.md index 3b61fececd4..43086c3d2d5 100644 --- a/docs/utilities/validation.md +++ b/docs/utilities/validation.md @@ -18,6 +18,14 @@ This utility provides JSON Schema validation for events and responses, including ???+ tip All examples shared in this documentation are available within the [project repository](https://github.com/awslabs/aws-lambda-powertools-python/tree/develop/examples){target="_blank"}. +### Install + +!!! info "This is not necessary if you're installing Powertools via [Lambda Layer](../index.md#lambda-layer){target="_blank"}" + +Add `aws-lambda-powertools[validation]` as a dependency in your preferred tool: _e.g._, _requirements.txt_, _pyproject.toml_. + +This will ensure you have the required dependencies before using Validation. + You can validate inbound and outbound events using [`validator` decorator](#validator-decorator). You can also use the standalone `validate` function, if you want more control over the validation process such as handling a validation error. From 79a676f10553d63be8ad258846056c8b6b1def48 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 11 Oct 2022 09:48:25 +0200 Subject: [PATCH 14/30] refactor(e2e): fix idempotency typing --- tests/e2e/idempotency/infrastructure.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/e2e/idempotency/infrastructure.py b/tests/e2e/idempotency/infrastructure.py index 997cadc4943..220263d0116 100644 --- a/tests/e2e/idempotency/infrastructure.py +++ b/tests/e2e/idempotency/infrastructure.py @@ -1,7 +1,8 @@ -from typing import Any +from typing import Dict from aws_cdk import CfnOutput, RemovalPolicy from aws_cdk import aws_dynamodb as dynamodb +from aws_cdk.aws_lambda import Function from tests.e2e.utils.infrastructure import BaseInfrastructure @@ -9,9 +10,9 @@ class IdempotencyDynamoDBStack(BaseInfrastructure): def create_resources(self): functions = self.create_lambda_functions() - self._create_dynamodb_table(function=functions) + self._create_dynamodb_table(functions=functions) - def _create_dynamodb_table(self, function: Any): + def _create_dynamodb_table(self, functions: Dict[str, Function]): table = dynamodb.Table( self.stack, "Idempotency", @@ -22,8 +23,8 @@ def _create_dynamodb_table(self, function: Any): billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST, ) - table.grant_read_write_data(function["TtlCacheExpirationHandler"]) - table.grant_read_write_data(function["TtlCacheTimeoutHandler"]) - table.grant_read_write_data(function["ParallelExecutionHandler"]) + table.grant_read_write_data(functions["TtlCacheExpirationHandler"]) + table.grant_read_write_data(functions["TtlCacheTimeoutHandler"]) + table.grant_read_write_data(functions["ParallelExecutionHandler"]) CfnOutput(self.stack, "DynamoDBTable", value=table.table_name) From b31786da2aa1adc5b38e3ff9974a0791e4cc8dfa Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 11 Oct 2022 10:32:32 +0200 Subject: [PATCH 15/30] refactor(e2e): make table name dynamic --- .../handlers/parallel_execution_handler.py | 4 +++- .../handlers/ttl_cache_expiration_handler.py | 4 +++- .../handlers/ttl_cache_timeout_handler.py | 4 +++- tests/e2e/idempotency/infrastructure.py | 23 ++++++++++--------- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/tests/e2e/idempotency/handlers/parallel_execution_handler.py b/tests/e2e/idempotency/handlers/parallel_execution_handler.py index 401097d4194..1cba7409adc 100644 --- a/tests/e2e/idempotency/handlers/parallel_execution_handler.py +++ b/tests/e2e/idempotency/handlers/parallel_execution_handler.py @@ -1,8 +1,10 @@ +import os import time from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent -persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") +TABLE_NAME = os.getenv("IdempotencyTable", "") +persistence_layer = DynamoDBPersistenceLayer(table_name=TABLE_NAME) @idempotent(persistence_store=persistence_layer) diff --git a/tests/e2e/idempotency/handlers/ttl_cache_expiration_handler.py b/tests/e2e/idempotency/handlers/ttl_cache_expiration_handler.py index eabf11e7852..30c3e279adf 100644 --- a/tests/e2e/idempotency/handlers/ttl_cache_expiration_handler.py +++ b/tests/e2e/idempotency/handlers/ttl_cache_expiration_handler.py @@ -1,8 +1,10 @@ +import os import time from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, IdempotencyConfig, idempotent -persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") +TABLE_NAME = os.getenv("IdempotencyTable", "") +persistence_layer = DynamoDBPersistenceLayer(table_name=TABLE_NAME) config = IdempotencyConfig(expires_after_seconds=20) diff --git a/tests/e2e/idempotency/handlers/ttl_cache_timeout_handler.py b/tests/e2e/idempotency/handlers/ttl_cache_timeout_handler.py index 4de97a4afe4..82ad024df53 100644 --- a/tests/e2e/idempotency/handlers/ttl_cache_timeout_handler.py +++ b/tests/e2e/idempotency/handlers/ttl_cache_timeout_handler.py @@ -1,8 +1,10 @@ +import os import time from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, IdempotencyConfig, idempotent -persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") +TABLE_NAME = os.getenv("IdempotencyTable", "") +persistence_layer = DynamoDBPersistenceLayer(table_name=TABLE_NAME) config = IdempotencyConfig(expires_after_seconds=1) diff --git a/tests/e2e/idempotency/infrastructure.py b/tests/e2e/idempotency/infrastructure.py index 220263d0116..abe69f6a5e6 100644 --- a/tests/e2e/idempotency/infrastructure.py +++ b/tests/e2e/idempotency/infrastructure.py @@ -1,30 +1,31 @@ -from typing import Dict - from aws_cdk import CfnOutput, RemovalPolicy from aws_cdk import aws_dynamodb as dynamodb -from aws_cdk.aws_lambda import Function +from aws_cdk.aws_dynamodb import Table from tests.e2e.utils.infrastructure import BaseInfrastructure class IdempotencyDynamoDBStack(BaseInfrastructure): def create_resources(self): - functions = self.create_lambda_functions() - self._create_dynamodb_table(functions=functions) + table = self._create_dynamodb_table() + + env_vars = {"IdempotencyTable": table.table_name} + functions = self.create_lambda_functions(function_props={"environment": env_vars}) + + table.grant_read_write_data(functions["TtlCacheExpirationHandler"]) + table.grant_read_write_data(functions["TtlCacheTimeoutHandler"]) + table.grant_read_write_data(functions["ParallelExecutionHandler"]) - def _create_dynamodb_table(self, functions: Dict[str, Function]): + def _create_dynamodb_table(self) -> Table: table = dynamodb.Table( self.stack, "Idempotency", - table_name="IdempotencyTable", removal_policy=RemovalPolicy.DESTROY, partition_key=dynamodb.Attribute(name="id", type=dynamodb.AttributeType.STRING), time_to_live_attribute="expiration", billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST, ) - table.grant_read_write_data(functions["TtlCacheExpirationHandler"]) - table.grant_read_write_data(functions["TtlCacheTimeoutHandler"]) - table.grant_read_write_data(functions["ParallelExecutionHandler"]) - CfnOutput(self.stack, "DynamoDBTable", value=table.table_name) + + return table From f63d437f0726ddef4a19932f9bec540dc1d84bc9 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Tue, 11 Oct 2022 15:00:39 +0100 Subject: [PATCH 16/30] bug(v2/idempotency-e2e): fixing e2e tests --- .../idempotency/handlers/parallel_execution_handler.py | 2 +- .../idempotency/handlers/ttl_cache_expiration_handler.py | 2 +- tests/e2e/idempotency/test_idempotency_dynamodb.py | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/e2e/idempotency/handlers/parallel_execution_handler.py b/tests/e2e/idempotency/handlers/parallel_execution_handler.py index 1cba7409adc..764ca1385a6 100644 --- a/tests/e2e/idempotency/handlers/parallel_execution_handler.py +++ b/tests/e2e/idempotency/handlers/parallel_execution_handler.py @@ -10,6 +10,6 @@ @idempotent(persistence_store=persistence_layer) def lambda_handler(event, context): - time.sleep(10) + time.sleep(5) return event diff --git a/tests/e2e/idempotency/handlers/ttl_cache_expiration_handler.py b/tests/e2e/idempotency/handlers/ttl_cache_expiration_handler.py index 30c3e279adf..4d8b194657e 100644 --- a/tests/e2e/idempotency/handlers/ttl_cache_expiration_handler.py +++ b/tests/e2e/idempotency/handlers/ttl_cache_expiration_handler.py @@ -5,7 +5,7 @@ TABLE_NAME = os.getenv("IdempotencyTable", "") persistence_layer = DynamoDBPersistenceLayer(table_name=TABLE_NAME) -config = IdempotencyConfig(expires_after_seconds=20) +config = IdempotencyConfig(expires_after_seconds=5) @idempotent(config=config, persistence_store=persistence_layer) diff --git a/tests/e2e/idempotency/test_idempotency_dynamodb.py b/tests/e2e/idempotency/test_idempotency_dynamodb.py index 19369b141db..87b61d285ec 100644 --- a/tests/e2e/idempotency/test_idempotency_dynamodb.py +++ b/tests/e2e/idempotency/test_idempotency_dynamodb.py @@ -29,7 +29,7 @@ def idempotency_table_name(infrastructure: dict) -> str: def test_ttl_caching_expiration_idempotency(ttl_cache_expiration_handler_fn_arn: str): # GIVEN - payload = json.dumps({"message": "Lambda Powertools - TTL 20s"}) + payload = json.dumps({"message": "Lambda Powertools - TTL 5s"}) # WHEN # first execution @@ -44,8 +44,8 @@ def test_ttl_caching_expiration_idempotency(ttl_cache_expiration_handler_fn_arn: ) second_execution_response = second_execution["Payload"].read().decode("utf-8") - # wait 20s to expire ttl and execute again, this should return a new response value - sleep(20) + # wait 8s to expire ttl and execute again, this should return a new response value + sleep(8) third_execution, _ = data_fetcher.get_lambda_response( lambda_arn=ttl_cache_expiration_handler_fn_arn, payload=payload ) @@ -58,7 +58,7 @@ def test_ttl_caching_expiration_idempotency(ttl_cache_expiration_handler_fn_arn: def test_ttl_caching_timeout_idempotency(ttl_cache_timeout_handler_fn_arn: str): # GIVEN - payload_timeout_execution = json.dumps({"sleep": 10, "message": "Lambda Powertools - TTL 1s"}) + payload_timeout_execution = json.dumps({"sleep": 5, "message": "Lambda Powertools - TTL 1s"}) payload_working_execution = json.dumps({"sleep": 0, "message": "Lambda Powertools - TTL 1s"}) # WHEN From 0464f46b3370c6b4b79e2d2d9fb5eac88440dda8 Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Thu, 13 Oct 2022 15:38:00 +0200 Subject: [PATCH 17/30] feat(layer): publish SAR v2 via Github actions (#1585) Co-authored-by: Heitor Lessa --- .github/workflows/publish_v2_layer.yml | 30 ++++- .../reusable_deploy_v2_layer_stack.yml | 4 +- .github/workflows/reusable_deploy_v2_sar.yml | 117 +++++++++++++++++- layer/app.py | 4 +- layer/poetry.lock | 93 +++++++++----- layer/pyproject.toml | 3 +- layer/sar/template.txt | 38 ++++++ 7 files changed, 243 insertions(+), 46 deletions(-) create mode 100644 layer/sar/template.txt diff --git a/.github/workflows/publish_v2_layer.yml b/.github/workflows/publish_v2_layer.yml index 469a94ad876..de04566c79e 100644 --- a/.github/workflows/publish_v2_layer.yml +++ b/.github/workflows/publish_v2_layer.yml @@ -8,8 +8,7 @@ on: workflow_dispatch: inputs: latest_published_version: - description: "Latest PyPi published version to rebuild latest docs for, e.g. v1.22.0" - default: "v2.0.0" + description: "Latest PyPi published version to rebuild latest docs for, e.g. v2.0.0" required: true # workflow_run: # workflows: ["Publish to PyPi"] @@ -23,6 +22,8 @@ jobs: defaults: run: working-directory: ./layer + outputs: + release-tag-version: ${{ steps.release-notes-tag.outputs.RELEASE_TAG_VERSION }} steps: - name: checkout uses: actions/checkout@v3 @@ -46,11 +47,12 @@ jobs: poetry export --format requirements.txt --output requirements.txt pip install -r requirements.txt - name: Set release notes tag + id: release-notes-tag run: | RELEASE_INPUT=${{ inputs.latest_published_version }} LATEST_TAG=$(git describe --tag --abbrev=0) RELEASE_TAG_VERSION=${RELEASE_INPUT:-$LATEST_TAG} - echo RELEASE_TAG_VERSION="${RELEASE_TAG_VERSION:1}" >> "$GITHUB_ENV" + echo RELEASE_TAG_VERSION="${RELEASE_TAG_VERSION:1}" >> "$GITHUB_OUTPUT" - name: Set up QEMU uses: docker/setup-qemu-action@8b122486cedac8393e77aa9734c3528886e4a1a8 # v2.0.0 # NOTE: we need QEMU to build Layer against a different architecture (e.g., ARM) @@ -62,7 +64,7 @@ jobs: npm install -g aws-cdk@2.44.0 cdk --version - name: CDK build - run: cdk synth --context version="$RELEASE_TAG_VERSION" -o cdk.out + run: cdk synth --context version="${{ steps.release-notes-tag.outputs.RELEASE_TAG_VERSION }}" -o cdk.out - name: zip output run: zip -r cdk.out.zip cdk.out - name: Archive CDK artifacts @@ -90,3 +92,23 @@ jobs: # stage: "PROD" # artefact-name: "cdk-layer-artefact" # environment: "layer-prod" + + deploy-sar-beta: + needs: build-layer + uses: ./.github/workflows/reusable_deploy_v2_sar.yml + secrets: inherit + with: + stage: "BETA" + artefact-name: "cdk-layer-artefact" + environment: "layer-beta" + package-version: ${{ needs.build-layer.outputs.release-tag-version }} + + deploy-sar-prod: + needs: deploy-sar-beta + uses: ./.github/workflows/reusable_deploy_v2_sar.yml + secrets: inherit + with: + stage: "PROD" + artefact-name: "cdk-layer-artefact" + environment: "layer-prod" + package-version: ${{ needs.build-layer.outputs.release-tag-version }} diff --git a/.github/workflows/reusable_deploy_v2_layer_stack.yml b/.github/workflows/reusable_deploy_v2_layer_stack.yml index 8c4d45c7708..1a5eb83cd23 100644 --- a/.github/workflows/reusable_deploy_v2_layer_stack.yml +++ b/.github/workflows/reusable_deploy_v2_layer_stack.yml @@ -97,6 +97,6 @@ jobs: - name: unzip artefact run: unzip cdk.out.zip - name: CDK Deploy Layer - run: cdk deploy --app cdk.out --context region=${{ matrix.region }} 'LayerStack' --require-approval never --verbose + run: cdk deploy --app cdk.out --context region=${{ matrix.region }} 'LayerV2Stack' --require-approval never --verbose - name: CDK Deploy Canary - run: cdk deploy --app cdk.out --context region=${{ matrix.region}} --parameters DeployStage="${{ inputs.stage }}" 'CanaryStack' --require-approval never --verbose + run: cdk deploy --app cdk.out --context region=${{ matrix.region}} --parameters DeployStage="${{ inputs.stage }}" 'CanaryV2Stack' --require-approval never --verbose diff --git a/.github/workflows/reusable_deploy_v2_sar.yml b/.github/workflows/reusable_deploy_v2_sar.yml index 905fd20d5d1..acb28179efe 100644 --- a/.github/workflows/reusable_deploy_v2_sar.yml +++ b/.github/workflows/reusable_deploy_v2_sar.yml @@ -1,5 +1,24 @@ name: Deploy V2 SAR +# SAR deployment process +# +# 1. This workflow starts after the layer artifact is produced on `publish_v2_layer` +# 2. We use the same layer artifact to ensure the SAR app is consistent with the published Lambda Layer +# 3. We publish the SAR for both x86_64 and arm64 (see `matrix` section) +# 4. We use `sam package` and `sam publish` to publish the SAR app +# 5. We remove the previous Canary stack (if present) and deploy a new one to test the SAR App. We retain the Canary in the account for debugging purposes +# 6. Finally the published SAR app is made public on the PROD environment + +permissions: + id-token: write + contents: read + +env: + NODE_VERSION: 16.12 + AWS_REGION: eu-west-1 + SAR_NAME: aws-lambda-powertools-python-layer-v2 + TEST_STACK_NAME: serverlessrepo-v2-powertools-layer-test-stack + on: workflow_call: inputs: @@ -21,8 +40,100 @@ on: type: string jobs: - dummy: + deploy-sar-app: runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + strategy: + matrix: + architecture: ["x86_64", "arm64"] steps: - - name: Hello world - run: echo "hello world" + - name: Checkout + uses: actions/checkout@v3 + - name: AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-region: ${{ env.AWS_REGION }} + role-to-assume: ${{ secrets.AWS_LAYERS_ROLE_ARN }} + - name: AWS credentials SAR role + uses: aws-actions/configure-aws-credentials@v1 + id: aws-credentials-sar-role + with: + aws-access-key-id: ${{ env.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ env.AWS_SECRET_ACCESS_KEY }} + aws-session-token: ${{ env.AWS_SESSION_TOKEN }} + role-duration-seconds: 1200 + aws-region: ${{ env.AWS_REGION }} + role-to-assume: ${{ secrets.AWS_SAR_V2_ROLE_ARN }} + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_VERSION }} + - name: Download artifact + uses: actions/download-artifact@v3 + with: + name: ${{ inputs.artefact-name }} + - name: Unzip artefact + run: unzip cdk.out.zip + - name: Configure SAR name + run: | + if [[ "${{ inputs.stage }}" == "BETA" ]]; then + SAR_NAME="test-${SAR_NAME}" + fi + echo SAR_NAME="${SAR_NAME}" >> "$GITHUB_ENV" + - name: Adds arm64 suffix to SAR name + if: ${{ matrix.architecture == 'arm64' }} + run: echo SAR_NAME="${SAR_NAME}-arm64" >> "$GITHUB_ENV" + - name: Deploy SAR + run: | + # From the generated LayerStack cdk.out artifact, find the layer asset path for the correct architecture. + # We'll use this as the source directory of our SAR. This way we are re-using the same layer asset for our SAR. + asset=$(jq -jc '.Resources[] | select(.Properties.CompatibleArchitectures == ["${{ matrix.architecture }}"]) | .Metadata."aws:asset:path"' cdk.out/LayerV2Stack.template.json) + + # fill in the SAR SAM template + sed -e "s||${{ inputs.package-version }}|g" -e "s//${{ env.SAR_NAME }}/g" -e "s||./cdk.out/$asset|g" layer/sar/template.txt > template.yml + + # SAR needs a README and a LICENSE, so just copy the ones from the repo + cp README.md LICENSE "./cdk.out/$asset/" + + # Package the SAR to our SAR S3 bucket, and publish it + sam package --template-file template.yml --output-template-file packaged.yml --s3-bucket ${{ secrets.AWS_SAR_S3_BUCKET }} + sam publish --template packaged.yml --region "$AWS_REGION" + - name: Deploy BETA canary + if: ${{ inputs.stage == 'BETA' }} + run: | + if [[ "${{ matrix.architecture }}" == "arm64" ]]; then + TEST_STACK_NAME="${TEST_STACK_NAME}-arm64" + fi + + echo "Check if stack does not exist" + stack_exists=$(aws cloudformation list-stacks --query "StackSummaries[?(StackName == '$TEST_STACK_NAME' && StackStatus == 'CREATE_COMPLETE')].{StackId:StackId, StackName:StackName, CreationTime:CreationTime, StackStatus:StackStatus}" --output text) + + if [[ -n "$stack_exists" ]] ; then + echo "Found test deployment stack, removing..." + aws cloudformation delete-stack --stack-name "$TEST_STACK_NAME" + aws cloudformation wait stack-delete-complete --stack-name "$TEST_STACK_NAME" + fi + + echo "Creating canary stack" + echo "Stack name: $TEST_STACK_NAME" + aws serverlessrepo create-cloud-formation-change-set --application-id arn:aws:serverlessrepo:${{ env.AWS_REGION }}:${{ steps.aws-credentials-sar-role.outputs.aws-account-id }}:applications/${{ env.SAR_NAME }} --stack-name "${TEST_STACK_NAME/serverlessrepo-/}" --capabilities CAPABILITY_NAMED_IAM + CHANGE_SET_ID=$(aws cloudformation list-change-sets --stack-name "$TEST_STACK_NAME" --query 'Summaries[*].ChangeSetId' --output text) + aws cloudformation wait change-set-create-complete --change-set-name "$CHANGE_SET_ID" + aws cloudformation execute-change-set --change-set-name "$CHANGE_SET_ID" + aws cloudformation wait stack-create-complete --stack-name "$TEST_STACK_NAME" + echo "Waiting until stack deployment completes..." + + echo "Exit with error if stack is not in CREATE_COMPLETE" + stack_exists=$(aws cloudformation list-stacks --query "StackSummaries[?(StackName == '$TEST_STACK_NAME' && StackStatus == 'CREATE_COMPLETE')].{StackId:StackId, StackName:StackName, CreationTime:CreationTime, StackStatus:StackStatus}") + if [[ -z "$stack_exists" ]] ; then + echo "Could find successful deployment, exit error..." + exit 1 + fi + echo "Deployment successful" + - name: Publish SAR + if: ${{ inputs.stage == 'PROD' }} + run: | + # wait until SAR registers the app, otherwise it fails to make it public + sleep 15 + echo "Make SAR app public" + aws serverlessrepo put-application-policy --application-id arn:aws:serverlessrepo:${{ env.AWS_REGION }}:${{ steps.aws-credentials-sar-role.outputs.aws-account-id }}:applications/${{ env.SAR_NAME }} --statements Principals='*',Actions=Deploy diff --git a/layer/app.py b/layer/app.py index e44b05d453d..59a35dfd300 100644 --- a/layer/app.py +++ b/layer/app.py @@ -19,7 +19,7 @@ LayerStack( app, - "LayerStack", + "LayerV2Stack", powertools_version=POWERTOOLS_VERSION, ssm_paramter_layer_arn=SSM_PARAM_LAYER_ARN, ssm_parameter_layer_arm64_arn=SSM_PARAM_LAYER_ARM64_ARN, @@ -27,7 +27,7 @@ CanaryStack( app, - "CanaryStack", + "CanaryV2Stack", powertools_version=POWERTOOLS_VERSION, ssm_paramter_layer_arn=SSM_PARAM_LAYER_ARN, ssm_parameter_layer_arm64_arn=SSM_PARAM_LAYER_ARM64_ARN, diff --git a/layer/poetry.lock b/layer/poetry.lock index 6720fc5b96a..d0fd4d0a37b 100644 --- a/layer/poetry.lock +++ b/layer/poetry.lock @@ -7,10 +7,10 @@ optional = false python-versions = ">=3.5" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] -docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "cloudpickle"] +dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] +docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] +tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] +tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] [[package]] name = "aws-cdk-lib" @@ -28,14 +28,14 @@ typeguard = ">=2.13.3,<2.14.0" [[package]] name = "boto3" -version = "1.24.88" +version = "1.24.89" description = "The AWS SDK for Python" category = "dev" optional = false python-versions = ">= 3.7" [package.dependencies] -botocore = ">=1.27.88,<1.28.0" +botocore = ">=1.27.89,<1.28.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.6.0,<0.7.0" @@ -44,7 +44,7 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.27.88" +version = "1.27.89" description = "Low-level, data-driven core of boto 3." category = "dev" optional = false @@ -72,7 +72,7 @@ exceptiongroup = {version = "*", markers = "python_version <= \"3.10\""} [[package]] name = "cdk-aws-lambda-powertools-layer" -version = "3.1.0" +version = "3.2.0" description = "A lambda layer for AWS Powertools for python and typescript" category = "main" optional = false @@ -95,7 +95,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "constructs" -version = "10.1.124" +version = "10.1.128" description = "A programming model for software-defined state" category = "main" optional = false @@ -169,8 +169,8 @@ optional = false python-versions = ">=3.6" [package.extras] -testing = ["pytest-benchmark", "pytest"] -dev = ["tox", "pre-commit"] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] [[package]] name = "publication" @@ -197,7 +197,7 @@ optional = false python-versions = ">=3.6.8" [package.extras] -diagrams = ["railroad-diagrams", "jinja2"] +diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" @@ -269,8 +269,8 @@ optional = false python-versions = ">=3.5.3" [package.extras] -test = ["mypy", "typing-extensions", "pytest"] doc = ["sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["mypy", "pytest", "typing-extensions"] [[package]] name = "typing-extensions" @@ -289,14 +289,14 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" [package.extras] -brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] -secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "cceb24edf99719274b946c811ad41ebbbdc0181593d4f07555c0977767cdd19a" +content-hash = "10a4e60fe1abbe982f077699767b8a7949b2be5ca82f909647f34d1e30ffb9a9" [metadata.files] attrs = [ @@ -308,28 +308,37 @@ aws-cdk-lib = [ {file = "aws_cdk_lib-2.45.0-py3-none-any.whl", hash = "sha256:9463fe6d84563c4c23ae96810be0ea0ff0a260eebb85a4a7afe0c3747eca18a8"}, ] boto3 = [ - {file = "boto3-1.24.88-py3-none-any.whl", hash = "sha256:6b4cf1cd0be65202c4cf0e4c69099bac3620bcd4049ca25a5e223c668401dd69"}, - {file = "boto3-1.24.88.tar.gz", hash = "sha256:93934343cac76084600a520e5be70c52152364d0c410681c2e25c2290f0e151c"}, + {file = "boto3-1.24.89-py3-none-any.whl", hash = "sha256:346f8f0d101a4261dac146a959df18d024feda6431e1d9d84f94efd24d086cae"}, + {file = "boto3-1.24.89.tar.gz", hash = "sha256:d0d8ffcdc10821c4562bc7f935cdd840033bbc342ac0e14b6bdd348b3adf4c04"}, ] botocore = [ - {file = "botocore-1.27.88-py3-none-any.whl", hash = "sha256:de4e087b24cd3bc369eb2e27f8fe94a6499f7dea08c919fba13cefb2496bd2bb"}, - {file = "botocore-1.27.88.tar.gz", hash = "sha256:ded0a4035baf91eb358ef501c92a8512543f5ab7658f459df3077a70a555b5cd"}, + {file = "botocore-1.27.89-py3-none-any.whl", hash = "sha256:238f1dfdb8d8d017c2aea082609a3764f3161d32745900f41bcdcf290d95a048"}, + {file = "botocore-1.27.89.tar.gz", hash = "sha256:621f5413be8f97712b7e36c1b075a8791d1d1b9971a7ee060cdcdf5e2debf6c1"}, +] +cattrs = [ + {file = "cattrs-22.1.0-py3-none-any.whl", hash = "sha256:d55c477b4672f93606e992049f15d526dc7867e6c756cd6256d4af92e2b1e364"}, + {file = "cattrs-22.1.0.tar.gz", hash = "sha256:94b67b64cf92c994f8784c40c082177dc916e0489a73a9a36b24eb18a9db40c6"}, ] -cattrs = [] cdk-aws-lambda-powertools-layer = [ - {file = "cdk-aws-lambda-powertools-layer-3.1.0.tar.gz", hash = "sha256:1e1457edbebfbb62ab2d21ae0f9b991db1b6c8508730fab8c99c3a7d3dfd99cb"}, - {file = "cdk_aws_lambda_powertools_layer-3.1.0-py3-none-any.whl", hash = "sha256:c66cc722c924a50458418600df4060a1396e134df25b3cc85de59d5914541b31"}, + {file = "cdk-aws-lambda-powertools-layer-3.2.0.tar.gz", hash = "sha256:75b86a6c8714c82293d754f1d799134c4159953711312e261f8b3aaf77492fa6"}, + {file = "cdk_aws_lambda_powertools_layer-3.2.0-py3-none-any.whl", hash = "sha256:a293a2f42b459de70ccd9d2a16b0b0789f7c682aa31ab80d6696e93ff07caa92"}, +] +colorama = [ + {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, + {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, ] -colorama = [] constructs = [ - {file = "constructs-10.1.124-py3-none-any.whl", hash = "sha256:76ef2e6776cfdd1c4131a18b0e83c1b154d462b3ebb37ddbeaf3043b3b0de301"}, - {file = "constructs-10.1.124.tar.gz", hash = "sha256:5e4bfcca275867e5cfb4636ef65ed334c861a816f287ce1136f082ad0e0c1c2c"}, + {file = "constructs-10.1.128-py3-none-any.whl", hash = "sha256:d6fbc88de4c2517b59e28a9d0bc3663e75decbe3464030b5bc53809868b52c9e"}, + {file = "constructs-10.1.128.tar.gz", hash = "sha256:6789412823ae27b39f659537337f688a9d555cad5845d4b821c7be02a061be1e"}, ] exceptiongroup = [ {file = "exceptiongroup-1.0.0rc9-py3-none-any.whl", hash = "sha256:2e3c3fc1538a094aab74fad52d6c33fc94de3dfee3ee01f187c0e0c72aec5337"}, {file = "exceptiongroup-1.0.0rc9.tar.gz", hash = "sha256:9086a4a21ef9b31c72181c77c040a074ba0889ee56a7b289ff0afb0d97655f96"}, ] -iniconfig = [] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] jmespath = [ {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, @@ -338,16 +347,34 @@ jsii = [ {file = "jsii-1.69.0-py3-none-any.whl", hash = "sha256:f3ae5cdf5e854b4d59256dc1f8818cd3fabb8eb43fbd3134a8e8aef962643005"}, {file = "jsii-1.69.0.tar.gz", hash = "sha256:7c7ed2a913372add17d63322a640c6435324770eb78c6b89e4c701e07d9c84db"}, ] -packaging = [] -pluggy = [] -publication = [] -py = [] -pyparsing = [] +packaging = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] +pluggy = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] +publication = [ + {file = "publication-0.0.3-py2.py3-none-any.whl", hash = "sha256:0248885351febc11d8a1098d5c8e3ab2dabcf3e8c0c96db1e17ecd12b53afbe6"}, + {file = "publication-0.0.3.tar.gz", hash = "sha256:68416a0de76dddcdd2930d1c8ef853a743cc96c82416c4e4d3b5d901c6276dc4"}, +] +py = [ + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +] +pyparsing = [ + {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, + {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, +] pytest = [ {file = "pytest-7.1.3-py3-none-any.whl", hash = "sha256:1377bda3466d70b55e3f5cecfa55bb7cfcf219c7964629b967c37cf0bda818b7"}, {file = "pytest-7.1.3.tar.gz", hash = "sha256:4f365fec2dff9c1162f834d9f18af1ba13062db0c708bf7b946f8a5c76180c39"}, ] -python-dateutil = [] +python-dateutil = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] s3transfer = [ {file = "s3transfer-0.6.0-py3-none-any.whl", hash = "sha256:06176b74f3a15f61f1b4f25a1fc29a4429040b7647133a463da8fa5bd28d5ecd"}, {file = "s3transfer-0.6.0.tar.gz", hash = "sha256:2ed07d3866f523cc561bf4a00fc5535827981b117dd7876f036b0c1aca42c947"}, diff --git a/layer/pyproject.toml b/layer/pyproject.toml index 4b0cada589f..e4caa660565 100644 --- a/layer/pyproject.toml +++ b/layer/pyproject.toml @@ -7,8 +7,7 @@ license = "MIT" [tool.poetry.dependencies] python = "^3.9" -cdk-aws-lambda-powertools-layer = "^3.1.0" -aws-cdk-lib = "^2.44.0" +cdk-aws-lambda-powertools-layer = "^3.2.0" [tool.poetry.dev-dependencies] pytest = "^7.1.2" diff --git a/layer/sar/template.txt b/layer/sar/template.txt new file mode 100644 index 00000000000..808e9d7a36a --- /dev/null +++ b/layer/sar/template.txt @@ -0,0 +1,38 @@ +AWSTemplateFormatVersion: '2010-09-09' + +Metadata: + AWS::ServerlessRepo::Application: + Name: + Description: "AWS Lambda Layer for aws-lambda-powertools " + Author: AWS + SpdxLicenseId: Apache-2.0 + LicenseUrl: /LICENSE + ReadmeUrl: /README.md + Labels: ['layer','lambda','powertools','python', 'aws'] + HomePageUrl: https://github.com/awslabs/aws-lambda-powertools-python + SemanticVersion: + SourceCodeUrl: https://github.com/awslabs/aws-lambda-powertools-python + +Transform: AWS::Serverless-2016-10-31 +Description: AWS Lambda Layer for aws-lambda-powertools with python 3.9, 3.8 or 3.7 + +Resources: + LambdaLayer: + Type: AWS::Serverless::LayerVersion + Properties: + Description: "AWS Lambda Layer for aws-lambda-powertools version " + LayerName: + ContentUri: + CompatibleRuntimes: + - python3.9 + - python3.8 + - python3.7 + LicenseInfo: 'Available under the Apache-2.0 license.' + RetentionPolicy: Retain + +Outputs: + LayerVersionArn: + Description: ARN for the published Layer version + Value: !Ref LambdaLayer + Export: + Name: !Sub 'LayerVersionArn-${AWS::StackName}' From cc18880438dfb9600abcede617b2d175c2116a9d Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Fri, 14 Oct 2022 17:38:23 +0100 Subject: [PATCH 18/30] feat(parameters): migrate AppConfig to new APIs due to API deprecation (#1553) Co-authored-by: Heitor Lessa --- .../utilities/parameters/appconfig.py | 46 ++++---- .../utilities/parameters/base.py | 4 +- docs/upgrade.md | 7 ++ docs/utilities/feature_flags.md | 11 +- docs/utilities/parameters.md | 17 +-- poetry.lock | 31 ++++-- pyproject.toml | 1 + tests/e2e/parameters/__init__.py | 0 tests/e2e/parameters/conftest.py | 19 ++++ .../parameter_appconfig_freeform_handler.py | 11 ++ tests/e2e/parameters/infrastructure.py | 105 ++++++++++++++++++ tests/e2e/parameters/test_appconfig.py | 61 ++++++++++ tests/functional/test_utilities_parameters.py | 46 ++++++-- 13 files changed, 306 insertions(+), 53 deletions(-) create mode 100644 tests/e2e/parameters/__init__.py create mode 100644 tests/e2e/parameters/conftest.py create mode 100644 tests/e2e/parameters/handlers/parameter_appconfig_freeform_handler.py create mode 100644 tests/e2e/parameters/infrastructure.py create mode 100644 tests/e2e/parameters/test_appconfig.py diff --git a/aws_lambda_powertools/utilities/parameters/appconfig.py b/aws_lambda_powertools/utilities/parameters/appconfig.py index 380e355d673..a3a340a62be 100644 --- a/aws_lambda_powertools/utilities/parameters/appconfig.py +++ b/aws_lambda_powertools/utilities/parameters/appconfig.py @@ -5,20 +5,17 @@ import os from typing import TYPE_CHECKING, Any, Dict, Optional, Union -from uuid import uuid4 import boto3 from botocore.config import Config if TYPE_CHECKING: - from mypy_boto3_appconfig import AppConfigClient + from mypy_boto3_appconfigdata import AppConfigDataClient from ...shared import constants from ...shared.functions import resolve_env_var_choice from .base import DEFAULT_MAX_AGE_SECS, DEFAULT_PROVIDERS, BaseProvider -CLIENT_ID = str(uuid4()) - class AppConfigProvider(BaseProvider): """ @@ -34,8 +31,8 @@ class AppConfigProvider(BaseProvider): Botocore configuration to pass during client initialization boto3_session : boto3.session.Session, optional Boto3 session to create a boto3_client from - boto3_client: AppConfigClient, optional - Boto3 AppConfig Client to use, boto3_session will be ignored if both are provided + boto3_client: AppConfigDataClient, optional + Boto3 AppConfigData Client to use, boto3_session will be ignored if both are provided Example ------- @@ -73,7 +70,7 @@ def __init__( application: Optional[str] = None, config: Optional[Config] = None, boto3_session: Optional[boto3.session.Session] = None, - boto3_client: Optional["AppConfigClient"] = None, + boto3_client: Optional["AppConfigDataClient"] = None, ): """ Initialize the App Config client @@ -81,8 +78,8 @@ def __init__( super().__init__() - self.client: "AppConfigClient" = self._build_boto3_client( - service_name="appconfig", client=boto3_client, session=boto3_session, config=config + self.client: "AppConfigDataClient" = self._build_boto3_client( + service_name="appconfigdata", client=boto3_client, session=boto3_session, config=config ) self.application = resolve_env_var_choice( @@ -91,6 +88,9 @@ def __init__( self.environment = environment self.current_version = "" + self._next_token = "" # nosec - token for get_latest_configuration executions + self.last_returned_value = "" + def _get(self, name: str, **sdk_options) -> str: """ Retrieve a parameter value from AWS App config. @@ -100,16 +100,26 @@ def _get(self, name: str, **sdk_options) -> str: name: str Name of the configuration sdk_options: dict, optional - Dictionary of options that will be passed to the client's get_configuration API call + SDK options to propagate to `start_configuration_session` API call """ + if not self._next_token: + sdk_options["ConfigurationProfileIdentifier"] = name + sdk_options["ApplicationIdentifier"] = self.application + sdk_options["EnvironmentIdentifier"] = self.environment + response_configuration = self.client.start_configuration_session(**sdk_options) + self._next_token = response_configuration["InitialConfigurationToken"] - sdk_options["Configuration"] = name - sdk_options["Application"] = self.application - sdk_options["Environment"] = self.environment - sdk_options["ClientId"] = CLIENT_ID + # The new AppConfig APIs require two API calls to return the configuration + # First we start the session and after that we retrieve the configuration + # We need to store the token to use in the next execution + response = self.client.get_latest_configuration(ConfigurationToken=self._next_token) + return_value = response["Configuration"].read() + self._next_token = response["NextPollConfigurationToken"] - response = self.client.get_configuration(**sdk_options) - return response["Content"].read() # read() of botocore.response.StreamingBody + if return_value: + self.last_returned_value = return_value + + return self.last_returned_value def _get_multiple(self, path: str, **sdk_options) -> Dict[str, str]: """ @@ -145,7 +155,7 @@ def get_app_config( max_age: int Maximum age of the cached value sdk_options: dict, optional - Dictionary of options that will be passed to the boto client get_configuration API call + SDK options to propagate to `start_configuration_session` API call Raises ------ @@ -180,8 +190,6 @@ def get_app_config( if "appconfig" not in DEFAULT_PROVIDERS: DEFAULT_PROVIDERS["appconfig"] = AppConfigProvider(environment=environment, application=application) - sdk_options["ClientId"] = CLIENT_ID - return DEFAULT_PROVIDERS["appconfig"].get( name, max_age=max_age, transform=transform, force_fetch=force_fetch, **sdk_options ) diff --git a/aws_lambda_powertools/utilities/parameters/base.py b/aws_lambda_powertools/utilities/parameters/base.py index ce03b757618..b76b16e1dd8 100644 --- a/aws_lambda_powertools/utilities/parameters/base.py +++ b/aws_lambda_powertools/utilities/parameters/base.py @@ -15,7 +15,7 @@ from .exceptions import GetParameterError, TransformParameterError if TYPE_CHECKING: - from mypy_boto3_appconfig import AppConfigClient + from mypy_boto3_appconfigdata import AppConfigDataClient from mypy_boto3_dynamodb import DynamoDBServiceResource from mypy_boto3_secretsmanager import SecretsManagerClient from mypy_boto3_ssm import SSMClient @@ -28,7 +28,7 @@ TRANSFORM_METHOD_JSON = "json" TRANSFORM_METHOD_BINARY = "binary" SUPPORTED_TRANSFORM_METHODS = [TRANSFORM_METHOD_JSON, TRANSFORM_METHOD_BINARY] -ParameterClients = Union["AppConfigClient", "SecretsManagerClient", "SSMClient"] +ParameterClients = Union["AppConfigDataClient", "SecretsManagerClient", "SSMClient"] class BaseProvider(ABC): diff --git a/docs/upgrade.md b/docs/upgrade.md index 37e9a318522..f6b3c7e9d00 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -13,6 +13,7 @@ Changes at a glance: * The API for **event handler's `Response`** has minor changes to support multi value headers and cookies. * The **legacy SQS batch processor** was removed. * The **Idempotency key** format changed slightly, invalidating all the existing cached results. +* The **Feature Flags and AppConfig Parameter utility** API calls have changed and you must update your IAM permissions. ???+ important Powertools for Python v2 drops suport for Python 3.6, following the Python 3.6 End-Of-Life (EOL) reached on December 23, 2021. @@ -154,3 +155,9 @@ Prior to this change, the Idempotency key was generated using only the caller fu After this change, the key is generated using the `module name` + `qualified function name` + `idempotency key` (e.g: `app.classExample.function#app.handler#282e83393862a613b612c00283fef4c8`). Using qualified names prevents distinct functions with the same name to contend for the same Idempotency key. + +## Feature Flags and AppConfig Parameter utility + +AWS AppConfig deprecated the current API (GetConfiguration) - [more details here](https://github.com/awslabs/aws-lambda-powertools-python/issues/1506#issuecomment-1266645884). + +You must update your IAM permissions to allow `appconfig:GetLatestConfiguration` and `appconfig:StartConfigurationSession`. There are no code changes required. diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 1d586d9377d..f35dd88106b 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -3,11 +3,11 @@ title: Feature flags description: Utility --- -???+ note - This is currently in Beta, as we might change Store parameters in the next release. - The feature flags utility provides a simple rule engine to define when one or multiple features should be enabled depending on the input. +???+ info + We currently only support AppConfig using [freeform configuration profile](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-creating-configuration-and-profile.html#appconfig-creating-configuration-and-profile-free-form-configurations). + ## Terminology Feature flags are used to modify behaviour without changing the application's code. These flags can be **static** or **dynamic**. @@ -28,6 +28,9 @@ If you want to learn more about feature flags, their variations and trade-offs, * [AWS Lambda Feature Toggles Made Simple - Ran Isenberg](https://isenberg-ran.medium.com/aws-lambda-feature-toggles-made-simple-580b0c444233) * [Feature Flags Getting Started - CloudBees](https://www.cloudbees.com/blog/ultimate-feature-flag-guide) +???+ note + AWS AppConfig requires two API calls to fetch configuration for the first time. You can improve latency by consolidating your feature settings in a single [Configuration](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-creating-configuration-and-profile.html). + ## Key features * Define simple feature flags to dynamically decide when to enable a feature @@ -38,7 +41,7 @@ If you want to learn more about feature flags, their variations and trade-offs, ### IAM Permissions -Your Lambda function must have `appconfig:GetConfiguration` IAM permission in order to fetch configuration from AWS AppConfig. +Your Lambda function IAM Role must have `appconfig:GetLatestConfiguration` and `appconfig:StartConfigurationSession` IAM permissions before using this feature. ### Required resources diff --git a/docs/utilities/parameters.md b/docs/utilities/parameters.md index 2559044b632..6b7d64b66b9 100644 --- a/docs/utilities/parameters.md +++ b/docs/utilities/parameters.md @@ -24,14 +24,15 @@ This utility requires additional permissions to work as expected. ???+ note Different parameter providers require different permissions. -| Provider | Function/Method | IAM Permission | -| ------------------- | ---------------------------------------------------- | ------------------------------- | -| SSM Parameter Store | `get_parameter`, `SSMProvider.get` | `ssm:GetParameter` | -| SSM Parameter Store | `get_parameters`, `SSMProvider.get_multiple` | `ssm:GetParametersByPath` | -| Secrets Manager | `get_secret`, `SecretsManager.get` | `secretsmanager:GetSecretValue` | -| DynamoDB | `DynamoDBProvider.get` | `dynamodb:GetItem` | -| DynamoDB | `DynamoDBProvider.get_multiple` | `dynamodb:Query` | -| App Config | `AppConfigProvider.get_app_config`, `get_app_config` | `appconfig:GetConfiguration` | +| Provider | Function/Method | IAM Permission | +| ------------------- | -----------------------------------------------------------------| -----------------------------------------------------------------------------| +| SSM Parameter Store | `get_parameter`, `SSMProvider.get` | `ssm:GetParameter` | +| SSM Parameter Store | `get_parameters`, `SSMProvider.get_multiple` | `ssm:GetParametersByPath` | +| SSM Parameter Store | If using `decrypt=True` | You must add an additional permission `kms:Decrypt` | +| Secrets Manager | `get_secret`, `SecretsManager.get` | `secretsmanager:GetSecretValue` | +| DynamoDB | `DynamoDBProvider.get` | `dynamodb:GetItem` | +| DynamoDB | `DynamoDBProvider.get_multiple` | `dynamodb:Query` | +| App Config | `get_app_config`, `AppConfigProvider.get_app_config` | `appconfig:GetLatestConfiguration` and `appconfig:StartConfigurationSession` | ### Fetching parameters diff --git a/poetry.lock b/poetry.lock index 9bbd6f8e450..1f90755db23 100644 --- a/poetry.lock +++ b/poetry.lock @@ -113,14 +113,14 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "boto3" -version = "1.24.90" +version = "1.24.91" description = "The AWS SDK for Python" category = "main" optional = false python-versions = ">= 3.7" [package.dependencies] -botocore = ">=1.27.90,<1.28.0" +botocore = ">=1.27.91,<1.28.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.6.0,<0.7.0" @@ -129,7 +129,7 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.27.90" +version = "1.27.91" description = "Low-level, data-driven core of boto 3." category = "main" optional = false @@ -753,6 +753,17 @@ python-versions = ">=3.7" [package.dependencies] typing-extensions = ">=4.1.0" +[[package]] +name = "mypy-boto3-appconfigdata" +version = "1.24.36.post1" +description = "Type annotations for boto3.AppConfigData 1.24.36 service generated with mypy-boto3-builder 7.10.0" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +typing-extensions = ">=4.1.0" + [[package]] name = "mypy-boto3-cloudformation" version = "1.24.36.post1" @@ -1381,7 +1392,7 @@ validation = ["fastjsonschema"] [metadata] lock-version = "1.1" python-versions = "^3.7.4" -content-hash = "1e4e1c94d7bb2e4dc1bf4d5631c5548db3e4f2912ef454903816c97f09e04cc1" +content-hash = "bc65bd1113d0d6d5494bd63fc42cbd98bfe00322d8da3f510e1600a7dbdd727c" [metadata.files] attrs = [ @@ -1432,12 +1443,12 @@ black = [ {file = "black-22.10.0.tar.gz", hash = "sha256:f513588da599943e0cde4e32cc9879e825d58720d6557062d1098c5ad80080e1"}, ] boto3 = [ - {file = "boto3-1.24.90-py3-none-any.whl", hash = "sha256:2a058a86989d88e27c79dd3e17218b3b167070d8738e20844d49363a3dd64281"}, - {file = "boto3-1.24.90.tar.gz", hash = "sha256:83c2bc50f762ba87471fc73ec3eefd86626883b38533e98d7993259450c043db"}, + {file = "boto3-1.24.91-py3-none-any.whl", hash = "sha256:b295640bc1be637f8f7c8c8fca70781048d6397196109e59f20541824fab4b67"}, + {file = "boto3-1.24.91.tar.gz", hash = "sha256:3225366014949039e6687387242e73f237f0fee0a9b7c20461894f1ee40686b8"}, ] botocore = [ - {file = "botocore-1.27.90-py3-none-any.whl", hash = "sha256:2a934e713e83ae7f4dde1dd013be280538e3c4a3825c5f5a2727a1956d4cd82c"}, - {file = "botocore-1.27.90.tar.gz", hash = "sha256:4ecc149d1dd36d32ba222de135c2e147731107ae444440b12714282dc00f88a4"}, + {file = "botocore-1.27.91-py3-none-any.whl", hash = "sha256:1d6e97bd8653f732c7078b34aa2bb438e750898957e5a0a74b6c72918bc1d0f7"}, + {file = "botocore-1.27.91.tar.gz", hash = "sha256:c8fac203a391cc2e4b682877bfce70e723e33c529b35b399a1d574605fbeb1af"}, ] cattrs = [ {file = "cattrs-22.1.0-py3-none-any.whl", hash = "sha256:d55c477b4672f93606e992049f15d526dc7867e6c756cd6256d4af92e2b1e364"}, @@ -1743,6 +1754,10 @@ mypy-boto3-appconfig = [ {file = "mypy-boto3-appconfig-1.24.36.post1.tar.gz", hash = "sha256:e1916b3754915cb411ef977083500e1f30f81f7b3aea6ff5eed1cec91944dea6"}, {file = "mypy_boto3_appconfig-1.24.36.post1-py3-none-any.whl", hash = "sha256:a5dbe549dbebf4bc7a6cfcbfa9dff89ceb4983c042b785763ee656504bdb49f6"}, ] +mypy-boto3-appconfigdata = [ + {file = "mypy-boto3-appconfigdata-1.24.36.post1.tar.gz", hash = "sha256:48c0b29a99f5e5a54a4585a4b3661bc00c7db40e481c5d014a4bfd86d1ae645e"}, + {file = "mypy_boto3_appconfigdata-1.24.36.post1-py3-none-any.whl", hash = "sha256:2bc495e6b6bd358d78d30f84b750d17ac326b2b4356a7786d0d1334812416edd"}, +] mypy-boto3-cloudformation = [ {file = "mypy-boto3-cloudformation-1.24.36.post1.tar.gz", hash = "sha256:ed7df9ae3a8390a145229122a1489d0a58bbf9986cb54f0d7a65ed54f12c8e63"}, {file = "mypy_boto3_cloudformation-1.24.36.post1-py3-none-any.whl", hash = "sha256:b39020c13a876bb18908aad22326478d0ac3faec0bdac0d2c11dc318c9dcf149"}, diff --git a/pyproject.toml b/pyproject.toml index d33a5dd8338..703b3978eee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ typing-extensions = "^4.4.0" mkdocs-material = "^8.5.4" filelock = "^3.8.0" checksumdir = "^1.2.0" +mypy-boto3-appconfigdata = "^1.24.36" importlib-metadata = "^4.13" [tool.poetry.extras] diff --git a/tests/e2e/parameters/__init__.py b/tests/e2e/parameters/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/e2e/parameters/conftest.py b/tests/e2e/parameters/conftest.py new file mode 100644 index 00000000000..f4c9d7396dd --- /dev/null +++ b/tests/e2e/parameters/conftest.py @@ -0,0 +1,19 @@ +import pytest + +from tests.e2e.parameters.infrastructure import ParametersStack + + +@pytest.fixture(autouse=True, scope="module") +def infrastructure(tmp_path_factory, worker_id): + """Setup and teardown logic for E2E test infrastructure + + Yields + ------ + Dict[str, str] + CloudFormation Outputs from deployed infrastructure + """ + stack = ParametersStack() + try: + yield stack.deploy() + finally: + stack.delete() diff --git a/tests/e2e/parameters/handlers/parameter_appconfig_freeform_handler.py b/tests/e2e/parameters/handlers/parameter_appconfig_freeform_handler.py new file mode 100644 index 00000000000..51b56eba95a --- /dev/null +++ b/tests/e2e/parameters/handlers/parameter_appconfig_freeform_handler.py @@ -0,0 +1,11 @@ +from aws_lambda_powertools.utilities import parameters +from aws_lambda_powertools.utilities.typing import LambdaContext + + +def lambda_handler(event: dict, context: LambdaContext): + # Retrieve a single configuration, latest version + value: bytes = parameters.get_app_config( + name=event.get("name"), environment=event.get("environment"), application=event.get("application") + ) + + return value diff --git a/tests/e2e/parameters/infrastructure.py b/tests/e2e/parameters/infrastructure.py new file mode 100644 index 00000000000..e1dfb13bcdd --- /dev/null +++ b/tests/e2e/parameters/infrastructure.py @@ -0,0 +1,105 @@ +from pyclbr import Function + +from aws_cdk import CfnOutput +from aws_cdk import aws_appconfig as appconfig +from aws_cdk import aws_iam as iam + +from tests.e2e.utils.data_builder import build_service_name +from tests.e2e.utils.infrastructure import BaseInfrastructure + + +class ParametersStack(BaseInfrastructure): + def create_resources(self): + functions = self.create_lambda_functions() + self._create_app_config(function=functions["ParameterAppconfigFreeformHandler"]) + + def _create_app_config(self, function: Function): + + service_name = build_service_name() + + cfn_application = appconfig.CfnApplication( + self.stack, id="appconfig-app", name=f"powertools-e2e-{service_name}", description="Lambda Powertools End-to-End testing for AppConfig" + ) + CfnOutput(self.stack, "AppConfigApplication", value=cfn_application.name) + + cfn_environment = appconfig.CfnEnvironment( + self.stack, + "appconfig-env", + application_id=cfn_application.ref, + name=f"powertools-e2e{service_name}", + description="Lambda Powertools End-to-End testing environment", + ) + CfnOutput(self.stack, "AppConfigEnvironment", value=cfn_environment.name) + + cfn_deployment_strategy = appconfig.CfnDeploymentStrategy( + self.stack, + "appconfig-deployment-strategy", + deployment_duration_in_minutes=0, + final_bake_time_in_minutes=0, + growth_factor=100, + name=f"deploymente2e{service_name}", + description="deploymente2e", + replicate_to="NONE", + growth_type="LINEAR", + ) + + self._create_app_config_freeform( + app=cfn_application, + environment=cfn_environment, + strategy=cfn_deployment_strategy, + function=function, + service_name=service_name, + ) + + def _create_app_config_freeform( + self, + app: appconfig.CfnApplication, + environment: appconfig.CfnEnvironment, + strategy: appconfig.CfnDeploymentStrategy, + function: Function, + service_name: str, + ): + + cfn_configuration_profile = appconfig.CfnConfigurationProfile( + self.stack, + "appconfig-profile", + application_id=app.ref, + location_uri="hosted", + type="AWS.Freeform", + name=f"profilee2e{service_name}", + description="profilee2e", + ) + CfnOutput(self.stack, "AppConfigProfile", value=cfn_configuration_profile.name) + + cfn_hosted_configuration_version = appconfig.CfnHostedConfigurationVersion( + self.stack, + "appconfig-hosted-deploy", + application_id=app.ref, + configuration_profile_id=cfn_configuration_profile.ref, + content='{"save_history": {"default": true}}', + content_type="application/json", + description="hostedconfiguratione2e", + ) + CfnOutput(self.stack, "AppConfigConfigurationValue", value=cfn_hosted_configuration_version.content) + + appconfig.CfnDeployment( + self.stack, + "appconfig-deployment", + application_id=app.ref, + configuration_profile_id=cfn_configuration_profile.ref, + configuration_version=cfn_hosted_configuration_version.ref, + deployment_strategy_id=strategy.ref, + environment_id=environment.ref, + description="deployment", + ) + + function.add_to_role_policy( + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + "appconfig:GetLatestConfiguration", + "appconfig:StartConfigurationSession", + ], + resources=["*"], + ) + ) diff --git a/tests/e2e/parameters/test_appconfig.py b/tests/e2e/parameters/test_appconfig.py new file mode 100644 index 00000000000..0129adb1515 --- /dev/null +++ b/tests/e2e/parameters/test_appconfig.py @@ -0,0 +1,61 @@ +import json + +import pytest + +from tests.e2e.utils import data_fetcher + + +@pytest.fixture +def parameter_appconfig_freeform_handler_fn_arn(infrastructure: dict) -> str: + return infrastructure.get("ParameterAppconfigFreeformHandlerArn", "") + + +@pytest.fixture +def parameter_appconfig_freeform_handler_fn(infrastructure: dict) -> str: + return infrastructure.get("ParameterAppconfigFreeformHandler", "") + + +@pytest.fixture +def parameter_appconfig_freeform_value(infrastructure: dict) -> str: + return infrastructure.get("AppConfigConfigurationValue", "") + + +@pytest.fixture +def parameter_appconfig_freeform_application(infrastructure: dict) -> str: + return infrastructure.get("AppConfigApplication", "") + + +@pytest.fixture +def parameter_appconfig_freeform_environment(infrastructure: dict) -> str: + return infrastructure.get("AppConfigEnvironment", "") + + +@pytest.fixture +def parameter_appconfig_freeform_profile(infrastructure: dict) -> str: + return infrastructure.get("AppConfigProfile", "") + + +def test_get_parameter_appconfig_freeform( + parameter_appconfig_freeform_handler_fn_arn: str, + parameter_appconfig_freeform_value: str, + parameter_appconfig_freeform_application: str, + parameter_appconfig_freeform_environment: str, + parameter_appconfig_freeform_profile: str, +): + # GIVEN + payload = json.dumps( + { + "name": parameter_appconfig_freeform_profile, + "environment": parameter_appconfig_freeform_environment, + "application": parameter_appconfig_freeform_application, + } + ) + expected_return = parameter_appconfig_freeform_value + + # WHEN + parameter_execution, _ = data_fetcher.get_lambda_response( + lambda_arn=parameter_appconfig_freeform_handler_fn_arn, payload=payload + ) + parameter_value = parameter_execution["Payload"].read().decode("utf-8") + + assert parameter_value == expected_return diff --git a/tests/functional/test_utilities_parameters.py b/tests/functional/test_utilities_parameters.py index 2b8291db47b..123c2fdbcc2 100644 --- a/tests/functional/test_utilities_parameters.py +++ b/tests/functional/test_utilities_parameters.py @@ -1639,14 +1639,22 @@ def test_appconf_provider_get_configuration_json_content_type(mock_name, config) encoded_message = json.dumps(mock_body_json).encode("utf-8") mock_value = StreamingBody(BytesIO(encoded_message), len(encoded_message)) - # Stub the boto3 client stubber = stub.Stubber(provider.client) - response = {"Content": mock_value, "ConfigurationVersion": "1", "ContentType": "application/json"} - stubber.add_response("get_configuration", response) + response_start_config_session = {"InitialConfigurationToken": "initial_token"} + stubber.add_response("start_configuration_session", response_start_config_session) + + response_get_latest_config = { + "Configuration": mock_value, + "NextPollConfigurationToken": "initial_token", + "ContentType": "application/json", + } + stubber.add_response("get_latest_configuration", response_get_latest_config) stubber.activate() try: - value = provider.get(mock_name, transform="json", ClientConfigurationVersion="2") + value = provider.get( + mock_name, transform="json", ApplicationIdentifier=application, EnvironmentIdentifier=environment + ) assert value == mock_body_json stubber.assert_no_pending_responses() @@ -1659,7 +1667,7 @@ def test_appconf_provider_get_configuration_json_content_type_with_custom_client Test get_configuration.get with default values """ - client = boto3.client("appconfig", config=config) + client = boto3.client("appconfigdata", config=config) # Create a new provider environment = "dev" @@ -1670,14 +1678,22 @@ def test_appconf_provider_get_configuration_json_content_type_with_custom_client encoded_message = json.dumps(mock_body_json).encode("utf-8") mock_value = StreamingBody(BytesIO(encoded_message), len(encoded_message)) - # Stub the boto3 client stubber = stub.Stubber(provider.client) - response = {"Content": mock_value, "ConfigurationVersion": "1", "ContentType": "application/json"} - stubber.add_response("get_configuration", response) + response_start_config_session = {"InitialConfigurationToken": "initial_token"} + stubber.add_response("start_configuration_session", response_start_config_session) + + response_get_latest_config = { + "Configuration": mock_value, + "NextPollConfigurationToken": "initial_token", + "ContentType": "application/json", + } + stubber.add_response("get_latest_configuration", response_get_latest_config) stubber.activate() try: - value = provider.get(mock_name, transform="json", ClientConfigurationVersion="2") + value = provider.get( + mock_name, transform="json", ApplicationIdentifier=application, EnvironmentIdentifier=environment + ) assert value == mock_body_json stubber.assert_no_pending_responses() @@ -1699,10 +1715,16 @@ def test_appconf_provider_get_configuration_no_transform(mock_name, config): encoded_message = json.dumps(mock_body_json).encode("utf-8") mock_value = StreamingBody(BytesIO(encoded_message), len(encoded_message)) - # Stub the boto3 client stubber = stub.Stubber(provider.client) - response = {"Content": mock_value, "ConfigurationVersion": "1", "ContentType": "application/json"} - stubber.add_response("get_configuration", response) + response_start_config_session = {"InitialConfigurationToken": "initial_token"} + stubber.add_response("start_configuration_session", response_start_config_session) + + response_get_latest_config = { + "Configuration": mock_value, + "NextPollConfigurationToken": "initial_token", + "ContentType": "application/json", + } + stubber.add_response("get_latest_configuration", response_get_latest_config) stubber.activate() try: From fe45ff7de92969bc74051692854d04843e7d1b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BAben=20Fonseca?= Date: Fri, 14 Oct 2022 22:18:10 +0200 Subject: [PATCH 19/30] fix: lint files --- .../event_handler/api_gateway.py | 3 +-- .../utilities/data_classes/alb_event.py | 5 ++++- .../handlers/api_gateway_http_handler.py | 6 +++++- .../handlers/api_gateway_rest_handler.py | 6 +++++- .../handlers/lambda_function_url_handler.py | 6 +++++- .../handlers/parallel_execution_handler.py | 5 ++++- .../handlers/ttl_cache_expiration_handler.py | 6 +++++- .../handlers/ttl_cache_timeout_handler.py | 6 +++++- tests/e2e/parameters/infrastructure.py | 5 ++++- tests/e2e/utils/infrastructure.py | 15 +++++++++++++-- .../event_handler/test_lambda_function_url.py | 6 +++++- tests/functional/test_utilities_batch.py | 15 +++++++++++---- 12 files changed, 67 insertions(+), 17 deletions(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 2b14ed3a3fd..4dbf753d22c 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -27,9 +27,8 @@ from aws_lambda_powertools.event_handler import content_types from aws_lambda_powertools.event_handler.exceptions import NotFoundError, ServiceError from aws_lambda_powertools.shared import constants -from aws_lambda_powertools.shared.functions import powertools_dev_is_set, strtobool from aws_lambda_powertools.shared.cookies import Cookie -from aws_lambda_powertools.shared.functions import resolve_truthy_env_var_choice +from aws_lambda_powertools.shared.functions import powertools_dev_is_set, strtobool from aws_lambda_powertools.shared.json_encoder import Encoder from aws_lambda_powertools.utilities.data_classes import ( ALBEvent, diff --git a/aws_lambda_powertools/utilities/data_classes/alb_event.py b/aws_lambda_powertools/utilities/data_classes/alb_event.py index 1bd49fd05b6..51a6f61f368 100644 --- a/aws_lambda_powertools/utilities/data_classes/alb_event.py +++ b/aws_lambda_powertools/utilities/data_classes/alb_event.py @@ -5,7 +5,10 @@ MultiValueHeadersSerializer, SingleValueHeadersSerializer, ) -from aws_lambda_powertools.utilities.data_classes.common import BaseProxyEvent, DictWrapper +from aws_lambda_powertools.utilities.data_classes.common import ( + BaseProxyEvent, + DictWrapper, +) class ALBEventRequestContext(DictWrapper): diff --git a/tests/e2e/event_handler/handlers/api_gateway_http_handler.py b/tests/e2e/event_handler/handlers/api_gateway_http_handler.py index 990761cd3b9..9edacc1c807 100644 --- a/tests/e2e/event_handler/handlers/api_gateway_http_handler.py +++ b/tests/e2e/event_handler/handlers/api_gateway_http_handler.py @@ -1,4 +1,8 @@ -from aws_lambda_powertools.event_handler import APIGatewayHttpResolver, Response, content_types +from aws_lambda_powertools.event_handler import ( + APIGatewayHttpResolver, + Response, + content_types, +) app = APIGatewayHttpResolver() diff --git a/tests/e2e/event_handler/handlers/api_gateway_rest_handler.py b/tests/e2e/event_handler/handlers/api_gateway_rest_handler.py index 0aa836cfe74..3127634e995 100644 --- a/tests/e2e/event_handler/handlers/api_gateway_rest_handler.py +++ b/tests/e2e/event_handler/handlers/api_gateway_rest_handler.py @@ -1,4 +1,8 @@ -from aws_lambda_powertools.event_handler import APIGatewayRestResolver, Response, content_types +from aws_lambda_powertools.event_handler import ( + APIGatewayRestResolver, + Response, + content_types, +) app = APIGatewayRestResolver() diff --git a/tests/e2e/event_handler/handlers/lambda_function_url_handler.py b/tests/e2e/event_handler/handlers/lambda_function_url_handler.py index c9c825c38d2..884704893ae 100644 --- a/tests/e2e/event_handler/handlers/lambda_function_url_handler.py +++ b/tests/e2e/event_handler/handlers/lambda_function_url_handler.py @@ -1,4 +1,8 @@ -from aws_lambda_powertools.event_handler import LambdaFunctionUrlResolver, Response, content_types +from aws_lambda_powertools.event_handler import ( + LambdaFunctionUrlResolver, + Response, + content_types, +) app = LambdaFunctionUrlResolver() diff --git a/tests/e2e/idempotency/handlers/parallel_execution_handler.py b/tests/e2e/idempotency/handlers/parallel_execution_handler.py index 764ca1385a6..6dcb012d858 100644 --- a/tests/e2e/idempotency/handlers/parallel_execution_handler.py +++ b/tests/e2e/idempotency/handlers/parallel_execution_handler.py @@ -1,7 +1,10 @@ import os import time -from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent +from aws_lambda_powertools.utilities.idempotency import ( + DynamoDBPersistenceLayer, + idempotent, +) TABLE_NAME = os.getenv("IdempotencyTable", "") persistence_layer = DynamoDBPersistenceLayer(table_name=TABLE_NAME) diff --git a/tests/e2e/idempotency/handlers/ttl_cache_expiration_handler.py b/tests/e2e/idempotency/handlers/ttl_cache_expiration_handler.py index 4d8b194657e..4cd71045dc0 100644 --- a/tests/e2e/idempotency/handlers/ttl_cache_expiration_handler.py +++ b/tests/e2e/idempotency/handlers/ttl_cache_expiration_handler.py @@ -1,7 +1,11 @@ import os import time -from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, IdempotencyConfig, idempotent +from aws_lambda_powertools.utilities.idempotency import ( + DynamoDBPersistenceLayer, + IdempotencyConfig, + idempotent, +) TABLE_NAME = os.getenv("IdempotencyTable", "") persistence_layer = DynamoDBPersistenceLayer(table_name=TABLE_NAME) diff --git a/tests/e2e/idempotency/handlers/ttl_cache_timeout_handler.py b/tests/e2e/idempotency/handlers/ttl_cache_timeout_handler.py index 82ad024df53..99be7b63391 100644 --- a/tests/e2e/idempotency/handlers/ttl_cache_timeout_handler.py +++ b/tests/e2e/idempotency/handlers/ttl_cache_timeout_handler.py @@ -1,7 +1,11 @@ import os import time -from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, IdempotencyConfig, idempotent +from aws_lambda_powertools.utilities.idempotency import ( + DynamoDBPersistenceLayer, + IdempotencyConfig, + idempotent, +) TABLE_NAME = os.getenv("IdempotencyTable", "") persistence_layer = DynamoDBPersistenceLayer(table_name=TABLE_NAME) diff --git a/tests/e2e/parameters/infrastructure.py b/tests/e2e/parameters/infrastructure.py index e1dfb13bcdd..d0fb1b6c60c 100644 --- a/tests/e2e/parameters/infrastructure.py +++ b/tests/e2e/parameters/infrastructure.py @@ -18,7 +18,10 @@ def _create_app_config(self, function: Function): service_name = build_service_name() cfn_application = appconfig.CfnApplication( - self.stack, id="appconfig-app", name=f"powertools-e2e-{service_name}", description="Lambda Powertools End-to-End testing for AppConfig" + self.stack, + id="appconfig-app", + name=f"powertools-e2e-{service_name}", + description="Lambda Powertools End-to-End testing for AppConfig", ) CfnOutput(self.stack, "AppConfigApplication", value=cfn_application.name) diff --git a/tests/e2e/utils/infrastructure.py b/tests/e2e/utils/infrastructure.py index 67f2af623f8..29e45b83abf 100644 --- a/tests/e2e/utils/infrastructure.py +++ b/tests/e2e/utils/infrastructure.py @@ -11,12 +11,23 @@ import boto3 import pytest from aws_cdk import App, CfnOutput, Environment, RemovalPolicy, Stack, aws_logs -from aws_cdk.aws_lambda import Architecture, Code, Function, LayerVersion, Runtime, Tracing +from aws_cdk.aws_lambda import ( + Architecture, + Code, + Function, + LayerVersion, + Runtime, + Tracing, +) from filelock import FileLock from mypy_boto3_cloudformation import CloudFormationClient from tests.e2e.utils.base import InfrastructureProvider -from tests.e2e.utils.constants import CDK_OUT_PATH, PYTHON_RUNTIME_VERSION, SOURCE_CODE_ROOT_PATH +from tests.e2e.utils.constants import ( + CDK_OUT_PATH, + PYTHON_RUNTIME_VERSION, + SOURCE_CODE_ROOT_PATH, +) from tests.e2e.utils.lambda_layer.powertools_layer import LocalLambdaPowertoolsLayer logger = logging.getLogger(__name__) diff --git a/tests/functional/event_handler/test_lambda_function_url.py b/tests/functional/event_handler/test_lambda_function_url.py index c87d0ecb854..aacbc94129b 100644 --- a/tests/functional/event_handler/test_lambda_function_url.py +++ b/tests/functional/event_handler/test_lambda_function_url.py @@ -1,4 +1,8 @@ -from aws_lambda_powertools.event_handler import LambdaFunctionUrlResolver, Response, content_types +from aws_lambda_powertools.event_handler import ( + LambdaFunctionUrlResolver, + Response, + content_types, +) from aws_lambda_powertools.shared.cookies import Cookie from aws_lambda_powertools.utilities.data_classes import LambdaFunctionUrlEvent from tests.functional.utils import load_event diff --git a/tests/functional/test_utilities_batch.py b/tests/functional/test_utilities_batch.py index 2c72f4494b2..4f46b428121 100644 --- a/tests/functional/test_utilities_batch.py +++ b/tests/functional/test_utilities_batch.py @@ -5,10 +5,18 @@ import pytest from botocore.config import Config -from aws_lambda_powertools.utilities.batch import BatchProcessor, EventType, batch_processor +from aws_lambda_powertools.utilities.batch import ( + BatchProcessor, + EventType, + batch_processor, +) from aws_lambda_powertools.utilities.batch.exceptions import BatchProcessingError -from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import DynamoDBRecord -from aws_lambda_powertools.utilities.data_classes.kinesis_stream_event import KinesisStreamRecord +from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import ( + DynamoDBRecord, +) +from aws_lambda_powertools.utilities.data_classes.kinesis_stream_event import ( + KinesisStreamRecord, +) from aws_lambda_powertools.utilities.data_classes.sqs_event import SQSRecord from aws_lambda_powertools.utilities.parser import BaseModel, validator from aws_lambda_powertools.utilities.parser.models import ( @@ -23,7 +31,6 @@ SqsRecordModel, ) from aws_lambda_powertools.utilities.parser.types import Literal -from aws_lambda_powertools.utilities.typing import LambdaContext from tests.functional.utils import b64_to_str, str_to_b64 From e5a263ace67ecc61c364d2041a85a7680737173e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BAben=20Fonseca?= Date: Fri, 14 Oct 2022 22:22:23 +0200 Subject: [PATCH 20/30] fix: mypy errors --- .../parser/src/extending_built_in_models_with_json_mypy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/parser/src/extending_built_in_models_with_json_mypy.py b/examples/parser/src/extending_built_in_models_with_json_mypy.py index 80314a814ce..813f757ad79 100644 --- a/examples/parser/src/extending_built_in_models_with_json_mypy.py +++ b/examples/parser/src/extending_built_in_models_with_json_mypy.py @@ -11,11 +11,11 @@ class CancelOrder(BaseModel): class CancelOrderModel(APIGatewayProxyEventV2Model): - body: Json[CancelOrder] # type: ignore[type-arg] + body: Json[CancelOrder] # type: ignore[assignment] @event_parser(model=CancelOrderModel) def handler(event: CancelOrderModel, context: LambdaContext): - cancel_order: CancelOrder = event.body # type: ignore[assignment] + cancel_order: CancelOrder = event.body assert cancel_order.order_id is not None From 18bee3797cc68649aaec150bd0345496b9569ab7 Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Tue, 18 Oct 2022 10:44:11 +0200 Subject: [PATCH 21/30] docs(homepage): auto-update Layer ARN on every release (#1610) Co-authored-by: Heitor Lessa --- .github/workflows/publish_v2_layer.yml | 4 +- .../reusable_deploy_v2_layer_stack.yml | 23 +- .../reusable_update_v2_layer_arn_docs.yml | 60 +++ .pre-commit-config.yaml | 2 +- docs/index.md | 455 ++++++++++++------ docs/stylesheets/extra.css | 2 +- layer/scripts/update_layer_arn.sh | 75 +++ 7 files changed, 467 insertions(+), 154 deletions(-) create mode 100644 .github/workflows/reusable_update_v2_layer_arn_docs.yml create mode 100755 layer/scripts/update_layer_arn.sh diff --git a/.github/workflows/publish_v2_layer.yml b/.github/workflows/publish_v2_layer.yml index de04566c79e..77f1f9dc627 100644 --- a/.github/workflows/publish_v2_layer.yml +++ b/.github/workflows/publish_v2_layer.yml @@ -82,6 +82,7 @@ jobs: stage: "BETA" artefact-name: "cdk-layer-artefact" environment: "layer-beta" + latest_published_version: ${{ inputs.latest_published_version }} # deploy-prod: # needs: @@ -92,6 +93,7 @@ jobs: # stage: "PROD" # artefact-name: "cdk-layer-artefact" # environment: "layer-prod" + # latest_published_version: ${{ inputs.latest_published_version }} deploy-sar-beta: needs: build-layer @@ -104,7 +106,7 @@ jobs: package-version: ${{ needs.build-layer.outputs.release-tag-version }} deploy-sar-prod: - needs: deploy-sar-beta + needs: [build-layer, deploy-sar-beta] uses: ./.github/workflows/reusable_deploy_v2_sar.yml secrets: inherit with: diff --git a/.github/workflows/reusable_deploy_v2_layer_stack.yml b/.github/workflows/reusable_deploy_v2_layer_stack.yml index 1a5eb83cd23..69b99fc3f9a 100644 --- a/.github/workflows/reusable_deploy_v2_layer_stack.yml +++ b/.github/workflows/reusable_deploy_v2_layer_stack.yml @@ -22,6 +22,10 @@ on: description: "GitHub Environment to use for encrypted secrets" required: true type: string + latest_published_version: + description: "Latest version that is published" + required: true + type: string jobs: deploy-cdk-stack: @@ -97,6 +101,23 @@ jobs: - name: unzip artefact run: unzip cdk.out.zip - name: CDK Deploy Layer - run: cdk deploy --app cdk.out --context region=${{ matrix.region }} 'LayerV2Stack' --require-approval never --verbose + run: cdk deploy --app cdk.out --context region=${{ matrix.region }} 'LayerV2Stack' --require-approval never --verbose --outputs-file cdk-outputs.json + - name: Store latest Layer ARN + if: ${{ inputs.stage == 'PROD' }} + run: | + jq -c '.LayerV2Stack.VersionArn' cdk-outputs.json > cdk-layer-stack-${{ matrix.region }}-layer-version.txt + jq -c '.LayerV2Stack.Arm64VersionArn' cdk-outputs.json >> cdk-layer-stack-${{ matrix.region }}-layer-version.txt - name: CDK Deploy Canary run: cdk deploy --app cdk.out --context region=${{ matrix.region}} --parameters DeployStage="${{ inputs.stage }}" 'CanaryV2Stack' --require-approval never --verbose + - name: Save Layer ARN artifact + uses: actions/upload-artifacts@v3 + with: + name: cdk-layer-stack + path: cdk-layer-stack* + + update_v2_layer_arn_docs: + permissions: + contents: write + uses: ./.github/workflows/reusable_update_v2_layer_arn_docs.yml + with: + latest_published_version: ${{ inputs.latest_published_version }} diff --git a/.github/workflows/reusable_update_v2_layer_arn_docs.yml b/.github/workflows/reusable_update_v2_layer_arn_docs.yml new file mode 100644 index 00000000000..857c8001bf9 --- /dev/null +++ b/.github/workflows/reusable_update_v2_layer_arn_docs.yml @@ -0,0 +1,60 @@ +name: Update V2 Layer ARN Docs + +on: + workflow_call: + inputs: + latest_published_version: + description: "Latest PyPi published version to rebuild latest docs for, e.g. v2.0.0" + type: string + required: true + +permissions: + contents: write + +env: + BRANCH: v2 + +jobs: + publish_v2_layer_arn: + # Force Github action to run only a single job at a time (based on the group name) + # This is to prevent race-condition and inconsistencies with changelog push + concurrency: + group: changelog-build + runs-on: ubuntu-latest + steps: + - name: Checkout repository # reusable workflows start clean, so we need to checkout again + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Git client setup and refresh tip + run: | + git config user.name "Release bot" + git config user.email "aws-devax-open-source@amazon.com" + git config pull.rebase true + git config remote.origin.url >&- || git remote add origin https://github.com/"${origin}" # Git Detached mode (release notes) doesn't have origin + git pull origin "${BRANCH}" + - name: Download CDK layer artifact + uses: actions/download-artifact@v3 + with: + name: cdk-layer-stack + path: cdk-layer-stack + - name: Replace layer versions in documentation + run: ./layer/scripts/update_layer_arn.sh cdk-layer-stack + - name: Update documentation in trunk + run: | + HAS_CHANGE=$(git status --porcelain) + test -z "${HAS_CHANGE}" && echo "Nothing to update" && exit 0 + git add docs/index.md + git commit -m "chore: update v2 layer ARN on documentation" + git pull origin "${BRANCH}" # prevents concurrent branch update failing push + git push origin HEAD:refs/heads/"${BRANCH}" + + release-docs: + needs: publish_v2_layer_arn + permissions: + contents: write + pages: write + uses: ./.github/workflows/reusable_publish_docs.yml + with: + version: ${{ inputs.latest_published_version }} + alias: latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 71b1125cf54..0ffeed560f4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: - id: cfn-python-lint files: examples/.*\.(yaml|yml)$ - repo: https://github.com/rhysd/actionlint - rev: v1.6.16 + rev: v1.6.21 hooks: - id: actionlint-docker args: [-pyflakes=] diff --git a/docs/index.md b/docs/index.md index 3626a13b47f..58bf74bf223 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,7 +17,8 @@ A suite of utilities for AWS Lambda functions to ease adopting best practices su Powertools is available in the following formats: -* **Lambda Layer**: [**arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPython:38**](#){: .copyMe}:clipboard: +* **Lambda Layer (x86_64)**: [**arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPythonV2:1**](#){: .copyMe}:clipboard: +* **Lambda Layer (arm64)**: [**arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1**](#){: .copyMe}:clipboard: * **PyPi**: **`pip install aws-lambda-powertools`** ???+ info "Some utilities require additional dependencies" @@ -51,160 +52,317 @@ You can include Lambda Powertools Lambda Layer using [AWS Lambda Console](https: ??? note "Note: Expand to copy any regional Lambda Layer ARN" - | Region | Layer ARN | - | ---------------- | -------------------------------------------------------------------------------------------------------- | - | `af-south-1` | [arn:aws:lambda:af-south-1:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - | `ap-east-1` | [arn:aws:lambda:ap-east-1:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - | `ap-northeast-1` | [arn:aws:lambda:ap-northeast-1:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - | `ap-northeast-2` | [arn:aws:lambda:ap-northeast-2:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - | `ap-northeast-3` | [arn:aws:lambda:ap-northeast-3:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - | `ap-south-1` | [arn:aws:lambda:ap-south-1:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - | `ap-southeast-1` | [arn:aws:lambda:ap-southeast-1:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - | `ap-southeast-2` | [arn:aws:lambda:ap-southeast-2:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - | `ap-southeast-3` | [arn:aws:lambda:ap-southeast-3:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - | `ca-central-1` | [arn:aws:lambda:ca-central-1:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - | `eu-central-1` | [arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - | `eu-north-1` | [arn:aws:lambda:eu-north-1:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - | `eu-south-1` | [arn:aws:lambda:eu-south-1:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - | `eu-west-1` | [arn:aws:lambda:eu-west-1:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - | `eu-west-2` | [arn:aws:lambda:eu-west-2:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - | `eu-west-3` | [arn:aws:lambda:eu-west-3:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - | `me-south-1` | [arn:aws:lambda:me-south-1:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - | `sa-east-1` | [arn:aws:lambda:sa-east-1:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - | `us-east-1` | [arn:aws:lambda:us-east-1:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - | `us-east-2` | [arn:aws:lambda:us-east-2:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - | `us-west-1` | [arn:aws:lambda:us-west-1:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - | `us-west-2` | [arn:aws:lambda:us-west-2:017000801446:layer:AWSLambdaPowertoolsPython:38](#){: .copyMe}:clipboard: | - -??? question "Can't find our Lambda Layer for your preferred AWS region?" - You can use [Serverless Application Repository (SAR)](#sar) method, our [CDK Layer Construct](https://github.com/aws-samples/cdk-lambda-powertools-python-layer){target="_blank"}, or PyPi like you normally would for any other library. - - Please do file a feature request with the region you'd want us to prioritize making our Lambda Layer available. - -=== "SAM" - - ```yaml hl_lines="5" - MyLambdaFunction: - Type: AWS::Serverless::Function - Properties: - Layers: - - !Sub arn:aws:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPython:38 - ``` + === "x86_64" + + | Region | Layer ARN | + | ---------------- | -------------------------------------------------------------------------------------------------------- | + | `af-south-1` | [arn:aws:lambda:af-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + | `ap-east-1` | [arn:aws:lambda:ap-east-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + | `ap-northeast-1` | [arn:aws:lambda:ap-northeast-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + | `ap-northeast-2` | [arn:aws:lambda:ap-northeast-2:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + | `ap-northeast-3` | [arn:aws:lambda:ap-northeast-3:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + | `ap-south-1` | [arn:aws:lambda:ap-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + | `ap-southeast-1` | [arn:aws:lambda:ap-southeast-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + | `ap-southeast-2` | [arn:aws:lambda:ap-southeast-2:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + | `ap-southeast-3` | [arn:aws:lambda:ap-southeast-3:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + | `ca-central-1` | [arn:aws:lambda:ca-central-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + | `eu-central-1` | [arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + | `eu-north-1` | [arn:aws:lambda:eu-north-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + | `eu-south-1` | [arn:aws:lambda:eu-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + | `eu-west-1` | [arn:aws:lambda:eu-west-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + | `eu-west-2` | [arn:aws:lambda:eu-west-2:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + | `eu-west-3` | [arn:aws:lambda:eu-west-3:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + | `me-south-1` | [arn:aws:lambda:me-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + | `sa-east-1` | [arn:aws:lambda:sa-east-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + | `us-east-1` | [arn:aws:lambda:us-east-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + | `us-east-2` | [arn:aws:lambda:us-east-2:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + | `us-west-1` | [arn:aws:lambda:us-west-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + | `us-west-2` | [arn:aws:lambda:us-west-2:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | + + === "arm64" + + | Region | Layer ARN | + | ---------------- | -------------------------------------------------------------------------------------------------------- | + | `af-south-1` | [arn:aws:lambda:af-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + | `ap-east-1` | [arn:aws:lambda:ap-east-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + | `ap-northeast-1` | [arn:aws:lambda:ap-northeast-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + | `ap-northeast-2` | [arn:aws:lambda:ap-northeast-2:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + | `ap-northeast-3` | [arn:aws:lambda:ap-northeast-3:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + | `ap-south-1` | [arn:aws:lambda:ap-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + | `ap-southeast-1` | [arn:aws:lambda:ap-southeast-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + | `ap-southeast-2` | [arn:aws:lambda:ap-southeast-2:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + | `ap-southeast-3` | [arn:aws:lambda:ap-southeast-3:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + | `ca-central-1` | [arn:aws:lambda:ca-central-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + | `eu-central-1` | [arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + | `eu-north-1` | [arn:aws:lambda:eu-north-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + | `eu-south-1` | [arn:aws:lambda:eu-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + | `eu-west-1` | [arn:aws:lambda:eu-west-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + | `eu-west-2` | [arn:aws:lambda:eu-west-2:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + | `eu-west-3` | [arn:aws:lambda:eu-west-3:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + | `me-south-1` | [arn:aws:lambda:me-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + | `sa-east-1` | [arn:aws:lambda:sa-east-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + | `us-east-1` | [arn:aws:lambda:us-east-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + | `us-east-2` | [arn:aws:lambda:us-east-2:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + | `us-west-1` | [arn:aws:lambda:us-west-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + | `us-west-2` | [arn:aws:lambda:us-west-2:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | + +=== "x86_64" + + === "SAM" + + ```yaml hl_lines="5" + MyLambdaFunction: + Type: AWS::Serverless::Function + Properties: + Layers: + - !Sub arn:aws:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPythonV2:1 + ``` -=== "Serverless framework" + === "Serverless framework" - ```yaml hl_lines="5" - functions: - hello: - handler: lambda_function.lambda_handler - layers: - - arn:aws:lambda:${aws:region}:017000801446:layer:AWSLambdaPowertoolsPython:38 - ``` + ```yaml hl_lines="5" + functions: + hello: + handler: lambda_function.lambda_handler + layers: + - arn:aws:lambda:${aws:region}:017000801446:layer:AWSLambdaPowertoolsPythonV2:1 + ``` -=== "CDK" + === "CDK" - ```python hl_lines="11 16" - from aws_cdk import core, aws_lambda + ```python hl_lines="11 16" + from aws_cdk import core, aws_lambda - class SampleApp(core.Construct): + class SampleApp(core.Construct): - def __init__(self, scope: core.Construct, id_: str, env: core.Environment) -> None: - super().__init__(scope, id_) + def __init__(self, scope: core.Construct, id_: str, env: core.Environment) -> None: + super().__init__(scope, id_) - powertools_layer = aws_lambda.LayerVersion.from_layer_version_arn( - self, - id="lambda-powertools", - layer_version_arn=f"arn:aws:lambda:{env.region}:017000801446:layer:AWSLambdaPowertoolsPython:38" - ) - aws_lambda.Function(self, - 'sample-app-lambda', - runtime=aws_lambda.Runtime.PYTHON_3_9, - layers=[powertools_layer] - # other props... - ) - ``` + powertools_layer = aws_lambda.LayerVersion.from_layer_version_arn( + self, + id="lambda-powertools", + layer_version_arn=f"arn:aws:lambda:{env.region}:017000801446:layer:AWSLambdaPowertoolsPythonV2:1" + ) + aws_lambda.Function(self, + 'sample-app-lambda', + runtime=aws_lambda.Runtime.PYTHON_3_9, + layers=[powertools_layer] + # other props... + ) + ``` -=== "Terraform" + === "Terraform" - ```terraform hl_lines="9 38" - terraform { - required_version = "~> 1.0.5" - required_providers { - aws = "~> 3.50.0" - } - } + ```terraform hl_lines="9 38" + terraform { + required_version = "~> 1.0.5" + required_providers { + aws = "~> 3.50.0" + } + } - provider "aws" { - region = "{region}" - } + provider "aws" { + region = "{region}" + } - resource "aws_iam_role" "iam_for_lambda" { - name = "iam_for_lambda" + resource "aws_iam_role" "iam_for_lambda" { + name = "iam_for_lambda" - assume_role_policy = < + ? 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:AWSLambdaPowertoolsPythonV2:1 + ❯ 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:AWSLambdaPowertoolsPythonV2:1 + ? 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` - ``` + ```bash title="AWS CLI" + aws lambda get-layer-version-by-arn --arn arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPythonV2:1 --region {region} + ``` -=== "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:38 - ❯ 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:38 - ? Do you want to edit the local lambda function now? No - ``` + The pre-signed URL to download this Lambda Layer will be within `Location` key. -=== "Get the Layer .zip contents" - Change {region} to your AWS region, e.g. `eu-west-1` +=== "arm64" - ```bash title="AWS CLI" - aws lambda get-layer-version-by-arn --arn arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPython:38 --region {region} - ``` + === "SAM" + + ```yaml hl_lines="6" + MyLambdaFunction: + Type: AWS::Serverless::Function + Properties: + Architectures: [arm64] + Layers: + - !Sub arn:aws:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1 + ``` + + === "Serverless framework" + + ```yaml hl_lines="6" + functions: + hello: + handler: lambda_function.lambda_handler + architecture: arm64 + layers: + - arn:aws:lambda:${aws:region}:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1 + ``` + + === "CDK" + + ```python hl_lines="11 17" + from aws_cdk import core, aws_lambda + + class SampleApp(core.Construct): + + def __init__(self, scope: core.Construct, id_: str, env: core.Environment) -> None: + super().__init__(scope, id_) + + powertools_layer = aws_lambda.LayerVersion.from_layer_version_arn( + self, + id="lambda-powertools", + layer_version_arn=f"arn:aws:lambda:{env.region}:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1" + ) + aws_lambda.Function(self, + 'sample-app-lambda', + runtime=aws_lambda.Runtime.PYTHON_3_9, + architecture=aws_lambda.Architecture.ARM_64, + layers=[powertools_layer] + # other props... + ) + ``` + + === "Terraform" + + ```terraform hl_lines="9 37" + terraform { + required_version = "~> 1.0.5" + required_providers { + aws = "~> 3.50.0" + } + } + + provider "aws" { + region = "{region}" + } + + resource "aws_iam_role" "iam_for_lambda" { + name = "iam_for_lambda" + + assume_role_policy = < + ? 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:AWSLambdaPowertoolsPythonV2-Arm64:1 + ❯ 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:AWSLambdaPowertoolsPythonV2-Arm64:1 + ? 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` + + ```bash title="AWS CLI" + aws lambda get-layer-version-by-arn --arn arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1 --region {region} + ``` + + The pre-signed URL to download this Lambda Layer will be within `Location` key. ???+ warning "Warning: Limitations" @@ -218,13 +376,10 @@ Serverless Application Repository (SAR) App deploys a CloudFormation stack with 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](#){: .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`. +| App | ARN | Description | +|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------| +| [aws-lambda-powertools-python-layer-v2](https://serverlessrepo.aws.amazon.com/applications/eu-west-1/057560766410/aws-lambda-powertools-python-layer-v2) | [arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer-v2](#){: .copyMe}:clipboard: | Contains all extra dependencies (e.g: pydantic). | +| [aws-lambda-powertools-python-layer-v2-arm64](https://serverlessrepo.aws.amazon.com/applications/eu-west-1/057560766410/aws-lambda-powertools-python-layer-v2-arm64) | [arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer-v2-arm64](#){: .copyMe}:clipboard: | Contains all extra dependencies (e.g: pydantic). For arm64 functions. | ???+ tip You can create a shared Lambda Layers stack and make this along with other account level layers stack. @@ -238,8 +393,8 @@ If using SAM, you can include this SAR App as part of your shared Layers stack, Type: AWS::Serverless::Application Properties: Location: - ApplicationId: arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer - SemanticVersion: 1.30.0 # change to latest semantic version available in SAR + ApplicationId: arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer-v2 + SemanticVersion: 2.0.0 # change to latest semantic version available in SAR MyLambdaFunction: Type: AWS::Serverless::Function @@ -265,9 +420,9 @@ If using SAM, you can include this SAR App as part of your shared Layers stack, Type: AWS::Serverless::Application Properties: Location: - ApplicationId: arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer + ApplicationId: arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer-v2 # Find latest from github.com/awslabs/aws-lambda-powertools-python/releases - SemanticVersion: 1.30.0 + SemanticVersion: 2.0.0 ``` === "CDK" @@ -277,8 +432,8 @@ 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.30.0' - POWERTOOLS_ARN = 'arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer' + POWERTOOLS_VER = '2.0.0' + POWERTOOLS_ARN = 'arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer-v2' class SampleApp(core.Construct): @@ -335,13 +490,13 @@ If using SAM, you can include this SAR App as part of your shared Layers stack, } data "aws_serverlessapplicationrepository_application" "sar_app" { - application_id = "arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer" + application_id = "arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer-v2" semantic_version = var.aws_powertools_version } variable "aws_powertools_version" { type = string - default = "1.30.0" + default = "2.0.0" description = "The AWS Powertools release version" } @@ -398,7 +553,7 @@ If using SAM, you can include this SAR App as part of your shared Layers stack, - serverlessrepo:GetCloudFormationTemplate Resource: # this is arn of the powertools SAR app - - arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer + - arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer-v2 - Sid: S3AccessLayer Effect: Allow Action: @@ -415,7 +570,7 @@ If using SAM, you can include this SAR App as part of your shared Layers stack, - lambda:PublishLayerVersion - lambda:GetLayerVersion Resource: - - !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:layer:aws-lambda-powertools-python-layer* + - !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:layer:aws-lambda-powertools-python-layer-v2* Roles: - Ref: "PowertoolsLayerIamRole" ``` @@ -424,7 +579,7 @@ You can fetch available versions via SAR ListApplicationVersions API: ```bash title="AWS CLI example" aws serverlessrepo list-application-versions \ - --application-id arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer + --application-id arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer-v2 ``` ## Quick getting started diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 194e4e2ba08..ca6ab06903d 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -1,5 +1,5 @@ .md-grid { - max-width: 81vw + max-width: 90vw } .highlight .hll { diff --git a/layer/scripts/update_layer_arn.sh b/layer/scripts/update_layer_arn.sh new file mode 100755 index 00000000000..b007b2d35cc --- /dev/null +++ b/layer/scripts/update_layer_arn.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +# This script is run during the reusable_update_v2_layer_arn_docs CI job, +# and it is responsible for replacing the layer ARN in our documentation, +# based on the output files generated by CDK when deploying to each pseudo_region. +# +# see .github/workflows/reusable_deploy_v2_layer_stack.yml + +set -eo pipefail + +if [[ $# -ne 1 ]]; then + cat < line + # sed doesn't support \d+ in a portable way, so we cheat with (:digit: :digit: *) + sed -i '' -e "s/$prefix:[[:digit:]][[:digit:]]*/$line/g" docs/index.md + + # We use the eu-central-1 layer as the version for all the frameworks (SAM, CDK, SLS, etc) + # We could have used any other region. What's important is the version at the end. + + # Examples of strings found in the documentation with pseudo regions: + # arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPython:39 + # arn:aws:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPython:39 + # arn:aws:lambda:${aws:region}:017000801446:layer:AWSLambdaPowertoolsPython:39 + # arn:aws:lambda:{env.region}:017000801446:layer:AWSLambdaPowertoolsPython:39 + if [[ "$line" == *"eu-central-1"* ]]; then + # These are all the framework pseudo parameters currently found in the docs + for pseudo_region in '{region}' '${AWS::Region}' '${aws:region}' '{env.region}' + do + prefix_pseudo_region=$(echo "$prefix" | sed "s/eu-central-1/${pseudo_region}/") + # prefix_pseudo_region = arn:aws:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPython + + line_pseudo_region=$(echo "$line" | sed "s/eu-central-1/${pseudo_region}/") + # line_pseudo_region = arn:aws:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPython:49 + + # Replace all the "prefix_pseudo_region"'s in the file + # prefix_pseudo_region:\d+ ==> line_pseudo_region + sed -i '' -e "s/$prefix_pseudo_region:[[:digit:]][[:digit:]]*/$line_pseudo_region/g" docs/index.md + done + fi + done +done From 648501bbe6313cdff421742614ed6d64c12c7159 Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Tue, 18 Oct 2022 11:09:12 +0200 Subject: [PATCH 22/30] chore(deps): remove email-validator; use Str over EmailStr in SES model (#1608) Co-authored-by: Huon Wilson Co-authored-by: Manuel Ochoa --- .../utilities/parser/models/ses.py | 9 ++- layer/layer/canary/app.py | 6 +- poetry.lock | 64 +++++-------------- pyproject.toml | 5 +- tox.ini | 3 +- 5 files changed, 25 insertions(+), 62 deletions(-) diff --git a/aws_lambda_powertools/utilities/parser/models/ses.py b/aws_lambda_powertools/utilities/parser/models/ses.py index 70fd2e83978..77b23431099 100644 --- a/aws_lambda_powertools/utilities/parser/models/ses.py +++ b/aws_lambda_powertools/utilities/parser/models/ses.py @@ -2,7 +2,6 @@ from typing import List, Optional from pydantic import BaseModel, Field -from pydantic.networks import EmailStr from pydantic.types import PositiveInt from ..types import Literal @@ -21,7 +20,7 @@ class SesReceiptAction(BaseModel): class SesReceipt(BaseModel): timestamp: datetime processingTimeMillis: PositiveInt - recipients: List[EmailStr] + recipients: List[str] spamVerdict: SesReceiptVerdict virusVerdict: SesReceiptVerdict spfVerdict: SesReceiptVerdict @@ -41,7 +40,7 @@ class SesMailCommonHeaders(BaseModel): bcc: Optional[List[str]] sender: Optional[List[str]] reply_to: Optional[List[str]] = Field(None, alias="reply-to") - returnPath: EmailStr + returnPath: str messageId: str date: str subject: str @@ -49,9 +48,9 @@ class SesMailCommonHeaders(BaseModel): class SesMail(BaseModel): timestamp: datetime - source: EmailStr + source: str messageId: str - destination: List[EmailStr] + destination: List[str] headersTruncated: bool headers: List[SesMailHeaders] commonHeaders: SesMailCommonHeaders diff --git a/layer/layer/canary/app.py b/layer/layer/canary/app.py index b577eff7fa5..e9d8d5d7679 100644 --- a/layer/layer/canary/app.py +++ b/layer/layer/canary/app.py @@ -5,7 +5,7 @@ from importlib.metadata import version import boto3 -from pydantic import EmailStr +from pydantic import HttpUrl from aws_lambda_powertools import Logger, Metrics, Tracer from aws_lambda_powertools.utilities.parser import BaseModel, envelopes, event_parser @@ -22,12 +22,12 @@ event_bus_arn = os.getenv("VERSION_TRACKING_EVENT_BUS_ARN") -# Model to check parser imports correctly, tests for pydantic and email-validator +# Model to check parser imports correctly, tests for pydantic class OrderItem(BaseModel): order_id: int quantity: int description: str - email: EmailStr + url: HttpUrl # Tests for jmespath presence diff --git a/poetry.lock b/poetry.lock index 1f90755db23..631f5d5ea4f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -238,34 +238,6 @@ category = "dev" optional = false python-versions = ">=3.5" -[[package]] -name = "dnspython" -version = "2.2.1" -description = "DNS toolkit" -category = "main" -optional = true -python-versions = ">=3.6,<4.0" - -[package.extras] -dnssec = ["cryptography (>=2.6,<37.0)"] -curio = ["curio (>=1.2,<2.0)", "sniffio (>=1.1,<2.0)"] -doh = ["h2 (>=4.1.0)", "httpx (>=0.21.1)", "requests (>=2.23.0,<3.0.0)", "requests-toolbelt (>=0.9.1,<0.10.0)"] -idna = ["idna (>=2.1,<4.0)"] -trio = ["trio (>=0.14,<0.20)"] -wmi = ["wmi (>=1.5.1,<2.0.0)"] - -[[package]] -name = "email-validator" -version = "1.3.0" -description = "A robust email address syntax and deliverability validation library." -category = "main" -optional = true -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" - -[package.dependencies] -dnspython = ">=1.15.0" -idna = ">=2.0.0" - [[package]] name = "eradicate" version = "2.1.0" @@ -493,7 +465,7 @@ typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\"" name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" +category = "dev" optional = false python-versions = ">=3.5" @@ -663,7 +635,7 @@ dev = ["shtab", "flake8 (>=3.0)", "coverage"] [[package]] name = "mkdocs" -version = "1.4.0" +version = "1.4.1" description = "Project documentation with Markdown." category = "dev" optional = false @@ -671,18 +643,20 @@ python-versions = ">=3.7" [package.dependencies] click = ">=7.0" +colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} ghp-import = ">=1.0" importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} -Jinja2 = ">=2.11.1" -Markdown = ">=3.2.1,<3.4" +jinja2 = ">=2.11.1" +markdown = ">=3.2.1,<3.4" mergedeep = ">=1.3.4" packaging = ">=20.5" -PyYAML = ">=5.1" +pyyaml = ">=5.1" pyyaml-env-tag = ">=0.1" typing-extensions = {version = ">=3.10", markers = "python_version < \"3.8\""} watchdog = ">=2.0" [package.extras] +min-versions = ["watchdog (==2.0)", "typing-extensions (==3.10)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "packaging (==20.5)", "mergedeep (==1.3.4)", "markupsafe (==2.0.1)", "markdown (==3.2.1)", "jinja2 (==2.11.1)", "importlib-metadata (==4.3)", "ghp-import (==1.0)", "colorama (==0.4)", "click (==7.0)", "babel (==2.9.0)"] i18n = ["babel (>=2.9.0)"] [[package]] @@ -1300,7 +1274,7 @@ types-urllib3 = "<1.27" [[package]] name = "types-urllib3" -version = "1.26.25" +version = "1.26.25.1" description = "Typing stubs for urllib3" category = "dev" optional = false @@ -1383,16 +1357,16 @@ docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo" testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "jaraco.functools", "more-itertools", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] [extras] -all = ["pydantic", "email-validator", "aws-xray-sdk", "fastjsonschema"] +all = ["pydantic", "aws-xray-sdk", "fastjsonschema"] aws-sdk = ["boto3"] -parser = ["pydantic", "email-validator"] +parser = ["pydantic"] tracer = ["aws-xray-sdk"] validation = ["fastjsonschema"] [metadata] lock-version = "1.1" python-versions = "^3.7.4" -content-hash = "bc65bd1113d0d6d5494bd63fc42cbd98bfe00322d8da3f510e1600a7dbdd727c" +content-hash = "125238a77d9f356c0f956e2b5a568833603594412e5cda7e996c91050b90e1ee" [metadata.files] attrs = [ @@ -1534,14 +1508,6 @@ decorator = [ {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, ] -dnspython = [ - {file = "dnspython-2.2.1-py3-none-any.whl", hash = "sha256:a851e51367fb93e9e1361732c1d60dab63eff98712e503ea7d92e6eccb109b4f"}, - {file = "dnspython-2.2.1.tar.gz", hash = "sha256:0f7569a4a6ff151958b64304071d370daa3243d15941a7beedf0c9fe5105603e"}, -] -email-validator = [ - {file = "email_validator-1.3.0-py2.py3-none-any.whl", hash = "sha256:816073f2a7cffef786b29928f58ec16cdac42710a53bb18aa94317e3e145ec5c"}, - {file = "email_validator-1.3.0.tar.gz", hash = "sha256:553a66f8be2ec2dea641ae1d3f29017ab89e9d603d4a25cdaac39eefa283d769"}, -] eradicate = [ {file = "eradicate-2.1.0-py3-none-any.whl", hash = "sha256:8bfaca181db9227dc88bdbce4d051a9627604c2243e7d85324f6d6ce0fd08bb2"}, {file = "eradicate-2.1.0.tar.gz", hash = "sha256:aac7384ab25b1bf21c4c012de9b4bf8398945a14c98c911545b2ea50ab558014"}, @@ -1711,8 +1677,8 @@ mike = [ {file = "mike-1.1.2.tar.gz", hash = "sha256:56c3f1794c2d0b5fdccfa9b9487beb013ca813de2e3ad0744724e9d34d40b77b"}, ] mkdocs = [ - {file = "mkdocs-1.4.0-py3-none-any.whl", hash = "sha256:ce057e9992f017b8e1496b591b6c242cbd34c2d406e2f9af6a19b97dd6248faa"}, - {file = "mkdocs-1.4.0.tar.gz", hash = "sha256:e5549a22d59e7cb230d6a791edd2c3d06690908454c0af82edc31b35d57e3069"}, + {file = "mkdocs-1.4.1-py3-none-any.whl", hash = "sha256:2b7845c2775396214cd408753e4cfb01af3cfed36acc141a84bce2ceec9d705d"}, + {file = "mkdocs-1.4.1.tar.gz", hash = "sha256:07ed90be4062e4ef732bbac2623097b9dca35c67b562c38cfd0bfbc7151758c1"}, ] mkdocs-git-revision-date-plugin = [ {file = "mkdocs_git_revision_date_plugin-0.3.2-py3-none-any.whl", hash = "sha256:2e67956cb01823dd2418e2833f3623dee8604cdf223bddd005fe36226a56f6ef"}, @@ -2086,8 +2052,8 @@ types-requests = [ {file = "types_requests-2.28.11.2-py3-none-any.whl", hash = "sha256:14941f8023a80b16441b3b46caffcbfce5265fd14555844d6029697824b5a2ef"}, ] types-urllib3 = [ - {file = "types-urllib3-1.26.25.tar.gz", hash = "sha256:5aef0e663724eef924afa8b320b62ffef2c1736c1fa6caecfc9bc6c8ae2c3def"}, - {file = "types_urllib3-1.26.25-py3-none-any.whl", hash = "sha256:c1d78cef7bd581e162e46c20a57b2e1aa6ebecdcf01fd0713bb90978ff3e3427"}, + {file = "types-urllib3-1.26.25.1.tar.gz", hash = "sha256:a948584944b2412c9a74b9cf64f6c48caf8652cb88b38361316f6d15d8a184cd"}, + {file = "types_urllib3-1.26.25.1-py3-none-any.whl", hash = "sha256:f6422596cc9ee5fdf68f9d547f541096a20c2dcfd587e37c804c9ea720bf5cb2"}, ] typing-extensions = [ {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, diff --git a/pyproject.toml b/pyproject.toml index 703b3978eee..c249619106b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,6 @@ python = "^3.7.4" aws-xray-sdk = { version = "^2.8.0", optional = true } fastjsonschema = { version = "^2.14.5", optional = true } pydantic = { version = "^1.8.2", optional = true } -email-validator = { version = "^1.3.0", optional = true } boto3 = { version = "^1.20.32", optional = true } [tool.poetry.dev-dependencies] @@ -77,10 +76,10 @@ mypy-boto3-appconfigdata = "^1.24.36" importlib-metadata = "^4.13" [tool.poetry.extras] -parser = ["pydantic", "email-validator"] +parser = ["pydantic"] validation = ["fastjsonschema"] tracer = ["aws-xray-sdk"] -all = ["pydantic", "email-validator", "aws-xray-sdk", "fastjsonschema"] +all = ["pydantic", "aws-xray-sdk", "fastjsonschema"] # allow customers to run code locally without emulators (SAM CLI, etc.) aws-sdk = ["boto3"] [tool.coverage.run] diff --git a/tox.ini b/tox.ini index 286b1c10ab0..20eef002f9d 100644 --- a/tox.ini +++ b/tox.ini @@ -6,10 +6,9 @@ deps = filelock pytest-xdist pydantic - email-validator commands = python parallel_run_e2e.py ; If you ever encounter another parallel lock across interpreters ; pip install tox tox-poetry -; tox -p --parallel-live \ No newline at end of file +; tox -p --parallel-live From ec96b14e59e71076d7f8ecefe2027e07dcda75e1 Mon Sep 17 00:00:00 2001 From: Heitor Lessa Date: Tue, 18 Oct 2022 12:18:13 +0200 Subject: [PATCH 23/30] chore(dep): add cfn-lint as a dev dependency; pre-commit (#1612) --- .pre-commit-config.yaml | 11 +- poetry.lock | 325 ++++++++++++++++++++++++++++++++++------ pyproject.toml | 4 + 3 files changed, 288 insertions(+), 52 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0ffeed560f4..a9f34e20593 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,11 +34,14 @@ repos: hooks: - id: markdownlint args: ["--fix"] - - repo: https://github.com/aws-cloudformation/cfn-python-lint - rev: v0.61.1 + - repo: local hooks: - - id: cfn-python-lint - files: examples/.*\.(yaml|yml)$ + - id: cloudformation + name: linting::cloudformation + entry: poetry run cfn-lint + language: system + types: [yaml] + files: examples/.* - repo: https://github.com/rhysd/actionlint rev: v1.6.21 hooks: diff --git a/poetry.lock b/poetry.lock index 631f5d5ea4f..c775e1714d8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -7,34 +7,36 @@ optional = false python-versions = ">=3.5" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] -docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "cloudpickle"] +dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] +docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] +tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] +tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] [[package]] -name = "aws-cdk-lib" -version = "2.46.0" -description = "Version 2 of the AWS Cloud Development Kit library" +name = "aws-cdk.aws-apigatewayv2-alpha" +version = "2.46.0a0" +description = "The CDK Construct Library for AWS::APIGatewayv2" category = "dev" optional = false python-versions = "~=3.7" [package.dependencies] +aws-cdk-lib = ">=2.46.0,<3.0.0" constructs = ">=10.0.0,<11.0.0" jsii = ">=1.69.0,<2.0.0" publication = ">=0.0.3" typeguard = ">=2.13.3,<2.14.0" [[package]] -name = "aws-cdk.aws-apigatewayv2-alpha" +name = "aws-cdk.aws-apigatewayv2-integrations-alpha" version = "2.46.0a0" -description = "The CDK Construct Library for AWS::APIGatewayv2" +description = "Integrations for AWS APIGateway V2" category = "dev" optional = false python-versions = "~=3.7" [package.dependencies] +"aws-cdk.aws-apigatewayv2-alpha" = "2.46.0.a0" aws-cdk-lib = ">=2.46.0,<3.0.0" constructs = ">=10.0.0,<11.0.0" jsii = ">=1.69.0,<2.0.0" @@ -42,21 +44,34 @@ publication = ">=0.0.3" typeguard = ">=2.13.3,<2.14.0" [[package]] -name = "aws-cdk.aws-apigatewayv2-integrations-alpha" -version = "2.46.0a0" -description = "Integrations for AWS APIGateway V2" +name = "aws-cdk-lib" +version = "2.46.0" +description = "Version 2 of the AWS Cloud Development Kit library" category = "dev" optional = false python-versions = "~=3.7" [package.dependencies] -aws-cdk-lib = ">=2.46.0,<3.0.0" -"aws-cdk.aws-apigatewayv2-alpha" = "2.46.0.a0" constructs = ">=10.0.0,<11.0.0" jsii = ">=1.69.0,<2.0.0" publication = ">=0.0.3" typeguard = ">=2.13.3,<2.14.0" +[[package]] +name = "aws-sam-translator" +version = "1.53.0" +description = "AWS SAM Translator is a library that transform SAM templates into AWS CloudFormation templates" +category = "dev" +optional = false +python-versions = ">=3.7, <=4.0, !=4.0" + +[package.dependencies] +boto3 = ">=1.19.5,<2.0.0" +jsonschema = ">=3.2,<4.0" + +[package.extras] +dev = ["black (==20.8b1)", "boto3 (>=1.23,<2)", "boto3-stubs[appconfig,serverlessrepo] (>=1.19.5,<2.0.0)", "click (>=7.1,<8.0)", "coverage (>=5.3,<6.0)", "dateparser (>=0.7,<1.0)", "docopt (>=0.6.2,<0.7.0)", "flake8 (>=3.8.4,<3.9.0)", "mypy (==0.971)", "parameterized (>=0.7.4,<0.8.0)", "pylint (>=2.9.0,<2.10.0)", "pytest (>=6.2.5,<6.3.0)", "pytest-cov (>=2.10.1,<2.11.0)", "pytest-env (>=0.6.2,<0.7.0)", "pytest-xdist (>=2.5,<3.0)", "pyyaml (>=5.4,<6.0)", "requests (>=2.24.0,<2.25.0)", "tenacity (>=7.0.0,<7.1.0)", "tox (>=3.24,<4.0)", "types-PyYAML (>=5.4,<6.0)", "types-jsonschema (>=3.2,<4.0)"] + [[package]] name = "aws-xray-sdk" version = "2.10.0" @@ -84,9 +99,9 @@ PyYAML = ">=5.3.1" stevedore = ">=1.20.0" [package.extras] -test = ["coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)", "toml", "beautifulsoup4 (>=4.8.0)", "pylint (==1.9.4)"] +test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)", "toml"] toml = ["toml"] -yaml = ["pyyaml"] +yaml = ["PyYAML"] [[package]] name = "black" @@ -164,6 +179,24 @@ category = "dev" optional = false python-versions = ">=3.6" +[[package]] +name = "cfn-lint" +version = "0.67.0" +description = "Checks CloudFormation templates for practices and behaviour that could potentially be improved" +category = "dev" +optional = false +python-versions = ">=3.7, <=4.0, !=4.0" + +[package.dependencies] +aws-sam-translator = ">=1.52.0" +jschema-to-python = ">=1.2.3,<1.3.0" +jsonpatch = "*" +jsonschema = ">=3.0,<5" +junit-xml = ">=1.9,<2.0" +networkx = ">=2.4,<3.0" +pyyaml = ">5.4" +sarif-om = ">=1.0.4,<1.1.0" + [[package]] name = "charset-normalizer" version = "2.1.1" @@ -173,7 +206,7 @@ optional = false python-versions = ">=3.6.0" [package.extras] -unicode_backport = ["unicodedata2"] +unicode-backport = ["unicodedata2"] [[package]] name = "checksumdir" @@ -277,7 +310,7 @@ optional = true python-versions = "*" [package.extras] -devel = ["colorama", "jsonschema", "json-spec", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"] +devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"] [[package]] name = "filelock" @@ -416,6 +449,9 @@ category = "dev" optional = false python-versions = "*" +[package.dependencies] +setuptools = "*" + [[package]] name = "future" version = "0.18.2" @@ -436,7 +472,7 @@ python-versions = "*" python-dateutil = ">=2.8.1" [package.extras] -dev = ["wheel", "flake8", "markdown", "twine"] +dev = ["flake8", "markdown", "twine", "wheel"] [[package]] name = "gitdb" @@ -482,9 +518,9 @@ typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] -docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "jaraco.tidelift (>=1.4)"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] perf = ["ipython"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] +testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] [[package]] name = "iniconfig" @@ -503,10 +539,10 @@ optional = false python-versions = ">=3.6.1,<4.0" [package.extras] -pipfile_deprecated_finder = ["pipreqs", "requirementslib"] -requirements_deprecated_finder = ["pipreqs", "pip-api"] colors = ["colorama (>=0.4.3,<0.5.0)"] +pipfile-deprecated-finder = ["pipreqs", "requirementslib"] plugins = ["setuptools"] +requirements-deprecated-finder = ["pip-api", "pipreqs"] [[package]] name = "jinja2" @@ -530,6 +566,19 @@ category = "main" optional = false python-versions = ">=3.7" +[[package]] +name = "jschema-to-python" +version = "1.2.3" +description = "Generate source code for Python classes from a JSON schema." +category = "dev" +optional = false +python-versions = ">= 2.7" + +[package.dependencies] +attrs = "*" +jsonpickle = "*" +pbr = "*" + [[package]] name = "jsii" version = "1.69.0" @@ -546,6 +595,71 @@ python-dateutil = "*" typeguard = ">=2.13.3,<2.14.0" typing-extensions = ">=3.7,<5.0" +[[package]] +name = "jsonpatch" +version = "1.32" +description = "Apply JSON-Patches (RFC 6902)" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +jsonpointer = ">=1.9" + +[[package]] +name = "jsonpickle" +version = "2.2.0" +description = "Python library for serializing any arbitrary object graph into JSON" +category = "dev" +optional = false +python-versions = ">=2.7" + +[package.dependencies] +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} + +[package.extras] +docs = ["jaraco.packaging (>=3.2)", "rst.linker (>=1.9)", "sphinx"] +testing = ["ecdsa", "enum34", "feedparser", "jsonlib", "numpy", "pandas", "pymongo", "pytest (>=3.5,!=3.7.3)", "pytest-black-multipy", "pytest-checkdocs (>=1.2.3)", "pytest-cov", "pytest-flake8 (<1.1.0)", "pytest-flake8 (>=1.1.1)", "scikit-learn", "sqlalchemy"] +testing-libs = ["simplejson", "ujson", "yajl"] + +[[package]] +name = "jsonpointer" +version = "2.3" +description = "Identify specific nodes in a JSON document (RFC 6901)" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "jsonschema" +version = "3.2.0" +description = "An implementation of JSON Schema validation for Python" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +attrs = ">=17.4.0" +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} +pyrsistent = ">=0.14.0" +setuptools = "*" +six = ">=1.11.0" + +[package.extras] +format = ["idna", "jsonpointer (>1.13)", "rfc3987", "strict-rfc3339", "webcolors"] +format-nongpl = ["idna", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "webcolors"] + +[[package]] +name = "junit-xml" +version = "1.9" +description = "Creates JUnit XML test result documents that can be read by tools such as Jenkins" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +six = "*" + [[package]] name = "mako" version = "1.2.3" @@ -559,7 +673,7 @@ importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} MarkupSafe = ">=0.9.2" [package.extras] -babel = ["babel"] +babel = ["Babel"] lingua = ["lingua"] testing = ["pytest"] @@ -575,7 +689,7 @@ python-versions = "*" six = "*" [package.extras] -restructuredText = ["rst2ansi"] +restructuredtext = ["rst2ansi"] [[package]] name = "markdown" @@ -630,8 +744,8 @@ pyyaml = ">=5.1" verspec = "*" [package.extras] -test = ["shtab", "flake8 (>=3.0)", "coverage"] -dev = ["shtab", "flake8 (>=3.0)", "coverage"] +dev = ["coverage", "flake8 (>=3.0)", "shtab"] +test = ["coverage", "flake8 (>=3.0)", "shtab"] [[package]] name = "mkdocs" @@ -656,8 +770,8 @@ typing-extensions = {version = ">=3.10", markers = "python_version < \"3.8\""} watchdog = ">=2.0" [package.extras] -min-versions = ["watchdog (==2.0)", "typing-extensions (==3.10)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "packaging (==20.5)", "mergedeep (==1.3.4)", "markupsafe (==2.0.1)", "markdown (==3.2.1)", "jinja2 (==2.11.1)", "importlib-metadata (==4.3)", "ghp-import (==1.0)", "colorama (==0.4)", "click (==7.0)", "babel (==2.9.0)"] i18n = ["babel (>=2.9.0)"] +min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.3)", "jinja2 (==2.11.1)", "markdown (==3.2.1)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "packaging (==20.5)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "typing-extensions (==3.10)", "watchdog (==2.0)"] [[package]] name = "mkdocs-git-revision-date-plugin" @@ -845,6 +959,21 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "networkx" +version = "2.6.3" +description = "Python package for creating and manipulating graphs and networks" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +default = ["matplotlib (>=3.3)", "numpy (>=1.19)", "pandas (>=1.1)", "scipy (>=1.5,!=1.6.1)"] +developer = ["black (==21.5b1)", "pre-commit (>=2.12)"] +doc = ["nb2plots (>=0.6)", "numpydoc (>=1.1)", "pillow (>=8.2)", "pydata-sphinx-theme (>=0.6,<1.0)", "sphinx (>=4.0,<5.0)", "sphinx-gallery (>=0.9,<1.0)", "texext (>=0.6.6)"] +extra = ["lxml (>=4.5)", "pydot (>=1.4.1)", "pygraphviz (>=1.7)"] +test = ["codecov (>=2.1)", "pytest (>=6.2)", "pytest-cov (>=2.12)"] + [[package]] name = "packaging" version = "21.3" @@ -893,8 +1022,8 @@ optional = false python-versions = ">=3.7" [package.extras] -docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] -test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] +docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx (>=4)", "sphinx-autodoc-typehints (>=1.12)"] +test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] [[package]] name = "pluggy" @@ -908,8 +1037,8 @@ python-versions = ">=3.6" importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} [package.extras] -testing = ["pytest-benchmark", "pytest"] -dev = ["tox", "pre-commit"] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] [[package]] name = "publication" @@ -997,7 +1126,15 @@ optional = false python-versions = ">=3.6.8" [package.extras] -diagrams = ["railroad-diagrams", "jinja2"] +diagrams = ["jinja2", "railroad-diagrams"] + +[[package]] +name = "pyrsistent" +version = "0.18.1" +description = "Persistent/Functional/Immutable data structures" +category = "dev" +optional = false +python-versions = ">=3.7" [[package]] name = "pytest" @@ -1064,7 +1201,7 @@ coverage = {version = ">=5.2.1", extras = ["toml"]} pytest = ">=4.6" [package.extras] -testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] [[package]] name = "pytest-forked" @@ -1090,7 +1227,7 @@ python-versions = ">=3.7" pytest = ">=5.0" [package.extras] -dev = ["pre-commit", "tox", "pytest-asyncio"] +dev = ["pre-commit", "pytest-asyncio", "tox"] [[package]] name = "pytest-xdist" @@ -1177,7 +1314,7 @@ urllib3 = ">=1.21.1,<1.27" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "retry" @@ -1205,6 +1342,31 @@ botocore = ">=1.12.36,<2.0a.0" [package.extras] crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"] +[[package]] +name = "sarif-om" +version = "1.0.4" +description = "Classes implementing the SARIF 2.1.0 object model." +category = "dev" +optional = false +python-versions = ">= 2.7" + +[package.dependencies] +attrs = "*" +pbr = "*" + +[[package]] +name = "setuptools" +version = "65.5.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mock", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + [[package]] name = "six" version = "1.16.0" @@ -1258,8 +1420,8 @@ optional = false python-versions = ">=3.5.3" [package.extras] -doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] -test = ["pytest", "typing-extensions", "mypy"] +doc = ["sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["mypy", "pytest", "typing-extensions"] [[package]] name = "types-requests" @@ -1297,8 +1459,8 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" [package.extras] -brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] -secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] @@ -1310,7 +1472,7 @@ optional = false python-versions = "*" [package.extras] -test = ["pytest", "pretend", "mypy", "flake8 (>=3.7)", "coverage"] +test = ["coverage", "flake8 (>=3.7)", "mypy", "pretend", "pytest"] [[package]] name = "watchdog" @@ -1353,8 +1515,8 @@ optional = false python-versions = ">=3.7" [package.extras] -docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "jaraco.tidelift (>=1.4)"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "jaraco.functools", "more-itertools", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] +testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] [extras] all = ["pydantic", "aws-xray-sdk", "fastjsonschema"] @@ -1366,17 +1528,13 @@ validation = ["fastjsonschema"] [metadata] lock-version = "1.1" python-versions = "^3.7.4" -content-hash = "125238a77d9f356c0f956e2b5a568833603594412e5cda7e996c91050b90e1ee" +content-hash = "b5cc8cfb68d83b842c7c95beede8788b16905ef398c71b0704ed8ae83156ea30" [metadata.files] attrs = [ {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, ] -aws-cdk-lib = [ - {file = "aws-cdk-lib-2.46.0.tar.gz", hash = "sha256:ec2c6055d64a0574533fcbcdc2006ee32a23d38a5755bc4b99fd1796124b1de5"}, - {file = "aws_cdk_lib-2.46.0-py3-none-any.whl", hash = "sha256:28d76161acf834d97ab5f9a6b2003bb81345e14197474d706de7ee30847b87bd"}, -] "aws-cdk.aws-apigatewayv2-alpha" = [ {file = "aws-cdk.aws-apigatewayv2-alpha-2.46.0a0.tar.gz", hash = "sha256:10d9324da26db7aeee3a45853a2e249b6b85866fcc8f8f43fa1a0544ce582482"}, {file = "aws_cdk.aws_apigatewayv2_alpha-2.46.0a0-py3-none-any.whl", hash = "sha256:2cdeac84fb1fe219e5686ee95d9528a1810e9d426b2bb7f305ea07cb43e328a8"}, @@ -1385,6 +1543,15 @@ aws-cdk-lib = [ {file = "aws-cdk.aws-apigatewayv2-integrations-alpha-2.46.0a0.tar.gz", hash = "sha256:91a792c94500987b69fd97cb00afec5ace00f2039ffebebd99f91ee6b47c3c8b"}, {file = "aws_cdk.aws_apigatewayv2_integrations_alpha-2.46.0a0-py3-none-any.whl", hash = "sha256:c7bbe1c08019cee41c14b6c1513f673d60b337422ef338c67f9a0cb3e17cc963"}, ] +aws-cdk-lib = [ + {file = "aws-cdk-lib-2.46.0.tar.gz", hash = "sha256:ec2c6055d64a0574533fcbcdc2006ee32a23d38a5755bc4b99fd1796124b1de5"}, + {file = "aws_cdk_lib-2.46.0-py3-none-any.whl", hash = "sha256:28d76161acf834d97ab5f9a6b2003bb81345e14197474d706de7ee30847b87bd"}, +] +aws-sam-translator = [ + {file = "aws-sam-translator-1.53.0.tar.gz", hash = "sha256:392ed4f5fb08f72cb68a8800f0bc278d2a3b6609bd1ac66bfcdeaaa94cdc18e5"}, + {file = "aws_sam_translator-1.53.0-py2-none-any.whl", hash = "sha256:85252646cf123642d08442137b60445e69e30bfd2f8b663b1202b20ab3782b10"}, + {file = "aws_sam_translator-1.53.0-py3-none-any.whl", hash = "sha256:84d780ad82f1a176e2f5d4c397749d1e71214cc97ee7cccd50f823fd7c7e7cdf"}, +] aws-xray-sdk = [ {file = "aws-xray-sdk-2.10.0.tar.gz", hash = "sha256:9b14924fd0628cf92936055864655354003f0b1acc3e1c3ffde6403d0799dd7a"}, {file = "aws_xray_sdk-2.10.0-py2.py3-none-any.whl", hash = "sha256:7551e81a796e1a5471ebe84844c40e8edf7c218db33506d046fec61f7495eda4"}, @@ -1432,6 +1599,10 @@ certifi = [ {file = "certifi-2022.9.24-py3-none-any.whl", hash = "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"}, {file = "certifi-2022.9.24.tar.gz", hash = "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14"}, ] +cfn-lint = [ + {file = "cfn-lint-0.67.0.tar.gz", hash = "sha256:dfa707e06f4a530ffc9cf66c0af7a4f28b11190b7a6a22536a6c4aa6afc5ff06"}, + {file = "cfn_lint-0.67.0-py3-none-any.whl", hash = "sha256:3526213b91f1740231cac894652046daa77409a0c0ca755589ab21d5faab8fd1"}, +] charset-normalizer = [ {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, @@ -1606,10 +1777,33 @@ jmespath = [ {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, ] +jschema-to-python = [ + {file = "jschema_to_python-1.2.3-py3-none-any.whl", hash = "sha256:8a703ca7604d42d74b2815eecf99a33359a8dccbb80806cce386d5e2dd992b05"}, + {file = "jschema_to_python-1.2.3.tar.gz", hash = "sha256:76ff14fe5d304708ccad1284e4b11f96a658949a31ee7faed9e0995279549b91"}, +] jsii = [ {file = "jsii-1.69.0-py3-none-any.whl", hash = "sha256:f3ae5cdf5e854b4d59256dc1f8818cd3fabb8eb43fbd3134a8e8aef962643005"}, {file = "jsii-1.69.0.tar.gz", hash = "sha256:7c7ed2a913372add17d63322a640c6435324770eb78c6b89e4c701e07d9c84db"}, ] +jsonpatch = [ + {file = "jsonpatch-1.32-py2.py3-none-any.whl", hash = "sha256:26ac385719ac9f54df8a2f0827bb8253aa3ea8ab7b3368457bcdb8c14595a397"}, + {file = "jsonpatch-1.32.tar.gz", hash = "sha256:b6ddfe6c3db30d81a96aaeceb6baf916094ffa23d7dd5fa2c13e13f8b6e600c2"}, +] +jsonpickle = [ + {file = "jsonpickle-2.2.0-py2.py3-none-any.whl", hash = "sha256:de7f2613818aa4f234138ca11243d6359ff83ae528b2185efdd474f62bcf9ae1"}, + {file = "jsonpickle-2.2.0.tar.gz", hash = "sha256:7b272918b0554182e53dc340ddd62d9b7f902fec7e7b05620c04f3ccef479a0e"}, +] +jsonpointer = [ + {file = "jsonpointer-2.3-py2.py3-none-any.whl", hash = "sha256:51801e558539b4e9cd268638c078c6c5746c9ac96bc38152d443400e4f3793e9"}, + {file = "jsonpointer-2.3.tar.gz", hash = "sha256:97cba51526c829282218feb99dab1b1e6bdf8efd1c43dc9d57be093c0d69c99a"}, +] +jsonschema = [ + {file = "jsonschema-3.2.0-py2.py3-none-any.whl", hash = "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163"}, + {file = "jsonschema-3.2.0.tar.gz", hash = "sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a"}, +] +junit-xml = [ + {file = "junit_xml-1.9-py2.py3-none-any.whl", hash = "sha256:ec5ca1a55aefdd76d28fcc0b135251d156c7106fa979686a4b48d62b761b4732"}, +] mako = [ {file = "Mako-1.2.3-py3-none-any.whl", hash = "sha256:c413a086e38cd885088d5e165305ee8eed04e8b3f8f62df343480da0a385735f"}, {file = "Mako-1.2.3.tar.gz", hash = "sha256:7fde96466fcfeedb0eed94f187f20b23d85e4cb41444be0e542e2c8c65c396cd"}, @@ -1764,6 +1958,10 @@ mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] +networkx = [ + {file = "networkx-2.6.3-py3-none-any.whl", hash = "sha256:80b6b89c77d1dfb64a4c7854981b60aeea6360ac02c6d4e4913319e0a313abef"}, + {file = "networkx-2.6.3.tar.gz", hash = "sha256:c0946ed31d71f1b732b5aaa6da5a0388a345019af232ce2f49c766e2d6795c51"}, +] packaging = [ {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, @@ -1857,6 +2055,29 @@ pyparsing = [ {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, ] +pyrsistent = [ + {file = "pyrsistent-0.18.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:df46c854f490f81210870e509818b729db4488e1f30f2a1ce1698b2295a878d1"}, + {file = "pyrsistent-0.18.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d45866ececf4a5fff8742c25722da6d4c9e180daa7b405dc0a2a2790d668c26"}, + {file = "pyrsistent-0.18.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ed6784ceac462a7d6fcb7e9b663e93b9a6fb373b7f43594f9ff68875788e01e"}, + {file = "pyrsistent-0.18.1-cp310-cp310-win32.whl", hash = "sha256:e4f3149fd5eb9b285d6bfb54d2e5173f6a116fe19172686797c056672689daf6"}, + {file = "pyrsistent-0.18.1-cp310-cp310-win_amd64.whl", hash = "sha256:636ce2dc235046ccd3d8c56a7ad54e99d5c1cd0ef07d9ae847306c91d11b5fec"}, + {file = "pyrsistent-0.18.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e92a52c166426efbe0d1ec1332ee9119b6d32fc1f0bbfd55d5c1088070e7fc1b"}, + {file = "pyrsistent-0.18.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7a096646eab884bf8bed965bad63ea327e0d0c38989fc83c5ea7b8a87037bfc"}, + {file = "pyrsistent-0.18.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cdfd2c361b8a8e5d9499b9082b501c452ade8bbf42aef97ea04854f4a3f43b22"}, + {file = "pyrsistent-0.18.1-cp37-cp37m-win32.whl", hash = "sha256:7ec335fc998faa4febe75cc5268a9eac0478b3f681602c1f27befaf2a1abe1d8"}, + {file = "pyrsistent-0.18.1-cp37-cp37m-win_amd64.whl", hash = "sha256:6455fc599df93d1f60e1c5c4fe471499f08d190d57eca040c0ea182301321286"}, + {file = "pyrsistent-0.18.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fd8da6d0124efa2f67d86fa70c851022f87c98e205f0594e1fae044e7119a5a6"}, + {file = "pyrsistent-0.18.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bfe2388663fd18bd8ce7db2c91c7400bf3e1a9e8bd7d63bf7e77d39051b85ec"}, + {file = "pyrsistent-0.18.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e3e1fcc45199df76053026a51cc59ab2ea3fc7c094c6627e93b7b44cdae2c8c"}, + {file = "pyrsistent-0.18.1-cp38-cp38-win32.whl", hash = "sha256:b568f35ad53a7b07ed9b1b2bae09eb15cdd671a5ba5d2c66caee40dbf91c68ca"}, + {file = "pyrsistent-0.18.1-cp38-cp38-win_amd64.whl", hash = "sha256:d1b96547410f76078eaf66d282ddca2e4baae8964364abb4f4dcdde855cd123a"}, + {file = "pyrsistent-0.18.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f87cc2863ef33c709e237d4b5f4502a62a00fab450c9e020892e8e2ede5847f5"}, + {file = "pyrsistent-0.18.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bc66318fb7ee012071b2792024564973ecc80e9522842eb4e17743604b5e045"}, + {file = "pyrsistent-0.18.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:914474c9f1d93080338ace89cb2acee74f4f666fb0424896fcfb8d86058bf17c"}, + {file = "pyrsistent-0.18.1-cp39-cp39-win32.whl", hash = "sha256:1b34eedd6812bf4d33814fca1b66005805d3640ce53140ab8bbb1e2651b0d9bc"}, + {file = "pyrsistent-0.18.1-cp39-cp39-win_amd64.whl", hash = "sha256:e24a828f57e0c337c8d8bb9f6b12f09dfdf0273da25fda9e314f0b684b415a07"}, + {file = "pyrsistent-0.18.1.tar.gz", hash = "sha256:d4d61f8b993a7255ba714df3aca52700f8125289f84f704cf80916517c46eb96"}, +] pytest = [ {file = "pytest-7.1.3-py3-none-any.whl", hash = "sha256:1377bda3466d70b55e3f5cecfa55bb7cfcf219c7964629b967c37cf0bda818b7"}, {file = "pytest-7.1.3.tar.gz", hash = "sha256:4f365fec2dff9c1162f834d9f18af1ba13062db0c708bf7b946f8a5c76180c39"}, @@ -2001,6 +2222,14 @@ s3transfer = [ {file = "s3transfer-0.6.0-py3-none-any.whl", hash = "sha256:06176b74f3a15f61f1b4f25a1fc29a4429040b7647133a463da8fa5bd28d5ecd"}, {file = "s3transfer-0.6.0.tar.gz", hash = "sha256:2ed07d3866f523cc561bf4a00fc5535827981b117dd7876f036b0c1aca42c947"}, ] +sarif-om = [ + {file = "sarif_om-1.0.4-py3-none-any.whl", hash = "sha256:539ef47a662329b1c8502388ad92457425e95dc0aaaf995fe46f4984c4771911"}, + {file = "sarif_om-1.0.4.tar.gz", hash = "sha256:cd5f416b3083e00d402a92e449a7ff67af46f11241073eea0461802a3b5aef98"}, +] +setuptools = [ + {file = "setuptools-65.5.0-py3-none-any.whl", hash = "sha256:f62ea9da9ed6289bfe868cd6845968a2c854d1427f8548d52cae02a42b4f0356"}, + {file = "setuptools-65.5.0.tar.gz", hash = "sha256:512e5536220e38146176efb833d4a62aa726b7bbff82cfbc8ba9eaa3996e0b17"}, +] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, diff --git a/pyproject.toml b/pyproject.toml index c249619106b..85d2a4bd960 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,10 @@ tracer = ["aws-xray-sdk"] all = ["pydantic", "aws-xray-sdk", "fastjsonschema"] # allow customers to run code locally without emulators (SAM CLI, etc.) aws-sdk = ["boto3"] + +[tool.poetry.group.dev.dependencies] +cfn-lint = "0.67.0" + [tool.coverage.run] source = ["aws_lambda_powertools"] omit = ["tests/*", "aws_lambda_powertools/exceptions/*", "aws_lambda_powertools/utilities/parser/types.py", "aws_lambda_powertools/utilities/jmespath_utils/envelopes.py"] From 2864b46f3d96d95ca3b8da1f32488862b49cdb9e Mon Sep 17 00:00:00 2001 From: walmsles <2704782+walmsles@users.noreply.github.com> Date: Wed, 19 Oct 2022 17:57:10 +1100 Subject: [PATCH 24/30] feat(apigateway): ignore trailing slashes in routes (APIGatewayRestResolver) (#1609) Co-authored-by: Heitor Lessa --- .../event_handler/api_gateway.py | 23 ++++- docs/core/event_handler/api_gateway.md | 10 +- .../e2e/event_handler/handlers/alb_handler.py | 5 +- .../handlers/api_gateway_http_handler.py | 5 +- .../handlers/api_gateway_rest_handler.py | 5 +- .../handlers/lambda_function_url_handler.py | 5 +- .../test_paths_ending_with_slash.py | 99 +++++++++++++++++++ tests/events/albEventPathTrailingSlash.json | 28 ++++++ ...apiGatewayProxyEventPathTrailingSlash.json | 80 +++++++++++++++ ...iGatewayProxyV2EventPathTrailingSlash.json | 69 +++++++++++++ ...mbdaFunctionUrlEventPathTrailingSlash.json | 52 ++++++++++ .../event_handler/test_api_gateway.py | 76 ++++++++++++++ .../event_handler/test_lambda_function_url.py | 16 +++ 13 files changed, 462 insertions(+), 11 deletions(-) create mode 100644 tests/e2e/event_handler/test_paths_ending_with_slash.py create mode 100644 tests/events/albEventPathTrailingSlash.json create mode 100644 tests/events/apiGatewayProxyEventPathTrailingSlash.json create mode 100644 tests/events/apiGatewayProxyV2EventPathTrailingSlash.json create mode 100644 tests/events/lambdaFunctionUrlEventPathTrailingSlash.json diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 4dbf753d22c..38aaaf096db 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -46,6 +46,7 @@ # API GW/ALB decode non-safe URI chars; we must support them too _UNSAFE_URI = "%<> \[\]{}|^" # noqa: W605 _NAMED_GROUP_BOUNDARY_PATTERN = rf"(?P\1[{_SAFE_URI}{_UNSAFE_URI}\\w]+)" +_ROUTE_REGEX = "^{}$" class ProxyEventType(Enum): @@ -562,7 +563,7 @@ def _has_debug(debug: Optional[bool] = None) -> bool: return powertools_dev_is_set() @staticmethod - def _compile_regex(rule: str): + def _compile_regex(rule: str, base_regex: str = _ROUTE_REGEX): """Precompile regex pattern Logic @@ -592,7 +593,7 @@ def _compile_regex(rule: str): NOTE: See #520 for context """ rule_regex: str = re.sub(_DYNAMIC_ROUTE_PATTERN, _NAMED_GROUP_BOUNDARY_PATTERN, rule) - return re.compile("^{}$".format(rule_regex)) + return re.compile(base_regex.format(rule_regex)) def _to_proxy_event(self, event: Dict) -> BaseProxyEvent: """Convert the event dict to the corresponding data class""" @@ -819,6 +820,24 @@ def __init__( """Amazon API Gateway REST and HTTP API v1 payload resolver""" super().__init__(ProxyEventType.APIGatewayProxyEvent, cors, debug, serializer, strip_prefixes) + # override route to ignore trailing "/" in routes for REST API + def route( + self, + rule: str, + method: Union[str, Union[List[str], Tuple[str]]], + cors: Optional[bool] = None, + compress: bool = False, + cache_control: Optional[str] = None, + ): + # NOTE: see #1552 for more context. + return super().route(rule.rstrip("/"), method, cors, compress, cache_control) + + # Override _compile_regex to exclude trailing slashes for route resolution + @staticmethod + def _compile_regex(rule: str, base_regex: str = _ROUTE_REGEX): + + return super(APIGatewayRestResolver, APIGatewayRestResolver)._compile_regex(rule, "^{}/*$") + class APIGatewayHttpResolver(ApiGatewayResolver): current_event: APIGatewayProxyEventV2 diff --git a/docs/core/event_handler/api_gateway.md b/docs/core/event_handler/api_gateway.md index 8ee07890b48..ec6116403e3 100644 --- a/docs/core/event_handler/api_gateway.md +++ b/docs/core/event_handler/api_gateway.md @@ -42,10 +42,10 @@ Before you decorate your functions to handle a given path and HTTP method(s), yo A resolver will handle request resolution, including [one or more routers](#split-routes-with-router), and give you access to the current event via typed properties. -For resolvers, we provide: `APIGatewayRestResolver`, `APIGatewayHttpResolver`, `ALBResolver`, and `LambdaFunctionUrlResolver` . +For resolvers, we provide: `APIGatewayRestResolver`, `APIGatewayHttpResolver`, `ALBResolver`, and `LambdaFunctionUrlResolver`. From here on, we will default to `APIGatewayRestResolver` across examples. -???+ info - We will use `APIGatewayRestResolver` as the default across examples. +???+ info "Auto-serialization" + We serialize `Dict` responses as JSON, trim whitespace for compact responses, and set content-type to `application/json`. #### API Gateway REST API @@ -53,8 +53,8 @@ When using Amazon API Gateway REST API to front your Lambda functions, you can u Here's an example on how we can handle the `/todos` path. -???+ info - We automatically serialize `Dict` responses as JSON, trim whitespace for compact responses, and set content-type to `application/json`. +???+ info "Trailing slash in routes" + For `APIGatewayRestResolver`, we seamless handle routes with a trailing slash (`/todos/`). === "getting_started_rest_api_resolver.py" diff --git a/tests/e2e/event_handler/handlers/alb_handler.py b/tests/e2e/event_handler/handlers/alb_handler.py index 0e386c82c51..26746284aee 100644 --- a/tests/e2e/event_handler/handlers/alb_handler.py +++ b/tests/e2e/event_handler/handlers/alb_handler.py @@ -2,9 +2,12 @@ app = ALBResolver() +# The reason we use post is that whoever is writing tests can easily assert on the +# content being sent (body, headers, cookies, content-type) to reduce cognitive load. + @app.post("/todos") -def hello(): +def todos(): payload = app.current_event.json_body body = payload.get("body", "Hello World") diff --git a/tests/e2e/event_handler/handlers/api_gateway_http_handler.py b/tests/e2e/event_handler/handlers/api_gateway_http_handler.py index 9edacc1c807..1012af7b3fb 100644 --- a/tests/e2e/event_handler/handlers/api_gateway_http_handler.py +++ b/tests/e2e/event_handler/handlers/api_gateway_http_handler.py @@ -6,9 +6,12 @@ app = APIGatewayHttpResolver() +# The reason we use post is that whoever is writing tests can easily assert on the +# content being sent (body, headers, cookies, content-type) to reduce cognitive load. + @app.post("/todos") -def hello(): +def todos(): payload = app.current_event.json_body body = payload.get("body", "Hello World") diff --git a/tests/e2e/event_handler/handlers/api_gateway_rest_handler.py b/tests/e2e/event_handler/handlers/api_gateway_rest_handler.py index 3127634e995..d52e2728cab 100644 --- a/tests/e2e/event_handler/handlers/api_gateway_rest_handler.py +++ b/tests/e2e/event_handler/handlers/api_gateway_rest_handler.py @@ -6,9 +6,12 @@ app = APIGatewayRestResolver() +# The reason we use post is that whoever is writing tests can easily assert on the +# content being sent (body, headers, cookies, content-type) to reduce cognitive load. + @app.post("/todos") -def hello(): +def todos(): payload = app.current_event.json_body body = payload.get("body", "Hello World") diff --git a/tests/e2e/event_handler/handlers/lambda_function_url_handler.py b/tests/e2e/event_handler/handlers/lambda_function_url_handler.py index 884704893ae..f90037afc75 100644 --- a/tests/e2e/event_handler/handlers/lambda_function_url_handler.py +++ b/tests/e2e/event_handler/handlers/lambda_function_url_handler.py @@ -6,9 +6,12 @@ app = LambdaFunctionUrlResolver() +# The reason we use post is that whoever is writing tests can easily assert on the +# content being sent (body, headers, cookies, content-type) to reduce cognitive load. + @app.post("/todos") -def hello(): +def todos(): payload = app.current_event.json_body body = payload.get("body", "Hello World") diff --git a/tests/e2e/event_handler/test_paths_ending_with_slash.py b/tests/e2e/event_handler/test_paths_ending_with_slash.py new file mode 100644 index 00000000000..4c1461d6fc5 --- /dev/null +++ b/tests/e2e/event_handler/test_paths_ending_with_slash.py @@ -0,0 +1,99 @@ +import pytest +from requests import HTTPError, Request + +from tests.e2e.utils import data_fetcher + + +@pytest.fixture +def alb_basic_listener_endpoint(infrastructure: dict) -> str: + dns_name = infrastructure.get("ALBDnsName") + port = infrastructure.get("ALBBasicListenerPort", "") + return f"http://{dns_name}:{port}" + + +@pytest.fixture +def alb_multi_value_header_listener_endpoint(infrastructure: dict) -> str: + dns_name = infrastructure.get("ALBDnsName") + port = infrastructure.get("ALBMultiValueHeaderListenerPort", "") + return f"http://{dns_name}:{port}" + + +@pytest.fixture +def apigw_rest_endpoint(infrastructure: dict) -> str: + return infrastructure.get("APIGatewayRestUrl", "") + + +@pytest.fixture +def apigw_http_endpoint(infrastructure: dict) -> str: + return infrastructure.get("APIGatewayHTTPUrl", "") + + +@pytest.fixture +def lambda_function_url_endpoint(infrastructure: dict) -> str: + return infrastructure.get("LambdaFunctionUrl", "") + + +def test_api_gateway_rest_trailing_slash(apigw_rest_endpoint): + # GIVEN API URL ends in a trailing slash + url = f"{apigw_rest_endpoint}todos/" + body = "Hello World" + + # WHEN + response = data_fetcher.get_http_response( + Request( + method="POST", + url=url, + json={"body": body}, + ) + ) + + # THEN expect a HTTP 200 response + assert response.status_code == 200 + + +def test_api_gateway_http_trailing_slash(apigw_http_endpoint): + # GIVEN the URL for the API ends in a trailing slash API gateway should return a 404 + url = f"{apigw_http_endpoint}todos/" + body = "Hello World" + + # WHEN calling an invalid URL (with trailing slash) expect HTTPError exception from data_fetcher + with pytest.raises(HTTPError): + data_fetcher.get_http_response( + Request( + method="POST", + url=url, + json={"body": body}, + ) + ) + + +def test_lambda_function_url_trailing_slash(lambda_function_url_endpoint): + # GIVEN the URL for the API ends in a trailing slash it should behave as if there was not one + url = f"{lambda_function_url_endpoint}todos/" # the function url endpoint already has the trailing / + body = "Hello World" + + # WHEN calling an invalid URL (with trailing slash) expect HTTPError exception from data_fetcher + with pytest.raises(HTTPError): + data_fetcher.get_http_response( + Request( + method="POST", + url=url, + json={"body": body}, + ) + ) + + +def test_alb_url_trailing_slash(alb_multi_value_header_listener_endpoint): + # GIVEN url has a trailing slash - it should behave as if there was not one + url = f"{alb_multi_value_header_listener_endpoint}/todos/" + body = "Hello World" + + # WHEN calling an invalid URL (with trailing slash) expect HTTPError exception from data_fetcher + with pytest.raises(HTTPError): + data_fetcher.get_http_response( + Request( + method="POST", + url=url, + json={"body": body}, + ) + ) diff --git a/tests/events/albEventPathTrailingSlash.json b/tests/events/albEventPathTrailingSlash.json new file mode 100644 index 00000000000..c517a3f6b04 --- /dev/null +++ b/tests/events/albEventPathTrailingSlash.json @@ -0,0 +1,28 @@ +{ + "requestContext": { + "elb": { + "targetGroupArn": "arn:aws:elasticloadbalancing:us-east-2:123456789012:targetgroup/lambda-279XGJDqGZ5rsrHC2Fjr/49e9d65c45c6791a" + } + }, + "httpMethod": "GET", + "path": "/lambda/", + "queryStringParameters": { + "query": "1234ABCD" + }, + "headers": { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8", + "accept-encoding": "gzip", + "accept-language": "en-US,en;q=0.9", + "connection": "keep-alive", + "host": "lambda-alb-123578498.us-east-2.elb.amazonaws.com", + "upgrade-insecure-requests": "1", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36", + "x-amzn-trace-id": "Root=1-5c536348-3d683b8b04734faae651f476", + "x-forwarded-for": "72.12.164.125", + "x-forwarded-port": "80", + "x-forwarded-proto": "http", + "x-imforwards": "20" + }, + "body": "Test", + "isBase64Encoded": false + } \ No newline at end of file diff --git a/tests/events/apiGatewayProxyEventPathTrailingSlash.json b/tests/events/apiGatewayProxyEventPathTrailingSlash.json new file mode 100644 index 00000000000..8a321d96c8c --- /dev/null +++ b/tests/events/apiGatewayProxyEventPathTrailingSlash.json @@ -0,0 +1,80 @@ +{ + "version": "1.0", + "resource": "/my/path", + "path": "/my/path/", + "httpMethod": "GET", + "headers": { + "Header1": "value1", + "Header2": "value2" + }, + "multiValueHeaders": { + "Header1": [ + "value1" + ], + "Header2": [ + "value1", + "value2" + ] + }, + "queryStringParameters": { + "parameter1": "value1", + "parameter2": "value" + }, + "multiValueQueryStringParameters": { + "parameter1": [ + "value1", + "value2" + ], + "parameter2": [ + "value" + ] + }, + "requestContext": { + "accountId": "123456789012", + "apiId": "id", + "authorizer": { + "claims": null, + "scopes": null + }, + "domainName": "id.execute-api.us-east-1.amazonaws.com", + "domainPrefix": "id", + "extendedRequestId": "request-id", + "httpMethod": "GET", + "identity": { + "accessKey": null, + "accountId": null, + "caller": null, + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "principalOrgId": null, + "sourceIp": "192.168.0.1/32", + "user": null, + "userAgent": "user-agent", + "userArn": null, + "clientCert": { + "clientCertPem": "CERT_CONTENT", + "subjectDN": "www.example.com", + "issuerDN": "Example issuer", + "serialNumber": "a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1", + "validity": { + "notBefore": "May 28 12:30:02 2019 GMT", + "notAfter": "Aug 5 09:36:04 2021 GMT" + } + } + }, + "path": "/my/path", + "protocol": "HTTP/1.1", + "requestId": "id=", + "requestTime": "04/Mar/2020:19:15:17 +0000", + "requestTimeEpoch": 1583349317135, + "resourceId": null, + "resourcePath": "/my/path", + "stage": "$default" + }, + "pathParameters": null, + "stageVariables": null, + "body": "Hello from Lambda!", + "isBase64Encoded": true + } \ No newline at end of file diff --git a/tests/events/apiGatewayProxyV2EventPathTrailingSlash.json b/tests/events/apiGatewayProxyV2EventPathTrailingSlash.json new file mode 100644 index 00000000000..dfb0d98f2e1 --- /dev/null +++ b/tests/events/apiGatewayProxyV2EventPathTrailingSlash.json @@ -0,0 +1,69 @@ +{ + "version": "2.0", + "routeKey": "$default", + "rawPath": "/my/path/", + "rawQueryString": "parameter1=value1¶meter1=value2¶meter2=value", + "cookies": [ + "cookie1", + "cookie2" + ], + "headers": { + "Header1": "value1", + "Header2": "value1,value2" + }, + "queryStringParameters": { + "parameter1": "value1,value2", + "parameter2": "value" + }, + "requestContext": { + "accountId": "123456789012", + "apiId": "api-id", + "authentication": { + "clientCert": { + "clientCertPem": "CERT_CONTENT", + "subjectDN": "www.example.com", + "issuerDN": "Example issuer", + "serialNumber": "a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1", + "validity": { + "notBefore": "May 28 12:30:02 2019 GMT", + "notAfter": "Aug 5 09:36:04 2021 GMT" + } + } + }, + "authorizer": { + "jwt": { + "claims": { + "claim1": "value1", + "claim2": "value2" + }, + "scopes": [ + "scope1", + "scope2" + ] + } + }, + "domainName": "id.execute-api.us-east-1.amazonaws.com", + "domainPrefix": "id", + "http": { + "method": "POST", + "path": "/my/path", + "protocol": "HTTP/1.1", + "sourceIp": "192.168.0.1/32", + "userAgent": "agent" + }, + "requestId": "id", + "routeKey": "$default", + "stage": "$default", + "time": "12/Mar/2020:19:03:58 +0000", + "timeEpoch": 1583348638390 + }, + "body": "{\"message\": \"hello world\", \"username\": \"tom\"}", + "pathParameters": { + "parameter1": "value1" + }, + "isBase64Encoded": false, + "stageVariables": { + "stageVariable1": "value1", + "stageVariable2": "value2" + } + } \ No newline at end of file diff --git a/tests/events/lambdaFunctionUrlEventPathTrailingSlash.json b/tests/events/lambdaFunctionUrlEventPathTrailingSlash.json new file mode 100644 index 00000000000..b1f82265187 --- /dev/null +++ b/tests/events/lambdaFunctionUrlEventPathTrailingSlash.json @@ -0,0 +1,52 @@ +{ + "version": "2.0", + "routeKey": "$default", + "rawPath": "/my/path/", + "rawQueryString": "parameter1=value1¶meter1=value2¶meter2=value", + "cookies": [ + "cookie1", + "cookie2" + ], + "headers": { + "header1": "value1", + "header2": "value1,value2" + }, + "queryStringParameters": { + "parameter1": "value1,value2", + "parameter2": "value" + }, + "requestContext": { + "accountId": "123456789012", + "apiId": "", + "authentication": null, + "authorizer": { + "iam": { + "accessKey": "AKIA...", + "accountId": "111122223333", + "callerId": "AIDA...", + "cognitoIdentity": null, + "principalOrgId": null, + "userArn": "arn:aws:iam::111122223333:user/example-user", + "userId": "AIDA..." + } + }, + "domainName": ".lambda-url.us-west-2.on.aws", + "domainPrefix": "", + "http": { + "method": "POST", + "path": "/my/path", + "protocol": "HTTP/1.1", + "sourceIp": "123.123.123.123", + "userAgent": "agent" + }, + "requestId": "id", + "routeKey": "$default", + "stage": "$default", + "time": "12/Mar/2020:19:03:58 +0000", + "timeEpoch": 1583348638390 + }, + "body": "Hello from client!", + "pathParameters": null, + "isBase64Encoded": false, + "stageVariables": null + } \ No newline at end of file diff --git a/tests/functional/event_handler/test_api_gateway.py b/tests/functional/event_handler/test_api_gateway.py index 8491754b65e..a78d3747d28 100644 --- a/tests/functional/event_handler/test_api_gateway.py +++ b/tests/functional/event_handler/test_api_gateway.py @@ -53,6 +53,7 @@ def read_media(file_name: str) -> bytes: LOAD_GW_EVENT = load_event("apiGatewayProxyEvent.json") +LOAD_GW_EVENT_TRAILING_SLASH = load_event("apiGatewayProxyEventPathTrailingSlash.json") def test_alb_event(): @@ -76,6 +77,27 @@ def foo(): assert result["body"] == "foo" +def test_alb_event_path_trailing_slash(json_dump): + # GIVEN an Application Load Balancer proxy type event + app = ALBResolver() + + @app.get("/lambda") + def foo(): + assert isinstance(app.current_event, ALBEvent) + assert app.lambda_context == {} + assert app.current_event.request_context.elb_target_group_arn is not None + return Response(200, content_types.TEXT_HTML, "foo") + + # WHEN calling the event handler using path with trailing "/" + result = app(load_event("albEventPathTrailingSlash.json"), {}) + + # THEN + assert result["statusCode"] == 404 + assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + expected = {"statusCode": 404, "message": "Not found"} + assert result["body"] == json_dump(expected) + + def test_api_gateway_v1(): # GIVEN a Http API V1 proxy type event app = APIGatewayRestResolver() @@ -96,6 +118,23 @@ def get_lambda() -> Response: assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] +def test_api_gateway_v1_path_trailing_slash(): + # GIVEN a Http API V1 proxy type event + app = APIGatewayRestResolver() + + @app.get("/my/path") + def get_lambda() -> Response: + return Response(200, content_types.APPLICATION_JSON, json.dumps({"foo": "value"})) + + # WHEN calling the event handler + result = app(LOAD_GW_EVENT_TRAILING_SLASH, {}) + + # THEN process event correctly + # AND set the current_event type as APIGatewayProxyEvent + assert result["statusCode"] == 200 + assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] + + def test_api_gateway_v1_cookies(): # GIVEN a Http API V1 proxy type event app = APIGatewayRestResolver() @@ -134,6 +173,24 @@ def get_lambda() -> Response: assert result["body"] == "foo" +def test_api_gateway_event_path_trailing_slash(json_dump): + # GIVEN a Rest API Gateway proxy type event + app = ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent) + + @app.get("/my/path") + def get_lambda() -> Response: + assert isinstance(app.current_event, APIGatewayProxyEvent) + return Response(200, content_types.TEXT_HTML, "foo") + + # WHEN calling the event handler + result = app(LOAD_GW_EVENT_TRAILING_SLASH, {}) + # THEN + assert result["statusCode"] == 404 + assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] + expected = {"statusCode": 404, "message": "Not found"} + assert result["body"] == json_dump(expected) + + def test_api_gateway_v2(): # GIVEN a Http API V2 proxy type event app = APIGatewayHttpResolver() @@ -156,6 +213,25 @@ def my_path() -> Response: assert result["body"] == "tom" +def test_api_gateway_v2_http_path_trailing_slash(json_dump): + # GIVEN a Http API V2 proxy type event + app = APIGatewayHttpResolver() + + @app.post("/my/path") + def my_path() -> Response: + post_data = app.current_event.json_body + return Response(200, content_types.TEXT_PLAIN, post_data["username"]) + + # WHEN calling the event handler + result = app(load_event("apiGatewayProxyV2EventPathTrailingSlash.json"), {}) + + # THEN expect a 404 response + assert result["statusCode"] == 404 + assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + expected = {"statusCode": 404, "message": "Not found"} + assert result["body"] == json_dump(expected) + + def test_api_gateway_v2_cookies(): # GIVEN a Http API V2 proxy type event app = APIGatewayHttpResolver() diff --git a/tests/functional/event_handler/test_lambda_function_url.py b/tests/functional/event_handler/test_lambda_function_url.py index aacbc94129b..41baed68a7c 100644 --- a/tests/functional/event_handler/test_lambda_function_url.py +++ b/tests/functional/event_handler/test_lambda_function_url.py @@ -30,6 +30,22 @@ def foo(): assert result["body"] == "foo" +def test_lambda_function_url_event_path_trailing_slash(): + # GIVEN a Lambda Function Url type event + app = LambdaFunctionUrlResolver() + + @app.post("/my/path") + def foo(): + return Response(200, content_types.TEXT_HTML, "foo") + + # WHEN calling the event handler with an event with a trailing slash + result = app(load_event("lambdaFunctionUrlEventPathTrailingSlash.json"), {}) + + # THEN return a 404 error + assert result["statusCode"] == 404 + assert result["headers"]["Content-Type"] == content_types.APPLICATION_JSON + + def test_lambda_function_url_event_with_cookies(): # GIVEN a Lambda Function Url type event app = LambdaFunctionUrlResolver() From bf0ae43f1b2a8074941cbb738fb7bed2871e05cd Mon Sep 17 00:00:00 2001 From: Ahmed Aboshanab Date: Wed, 19 Oct 2022 17:47:45 +0200 Subject: [PATCH 25/30] feat(data-classes): replace AttributeValue in DynamoDBStreamEvent with deserialized Python values (#1619) Co-authored-by: heitorlessa --- aws_lambda_powertools/utilities/batch/base.py | 6 +- .../data_classes/dynamo_db_stream_event.py | 262 +++++++----------- docs/upgrade.md | 45 +++ docs/utilities/batch.md | 10 +- docs/utilities/data_classes.md | 16 +- tests/functional/test_data_classes.py | 193 ++++--------- tests/functional/test_utilities_batch.py | 2 +- 7 files changed, 225 insertions(+), 309 deletions(-) diff --git a/aws_lambda_powertools/utilities/batch/base.py b/aws_lambda_powertools/utilities/batch/base.py index e4a869a1e54..4f9c4ca8780 100644 --- a/aws_lambda_powertools/utilities/batch/base.py +++ b/aws_lambda_powertools/utilities/batch/base.py @@ -323,10 +323,10 @@ def lambda_handler(event, context: LambdaContext): @tracer.capture_method def record_handler(record: DynamoDBRecord): logger.info(record.dynamodb.new_image) - payload: dict = json.loads(record.dynamodb.new_image.get("item").s_value) + payload: dict = json.loads(record.dynamodb.new_image.get("item")) # alternatively: - # changes: Dict[str, dynamo_db_stream_event.AttributeValue] = record.dynamodb.new_image # noqa: E800 - # payload = change.get("Message").raw_event -> {"S": ""} + # changes: Dict[str, Any] = record.dynamodb.new_image # noqa: E800 + # payload = change.get("Message") -> "" ... @logger.inject_lambda_context diff --git a/aws_lambda_powertools/utilities/data_classes/dynamo_db_stream_event.py b/aws_lambda_powertools/utilities/data_classes/dynamo_db_stream_event.py index eb674c86b60..e62e307d67a 100644 --- a/aws_lambda_powertools/utilities/data_classes/dynamo_db_stream_event.py +++ b/aws_lambda_powertools/utilities/data_classes/dynamo_db_stream_event.py @@ -1,169 +1,100 @@ +from decimal import Clamped, Context, Decimal, Inexact, Overflow, Rounded, Underflow from enum import Enum -from typing import Any, Dict, Iterator, List, Optional, Union +from typing import Any, Callable, Dict, Iterator, Optional, Sequence, Set from aws_lambda_powertools.utilities.data_classes.common import DictWrapper +# NOTE: DynamoDB supports up to 38 digits precision +# Therefore, this ensures our Decimal follows what's stored in the table +DYNAMODB_CONTEXT = Context( + Emin=-128, + Emax=126, + prec=38, + traps=[Clamped, Overflow, Inexact, Rounded, Underflow], +) -class AttributeValueType(Enum): - Binary = "B" - BinarySet = "BS" - Boolean = "BOOL" - List = "L" - Map = "M" - Number = "N" - NumberSet = "NS" - Null = "NULL" - String = "S" - StringSet = "SS" +class TypeDeserializer: + """ + Deserializes DynamoDB types to Python types. -class AttributeValue(DictWrapper): - """Represents the data for an attribute + It's based on boto3's [DynamoDB TypeDeserializer](https://boto3.amazonaws.com/v1/documentation/api/latest/_modules/boto3/dynamodb/types.html). # noqa: E501 - Documentation: - -------------- - - https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_streams_AttributeValue.html - - https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.NamingRulesDataTypes.html + The only notable difference is that for Binary (`B`, `BS`) values we return Python Bytes directly, + since we don't support Python 2. """ - def __init__(self, data: Dict[str, Any]): - """AttributeValue constructor + def deserialize(self, value: Dict) -> Any: + """Deserialize DynamoDB data types into Python types. Parameters ---------- - data: Dict[str, Any] - Raw lambda event dict - """ - super().__init__(data) - self.dynamodb_type = list(data.keys())[0] + value: Any + DynamoDB value to be deserialized to a python type - @property - def b_value(self) -> Optional[str]: - """An attribute of type Base64-encoded binary data object - Example: - >>> {"B": "dGhpcyB0ZXh0IGlzIGJhc2U2NC1lbmNvZGVk"} - """ - return self.get("B") + Here are the various conversions: - @property - def bs_value(self) -> Optional[List[str]]: - """An attribute of type Array of Base64-encoded binary data objects - - Example: - >>> {"BS": ["U3Vubnk=", "UmFpbnk=", "U25vd3k="]} - """ - return self.get("BS") - - @property - def bool_value(self) -> Optional[bool]: - """An attribute of type Boolean - - Example: - >>> {"BOOL": True} - """ - item = self.get("BOOL") - return None if item is None else bool(item) + DynamoDB Python + -------- ------ + {'NULL': True} None + {'BOOL': True/False} True/False + {'N': str(value)} str(value) + {'S': string} string + {'B': bytes} bytes + {'NS': [str(value)]} set([str(value)]) + {'SS': [string]} set([string]) + {'BS': [bytes]} set([bytes]) + {'L': list} list + {'M': dict} dict - @property - def list_value(self) -> Optional[List["AttributeValue"]]: - """An attribute of type Array of AttributeValue objects - - Example: - >>> {"L": [ {"S": "Cookies"} , {"S": "Coffee"}, {"N": "3.14159"}]} - """ - item = self.get("L") - return None if item is None else [AttributeValue(v) for v in item] - - @property - def map_value(self) -> Optional[Dict[str, "AttributeValue"]]: - """An attribute of type String to AttributeValue object map - - Example: - >>> {"M": {"Name": {"S": "Joe"}, "Age": {"N": "35"}}} - """ - return _attribute_value_dict(self._data, "M") - - @property - def n_value(self) -> Optional[str]: - """An attribute of type Number - - Numbers are sent across the network to DynamoDB as strings, to maximize compatibility across languages - and libraries. However, DynamoDB treats them as number type attributes for mathematical operations. + Parameters + ---------- + value: Any + DynamoDB value to be deserialized to a python type - Example: - >>> {"N": "123.45"} + Returns + -------- + any + Python native type converted from DynamoDB type """ - return self.get("N") - - @property - def ns_value(self) -> Optional[List[str]]: - """An attribute of type Number Set - Example: - >>> {"NS": ["42.2", "-19", "7.5", "3.14"]} - """ - return self.get("NS") + dynamodb_type = list(value.keys())[0] + deserializer: Optional[Callable] = getattr(self, f"_deserialize_{dynamodb_type}".lower(), None) + if deserializer is None: + raise TypeError(f"Dynamodb type {dynamodb_type} is not supported") - @property - def null_value(self) -> None: - """An attribute of type Null. + return deserializer(value[dynamodb_type]) - Example: - >>> {"NULL": True} - """ + def _deserialize_null(self, value: bool) -> None: return None - @property - def s_value(self) -> Optional[str]: - """An attribute of type String + def _deserialize_bool(self, value: bool) -> bool: + return value - Example: - >>> {"S": "Hello"} - """ - return self.get("S") + def _deserialize_n(self, value: str) -> Decimal: + return DYNAMODB_CONTEXT.create_decimal(value) - @property - def ss_value(self) -> Optional[List[str]]: - """An attribute of type Array of strings + def _deserialize_s(self, value: str) -> str: + return value - Example: - >>> {"SS": ["Giraffe", "Hippo" ,"Zebra"]} - """ - return self.get("SS") + def _deserialize_b(self, value: bytes) -> bytes: + return value - @property - def get_type(self) -> AttributeValueType: - """Get the attribute value type based on the contained data""" - return AttributeValueType(self.dynamodb_type) + def _deserialize_ns(self, value: Sequence[str]) -> Set[Decimal]: + return set(map(self._deserialize_n, value)) - @property - def l_value(self) -> Optional[List["AttributeValue"]]: - """Alias of list_value""" - return self.list_value + def _deserialize_ss(self, value: Sequence[str]) -> Set[str]: + return set(map(self._deserialize_s, value)) - @property - def m_value(self) -> Optional[Dict[str, "AttributeValue"]]: - """Alias of map_value""" - return self.map_value + def _deserialize_bs(self, value: Sequence[bytes]) -> Set[bytes]: + return set(map(self._deserialize_b, value)) - @property - def get_value(self) -> Union[Optional[bool], Optional[str], Optional[List], Optional[Dict]]: - """Get the attribute value""" - try: - return getattr(self, f"{self.dynamodb_type.lower()}_value") - except AttributeError: - raise TypeError(f"Dynamodb type {self.dynamodb_type} is not supported") + def _deserialize_l(self, value: Sequence[Dict]) -> Sequence[Any]: + return [self.deserialize(v) for v in value] - -def _attribute_value_dict(attr_values: Dict[str, dict], key: str) -> Optional[Dict[str, AttributeValue]]: - """A dict of type String to AttributeValue object map - - Example: - >>> {"NewImage": {"Id": {"S": "xxx-xxx"}, "Value": {"N": "35"}}} - """ - attr_values_dict = attr_values.get(key) - return None if attr_values_dict is None else {k: AttributeValue(v) for k, v in attr_values_dict.items()} + def _deserialize_m(self, value: Dict) -> Dict: + return {k: self.deserialize(v) for k, v in value.items()} class StreamViewType(Enum): @@ -176,28 +107,57 @@ class StreamViewType(Enum): class StreamRecord(DictWrapper): + _deserializer = TypeDeserializer() + + def __init__(self, data: Dict[str, Any]): + """StreamRecord constructor + Parameters + ---------- + data: Dict[str, Any] + Represents the dynamodb dict inside DynamoDBStreamEvent's records + """ + super().__init__(data) + self._deserializer = TypeDeserializer() + + def _deserialize_dynamodb_dict(self, key: str) -> Optional[Dict[str, Any]]: + """Deserialize DynamoDB records available in `Keys`, `NewImage`, and `OldImage` + + Parameters + ---------- + key : str + DynamoDB key (e.g., Keys, NewImage, or OldImage) + + Returns + ------- + Optional[Dict[str, Any]] + Deserialized records in Python native types + """ + dynamodb_dict = self._data.get(key) + if dynamodb_dict is None: + return None + + return {k: self._deserializer.deserialize(v) for k, v in dynamodb_dict.items()} + @property def approximate_creation_date_time(self) -> Optional[int]: """The approximate date and time when the stream record was created, in UNIX epoch time format.""" item = self.get("ApproximateCreationDateTime") return None if item is None else int(item) - # NOTE: This override breaks the Mapping protocol of DictWrapper, it's left here for backwards compatibility with - # a 'type: ignore' comment. See #1516 for discussion @property - def keys(self) -> Optional[Dict[str, AttributeValue]]: # type: ignore[override] + def keys(self) -> Optional[Dict[str, Any]]: # type: ignore[override] """The primary key attribute(s) for the DynamoDB item that was modified.""" - return _attribute_value_dict(self._data, "Keys") + return self._deserialize_dynamodb_dict("Keys") @property - def new_image(self) -> Optional[Dict[str, AttributeValue]]: + def new_image(self) -> Optional[Dict[str, Any]]: """The item in the DynamoDB table as it appeared after it was modified.""" - return _attribute_value_dict(self._data, "NewImage") + return self._deserialize_dynamodb_dict("NewImage") @property - def old_image(self) -> Optional[Dict[str, AttributeValue]]: + def old_image(self) -> Optional[Dict[str, Any]]: """The item in the DynamoDB table as it appeared before it was modified.""" - return _attribute_value_dict(self._data, "OldImage") + return self._deserialize_dynamodb_dict("OldImage") @property def sequence_number(self) -> Optional[str]: @@ -233,7 +193,7 @@ def aws_region(self) -> Optional[str]: @property def dynamodb(self) -> Optional[StreamRecord]: - """The main body of the stream record, containing all the DynamoDB-specific fields.""" + """The main body of the stream record, containing all the DynamoDB-specific dicts.""" stream_record = self.get("dynamodb") return None if stream_record is None else StreamRecord(stream_record) @@ -278,26 +238,18 @@ class DynamoDBStreamEvent(DictWrapper): Example ------- - **Process dynamodb stream events and use get_type and get_value for handling conversions** + **Process dynamodb stream events. DynamoDB types are automatically converted to their equivalent Python values.** from aws_lambda_powertools.utilities.data_classes import event_source, DynamoDBStreamEvent - from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import ( - AttributeValueType, - AttributeValue, - ) from aws_lambda_powertools.utilities.typing import LambdaContext @event_source(data_class=DynamoDBStreamEvent) def lambda_handler(event: DynamoDBStreamEvent, context: LambdaContext): for record in event.records: - key: AttributeValue = record.dynamodb.keys["id"] - if key == AttributeValueType.Number: - assert key.get_value == key.n_value - print(key.get_value) - elif key == AttributeValueType.Map: - assert key.get_value == key.map_value - print(key.get_value) + # {"N": "123.45"} => Decimal("123.45") + key: str = record.dynamodb.keys["id"] + print(key) """ @property diff --git a/docs/upgrade.md b/docs/upgrade.md index f6b3c7e9d00..fcce2f1958d 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -14,6 +14,7 @@ Changes at a glance: * The **legacy SQS batch processor** was removed. * The **Idempotency key** format changed slightly, invalidating all the existing cached results. * The **Feature Flags and AppConfig Parameter utility** API calls have changed and you must update your IAM permissions. +* The **`DynamoDBStreamEvent`** replaced `AttributeValue` with native Python types. ???+ important Powertools for Python v2 drops suport for Python 3.6, following the Python 3.6 End-Of-Life (EOL) reached on December 23, 2021. @@ -161,3 +162,47 @@ Using qualified names prevents distinct functions with the same name to contend AWS AppConfig deprecated the current API (GetConfiguration) - [more details here](https://github.com/awslabs/aws-lambda-powertools-python/issues/1506#issuecomment-1266645884). You must update your IAM permissions to allow `appconfig:GetLatestConfiguration` and `appconfig:StartConfigurationSession`. There are no code changes required. + +## DynamoDBStreamEvent in Event Source Data Classes + +???+ info + This also applies if you're using [**`BatchProcessor`**](https://awslabs.github.io/aws-lambda-powertools-python/latest/utilities/batch/#processing-messages-from-dynamodb){target="_blank"} to handle DynamoDB Stream events. + +You will now receive native Python types when accessing DynamoDB records via `keys`, `new_image`, and `old_image` attributes in `DynamoDBStreamEvent`. + +Previously, you'd receive a `AttributeValue` instance and need to deserialize each item to the type you'd want for convenience, or to the type DynamoDB stored via `get_value` method. + +With this change, you can access data deserialized as stored in DynamoDB, and no longer need to recursively deserialize nested objects (Maps) if you had them. + +???+ note + For a lossless conversion of DynamoDB `Number` type, we follow AWS Python SDK (boto3) approach and convert to `Decimal`. + +```python hl_lines="15-20 24-25" +from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import ( + DynamoDBStreamEvent, + DynamoDBRecordEventName +) + +def send_to_sqs(data: Dict): + body = json.dumps(data) + ... + +@event_source(data_class=DynamoDBStreamEvent) +def lambda_handler(event: DynamoDBStreamEvent, context): + for record in event.records: + + # BEFORE + new_image: Dict[str, AttributeValue] = record.dynamodb.new_image + event_type: AttributeValue = new_image["eventType"].get_value + if event_type == "PENDING": + # deserialize attribute value into Python native type + # NOTE: nested objects would need additional logic + data = {k: v.get_value for k, v in image.items()} + send_to_sqs(data) + + # AFTER + new_image: Dict[str, Any] = record.dynamodb.new_image + if new_image.get("eventType") == "PENDING": + send_to_sqs(new_image) # Here new_image is just a Python Dict type + +``` diff --git a/docs/utilities/batch.md b/docs/utilities/batch.md index 1bbba86c395..7fcf1ff46d8 100644 --- a/docs/utilities/batch.md +++ b/docs/utilities/batch.md @@ -506,9 +506,9 @@ Processing batches from Kinesis works in four stages: @tracer.capture_method def record_handler(record: DynamoDBRecord): logger.info(record.dynamodb.new_image) - payload: dict = json.loads(record.dynamodb.new_image.get("Message").get_value) + payload: dict = json.loads(record.dynamodb.new_image.get("Message")) # alternatively: - # changes: Dict[str, dynamo_db_stream_event.AttributeValue] = record.dynamodb.new_image + # changes: Dict[str, Any] = record.dynamodb.new_image # payload = change.get("Message").raw_event -> {"S": ""} ... @@ -538,10 +538,10 @@ Processing batches from Kinesis works in four stages: @tracer.capture_method def record_handler(record: DynamoDBRecord): logger.info(record.dynamodb.new_image) - payload: dict = json.loads(record.dynamodb.new_image.get("item").s_value) + payload: dict = json.loads(record.dynamodb.new_image.get("item")) # alternatively: - # changes: Dict[str, dynamo_db_stream_event.AttributeValue] = record.dynamodb.new_image - # payload = change.get("Message").raw_event -> {"S": ""} + # changes: Dict[str, Any] = record.dynamodb.new_image + # payload = change.get("Message") -> "" ... @logger.inject_lambda_context diff --git a/docs/utilities/data_classes.md b/docs/utilities/data_classes.md index 67d821fe04f..4ab41d30d7f 100644 --- a/docs/utilities/data_classes.md +++ b/docs/utilities/data_classes.md @@ -797,9 +797,9 @@ This example is based on the AWS Cognito docs for [Verify Auth Challenge Respons ### DynamoDB Streams -The DynamoDB data class utility provides the base class for `DynamoDBStreamEvent`, a typed class for -attributes values (`AttributeValue`), as well as enums for stream view type (`StreamViewType`) and event type +The DynamoDB data class utility provides the base class for `DynamoDBStreamEvent`, as well as enums for stream view type (`StreamViewType`) and event type. (`DynamoDBRecordEventName`). +The class automatically deserializes DynamoDB types into their equivalent Python types. === "app.py" @@ -823,21 +823,15 @@ attributes values (`AttributeValue`), as well as enums for stream view type (`St ```python from aws_lambda_powertools.utilities.data_classes import event_source, DynamoDBStreamEvent - from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import AttributeValueType, AttributeValue from aws_lambda_powertools.utilities.typing import LambdaContext @event_source(data_class=DynamoDBStreamEvent) def lambda_handler(event: DynamoDBStreamEvent, context: LambdaContext): for record in event.records: - key: AttributeValue = record.dynamodb.keys["id"] - if key == AttributeValueType.Number: - # {"N": "123.45"} => "123.45" - assert key.get_value == key.n_value - print(key.get_value) - elif key == AttributeValueType.Map: - assert key.get_value == key.map_value - print(key.get_value) + # {"N": "123.45"} => Decimal("123.45") + key: str = record.dynamodb.keys["id"] + print(key) ``` ### EventBridge diff --git a/tests/functional/test_data_classes.py b/tests/functional/test_data_classes.py index 1f8c0cef955..4fe0eb40331 100644 --- a/tests/functional/test_data_classes.py +++ b/tests/functional/test_data_classes.py @@ -2,6 +2,7 @@ import datetime import json import zipfile +from decimal import Clamped, Context, Inexact, Overflow, Rounded, Underflow from secrets import compare_digest from urllib.parse import quote_plus @@ -75,8 +76,6 @@ ConnectContactFlowInitiationMethod, ) from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import ( - AttributeValue, - AttributeValueType, DynamoDBRecordEventName, DynamoDBStreamEvent, StreamRecord, @@ -490,7 +489,13 @@ def test_connect_contact_flow_event_all(): assert event.parameters == {"ParameterOne": "One", "ParameterTwo": "Two"} -def test_dynamo_db_stream_trigger_event(): +def test_dynamodb_stream_trigger_event(): + decimal_context = Context( + Emin=-128, + Emax=126, + prec=38, + traps=[Clamped, Overflow, Inexact, Rounded, Underflow], + ) event = DynamoDBStreamEvent(load_event("dynamoStreamEvent.json")) records = list(event.records) @@ -502,20 +507,8 @@ def test_dynamo_db_stream_trigger_event(): assert dynamodb.approximate_creation_date_time is None keys = dynamodb.keys assert keys is not None - id_key = keys["Id"] - assert id_key.b_value is None - assert id_key.bs_value is None - assert id_key.bool_value is None - assert id_key.list_value is None - assert id_key.map_value is None - assert id_key.n_value == "101" - assert id_key.ns_value is None - assert id_key.null_value is None - assert id_key.s_value is None - assert id_key.ss_value is None - message_key = dynamodb.new_image["Message"] - assert message_key is not None - assert message_key.s_value == "New item!" + assert keys["Id"] == decimal_context.create_decimal(101) + assert dynamodb.new_image["Message"] == "New item!" assert dynamodb.old_image is None assert dynamodb.sequence_number == "111" assert dynamodb.size_bytes == 26 @@ -528,129 +521,61 @@ def test_dynamo_db_stream_trigger_event(): assert record.user_identity is None -def test_dynamo_attribute_value_b_value(): - example_attribute_value = {"B": "dGhpcyB0ZXh0IGlzIGJhc2U2NC1lbmNvZGVk"} - - attribute_value = AttributeValue(example_attribute_value) - - assert attribute_value.get_type == AttributeValueType.Binary - assert attribute_value.b_value == attribute_value.get_value - - -def test_dynamo_attribute_value_bs_value(): - example_attribute_value = {"BS": ["U3Vubnk=", "UmFpbnk=", "U25vd3k="]} - - attribute_value = AttributeValue(example_attribute_value) - - assert attribute_value.get_type == AttributeValueType.BinarySet - assert attribute_value.bs_value == attribute_value.get_value - - -def test_dynamo_attribute_value_bool_value(): - example_attribute_value = {"BOOL": True} - - attribute_value = AttributeValue(example_attribute_value) - - assert attribute_value.get_type == AttributeValueType.Boolean - assert attribute_value.bool_value == attribute_value.get_value - - -def test_dynamo_attribute_value_list_value(): - example_attribute_value = {"L": [{"S": "Cookies"}, {"S": "Coffee"}, {"N": "3.14159"}]} - attribute_value = AttributeValue(example_attribute_value) - list_value = attribute_value.list_value - assert list_value is not None - item = list_value[0] - assert item.s_value == "Cookies" - assert attribute_value.get_type == AttributeValueType.List - assert attribute_value.l_value == attribute_value.list_value - assert attribute_value.list_value == attribute_value.get_value - - -def test_dynamo_attribute_value_map_value(): - example_attribute_value = {"M": {"Name": {"S": "Joe"}, "Age": {"N": "35"}}} - - attribute_value = AttributeValue(example_attribute_value) - - map_value = attribute_value.map_value - assert map_value is not None - item = map_value["Name"] - assert item.s_value == "Joe" - assert attribute_value.get_type == AttributeValueType.Map - assert attribute_value.m_value == attribute_value.map_value - assert attribute_value.map_value == attribute_value.get_value - - -def test_dynamo_attribute_value_n_value(): - example_attribute_value = {"N": "123.45"} - - attribute_value = AttributeValue(example_attribute_value) - - assert attribute_value.get_type == AttributeValueType.Number - assert attribute_value.n_value == attribute_value.get_value - - -def test_dynamo_attribute_value_ns_value(): - example_attribute_value = {"NS": ["42.2", "-19", "7.5", "3.14"]} - - attribute_value = AttributeValue(example_attribute_value) - - assert attribute_value.get_type == AttributeValueType.NumberSet - assert attribute_value.ns_value == attribute_value.get_value - - -def test_dynamo_attribute_value_null_value(): - example_attribute_value = {"NULL": True} - - attribute_value = AttributeValue(example_attribute_value) - - assert attribute_value.get_type == AttributeValueType.Null - assert attribute_value.null_value is None - assert attribute_value.null_value == attribute_value.get_value - - -def test_dynamo_attribute_value_s_value(): - example_attribute_value = {"S": "Hello"} - - attribute_value = AttributeValue(example_attribute_value) - - assert attribute_value.get_type == AttributeValueType.String - assert attribute_value.s_value == attribute_value.get_value - - -def test_dynamo_attribute_value_ss_value(): - example_attribute_value = {"SS": ["Giraffe", "Hippo", "Zebra"]} - - attribute_value = AttributeValue(example_attribute_value) - - assert attribute_value.get_type == AttributeValueType.StringSet - assert attribute_value.ss_value == attribute_value.get_value - - -def test_dynamo_attribute_value_type_error(): - example_attribute_value = {"UNSUPPORTED": "'value' should raise a type error"} - - attribute_value = AttributeValue(example_attribute_value) - - with pytest.raises(TypeError): - print(attribute_value.get_value) - with pytest.raises(ValueError): - print(attribute_value.get_type) - - -def test_stream_record_keys_with_valid_keys(): - attribute_value = {"Foo": "Bar"} - record = StreamRecord({"Keys": {"Key1": attribute_value}}) - assert record.keys == {"Key1": AttributeValue(attribute_value)} +def test_dynamodb_stream_record_deserialization(): + byte_list = [s.encode("utf-8") for s in ["item1", "item2"]] + decimal_context = Context( + Emin=-128, + Emax=126, + prec=38, + traps=[Clamped, Overflow, Inexact, Rounded, Underflow], + ) + data = { + "Keys": {"key1": {"attr1": "value1"}}, + "NewImage": { + "Name": {"S": "Joe"}, + "Age": {"N": "35"}, + "TypesMap": { + "M": { + "string": {"S": "value"}, + "number": {"N": "100"}, + "bool": {"BOOL": True}, + "dict": {"M": {"key": {"S": "value"}}}, + "stringSet": {"SS": ["item1", "item2"]}, + "numberSet": {"NS": ["100", "200", "300"]}, + "binary": {"B": b"\x00"}, + "byteSet": {"BS": byte_list}, + "list": {"L": [{"S": "item1"}, {"N": "3.14159"}, {"BOOL": False}]}, + "null": {"NULL": True}, + }, + }, + }, + } + record = StreamRecord(data) + assert record.new_image == { + "Name": "Joe", + "Age": decimal_context.create_decimal("35"), + "TypesMap": { + "string": "value", + "number": decimal_context.create_decimal("100"), + "bool": True, + "dict": {"key": "value"}, + "stringSet": {"item1", "item2"}, + "numberSet": {decimal_context.create_decimal(n) for n in ["100", "200", "300"]}, + "binary": b"\x00", + "byteSet": set(byte_list), + "list": ["item1", decimal_context.create_decimal("3.14159"), False], + "null": None, + }, + } -def test_stream_record_keys_with_no_keys(): +def test_dynamodb_stream_record_keys_with_no_keys(): record = StreamRecord({}) assert record.keys is None -def test_stream_record_keys_overrides_dict_wrapper_keys(): - data = {"Keys": {"key1": {"attr1": "value1"}}} +def test_dynamodb_stream_record_keys_overrides_dict_wrapper_keys(): + data = {"Keys": {"key1": {"N": "101"}}} record = StreamRecord(data) assert record.keys != data.keys() diff --git a/tests/functional/test_utilities_batch.py b/tests/functional/test_utilities_batch.py index 4f46b428121..1d50de9e85e 100644 --- a/tests/functional/test_utilities_batch.py +++ b/tests/functional/test_utilities_batch.py @@ -129,7 +129,7 @@ def handler(record: KinesisStreamRecord): @pytest.fixture(scope="module") def dynamodb_record_handler() -> Callable: def handler(record: DynamoDBRecord): - body = record.dynamodb.new_image.get("Message").get_value + body = record.dynamodb.new_image.get("Message") if "fail" in body: raise Exception("Failed to process record.") return body From feca6503d4cbe86a4e5739b55ffa5297fe8e7d84 Mon Sep 17 00:00:00 2001 From: Heitor Lessa Date: Wed, 19 Oct 2022 18:35:29 +0200 Subject: [PATCH 26/30] refactor(apigateway): remove POWERTOOLS_EVENT_HANDLER_DEBUG env var (#1620) --- .../event_handler/api_gateway.py | 14 ++----------- aws_lambda_powertools/shared/constants.py | 2 -- docs/index.md | 21 +++++++++---------- .../event_handler/test_api_gateway.py | 10 --------- 4 files changed, 12 insertions(+), 35 deletions(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 38aaaf096db..112bcd92dfe 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -1,7 +1,6 @@ import base64 import json import logging -import os import re import traceback import warnings @@ -26,9 +25,8 @@ from aws_lambda_powertools.event_handler import content_types from aws_lambda_powertools.event_handler.exceptions import NotFoundError, ServiceError -from aws_lambda_powertools.shared import constants from aws_lambda_powertools.shared.cookies import Cookie -from aws_lambda_powertools.shared.functions import powertools_dev_is_set, strtobool +from aws_lambda_powertools.shared.functions import powertools_dev_is_set from aws_lambda_powertools.shared.json_encoder import Encoder from aws_lambda_powertools.utilities.data_classes import ( ALBEvent, @@ -459,7 +457,7 @@ def __init__( 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" + Enables debug mode, by default False. Can be also be enabled by "POWERTOOLS_DEV" environment variable serializer : Callable, optional function to serialize `obj` to a JSON formatted `str`, by default json.dumps @@ -552,14 +550,6 @@ def _has_debug(debug: Optional[bool] = None) -> bool: if debug is not None: return debug - # Maintenance: deprecate EVENT_HANDLER_DEBUG later in V2. - env_debug = os.getenv(constants.EVENT_HANDLER_DEBUG_ENV) - if env_debug is not None: - warnings.warn( - "POWERTOOLS_EVENT_HANDLER_DEBUG is set and will be deprecated in V2. Please use POWERTOOLS_DEV instead." - ) - return strtobool(env_debug) or powertools_dev_is_set() - return powertools_dev_is_set() @staticmethod diff --git a/aws_lambda_powertools/shared/constants.py b/aws_lambda_powertools/shared/constants.py index 86a6c2ac41b..2ec120e4d4a 100644 --- a/aws_lambda_powertools/shared/constants.py +++ b/aws_lambda_powertools/shared/constants.py @@ -10,8 +10,6 @@ METRICS_NAMESPACE_ENV: str = "POWERTOOLS_METRICS_NAMESPACE" -EVENT_HANDLER_DEBUG_ENV: str = "POWERTOOLS_EVENT_HANDLER_DEBUG" - SERVICE_NAME_ENV: str = "POWERTOOLS_SERVICE_NAME" XRAY_TRACE_ID_ENV: str = "_X_AMZN_TRACE_ID" LAMBDA_TASK_ROOT_ENV: str = "LAMBDA_TASK_ROOT" diff --git a/docs/index.md b/docs/index.md index 58bf74bf223..4843a6b28fd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -54,8 +54,8 @@ You can include Lambda Powertools Lambda Layer using [AWS Lambda Console](https: === "x86_64" - | Region | Layer ARN | - | ---------------- | -------------------------------------------------------------------------------------------------------- | + | Region | Layer ARN | + | ---------------- | --------------------------------------------------------------------------------------------------------- | | `af-south-1` | [arn:aws:lambda:af-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | | `ap-east-1` | [arn:aws:lambda:ap-east-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | | `ap-northeast-1` | [arn:aws:lambda:ap-northeast-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:1](#){: .copyMe}:clipboard: | @@ -81,8 +81,8 @@ You can include Lambda Powertools Lambda Layer using [AWS Lambda Console](https: === "arm64" - | Region | Layer ARN | - | ---------------- | -------------------------------------------------------------------------------------------------------- | + | Region | Layer ARN | + | ---------------- | --------------------------------------------------------------------------------------------------------------- | | `af-south-1` | [arn:aws:lambda:af-south-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | | `ap-east-1` | [arn:aws:lambda:ap-east-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | | `ap-northeast-1` | [arn:aws:lambda:ap-northeast-1:017000801446:layer:AWSLambdaPowertoolsPythonV2-Arm64:1](#){: .copyMe}:clipboard: | @@ -377,7 +377,7 @@ Serverless Application Repository (SAR) App deploys a CloudFormation stack with 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-v2](https://serverlessrepo.aws.amazon.com/applications/eu-west-1/057560766410/aws-lambda-powertools-python-layer-v2) | [arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer-v2](#){: .copyMe}:clipboard: | Contains all extra dependencies (e.g: pydantic). | | [aws-lambda-powertools-python-layer-v2-arm64](https://serverlessrepo.aws.amazon.com/applications/eu-west-1/057560766410/aws-lambda-powertools-python-layer-v2-arm64) | [arn:aws:serverlessrepo:eu-west-1:057560766410:applications/aws-lambda-powertools-python-layer-v2-arm64](#){: .copyMe}:clipboard: | Contains all extra dependencies (e.g: pydantic). For arm64 functions. | @@ -625,7 +625,6 @@ Core utilities such as Tracing, Logging, Metrics, and Event Handler will be avai | **POWERTOOLS_LOGGER_LOG_EVENT** | Logs incoming event | [Logging](./core/logger) | `false` | | **POWERTOOLS_LOGGER_SAMPLE_RATE** | Debug log sampling | [Logging](./core/logger) | `0` | | **POWERTOOLS_LOG_DEDUPLICATION_DISABLED** | Disables log deduplication filter protection to use Pytest Live Log feature | [Logging](./core/logger) | `false` | -| **POWERTOOLS_EVENT_HANDLER_DEBUG** | Enables debugging mode for event handler | [Event Handler](./core/event_handler/api_gateway.md#debug-mode) | `false` | | **POWERTOOLS_DEV** | Increases verbosity across utilities | Multiple; see [POWERTOOLS_DEV effect below](#increasing-verbosity-across-utilities) | `0` | | **LOG_LEVEL** | Sets logging level | [Logging](./core/logger) | `INFO` | @@ -638,11 +637,11 @@ Whether you're prototyping locally or against a non-production environment, you When `POWERTOOLS_DEV` is set to a truthy value (`1`, `true`), it'll have the following effects: -| Utility | Effect | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Logger** | Increase JSON indentation to 4. This will ease local debugging when running functions locally under emulators or direct calls while not affecting unit tests | -| **Event Handler** | Enable full traceback errors in the response, indent request/responses, and CORS in dev mode (`*`). This will deprecate [`POWERTOOLS_EVENT_HANDLER_DEBUG`](https://awslabs.github.io/aws-lambda-powertools-python/latest/core/event_handler/api_gateway/#debug-mode) in the future. | -| **Tracer** | Future-proof safety to disables tracing operations in non-Lambda environments. This already happens automatically in the Tracer utility. | +| Utility | Effect | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Logger** | Increase JSON indentation to 4. This will ease local debugging when running functions locally under emulators or direct calls while not affecting unit tests | +| **Event Handler** | Enable full traceback errors in the response, indent request/responses, and CORS in dev mode (`*`). | +| **Tracer** | Future-proof safety to disables tracing operations in non-Lambda environments. This already happens automatically in the Tracer utility. | ## Debug mode diff --git a/tests/functional/event_handler/test_api_gateway.py b/tests/functional/event_handler/test_api_gateway.py index a78d3747d28..6b343dd1f0f 100644 --- a/tests/functional/event_handler/test_api_gateway.py +++ b/tests/functional/event_handler/test_api_gateway.py @@ -793,16 +793,6 @@ def raises_error(): assert e.value.args == ("Foo",) -def test_debug_mode_environment_variable(monkeypatch): - # GIVEN a debug mode environment variable is set - monkeypatch.setenv(constants.EVENT_HANDLER_DEBUG_ENV, "true") - app = ApiGatewayResolver() - - # WHEN calling app._debug - # THEN the debug mode is enabled - assert app._debug - - def test_powertools_dev_sets_debug_mode(monkeypatch): # GIVEN a debug mode environment variable is set monkeypatch.setenv(constants.POWERTOOLS_DEV_ENV, "true") From 4bba49cf57c9974022de4435d4335b7a69dd0d80 Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Wed, 19 Oct 2022 18:39:30 +0200 Subject: [PATCH 27/30] chore(ci): remove v1 workflows (#1617) --- .github/workflows/on_release_notes.yml | 11 ++- .github/workflows/publish_layer.yml | 86 ---------------- .github/workflows/publish_v2_layer.yml | 33 +++---- .github/workflows/rebuild_latest_docs.yml | 4 +- .../workflows/reusable_deploy_layer_stack.yml | 99 ------------------- .../reusable_update_v2_layer_arn_docs.yml | 2 +- .github/workflows/v2_on_push_docs.yml | 36 ------- .github/workflows/v2_rebuild_latest_docs.yml | 14 --- 8 files changed, 29 insertions(+), 256 deletions(-) delete mode 100644 .github/workflows/publish_layer.yml delete mode 100644 .github/workflows/reusable_deploy_layer_stack.yml delete mode 100644 .github/workflows/v2_on_push_docs.yml delete mode 100644 .github/workflows/v2_rebuild_latest_docs.yml diff --git a/.github/workflows/on_release_notes.yml b/.github/workflows/on_release_notes.yml index 2b431defff0..8d6754b88a0 100644 --- a/.github/workflows/on_release_notes.yml +++ b/.github/workflows/on_release_notes.yml @@ -25,12 +25,14 @@ env: on: release: + # We can't filter by tag here, so we filter later on the first job types: [published] + workflow_dispatch: inputs: version_to_publish: description: "Version to be released in PyPi, Docs, and Lambda Layer, e.g. v1.26.4" - default: v1.26.4 + default: v2.0.0 required: true skip_pypi: description: "Skip publishing to PyPi as it can't publish more than once. Useful for semi-failed releases" @@ -45,6 +47,7 @@ on: jobs: release: + if: ${{ startsWith(github.ref, 'refs/tags/v2') }} environment: release runs-on: ubuntu-latest permissions: @@ -121,6 +124,12 @@ jobs: alias: latest detached_mode: true + publish_layer: + needs: release + uses: ./.github/workflows/publish_v2_layer.yml + with: + latest_published_version: ${{ needs.release.outputs.RELEASE_VERSION }} + post_release: needs: release permissions: diff --git a/.github/workflows/publish_layer.yml b/.github/workflows/publish_layer.yml deleted file mode 100644 index 564cbfad9de..00000000000 --- a/.github/workflows/publish_layer.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: Deploy layer to all regions - -permissions: - id-token: write - contents: read - -on: - workflow_dispatch: - inputs: - latest_published_version: - description: "Latest PyPi published version to rebuild latest docs for, e.g. v1.22.0" - default: "v1.22.0" - required: true - workflow_run: - workflows: ["Publish to PyPi"] - types: - - completed - -jobs: - build-layer: - runs-on: ubuntu-latest - if: ${{ (github.event.workflow_run.conclusion == 'success') || (github.event_name == 'workflow_dispatch') }} - defaults: - run: - working-directory: ./layer - steps: - - name: checkout - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - name: Install poetry - run: pipx install poetry - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: "16.12" - - name: Setup python - uses: actions/setup-python@v4 - with: - python-version: "3.9" - cache: "pip" - - name: Resolve and install project dependencies - # CDK spawns system python when compiling stack - # therefore it ignores both activated virtual env and cached interpreter by GH - run: | - poetry export --format requirements.txt --output requirements.txt - pip install -r requirements.txt - - name: Set release notes tag - run: | - RELEASE_INPUT=${{ inputs.latest_published_version }} - LATEST_TAG=$(git describe --tag --abbrev=0) - RELEASE_TAG_VERSION=${RELEASE_INPUT:-$LATEST_TAG} - echo "RELEASE_TAG_VERSION=${RELEASE_TAG_VERSION:1}" >> $GITHUB_ENV - - name: install cdk and deps - run: | - npm install -g aws-cdk@2.29.0 - cdk --version - - name: CDK build - run: cdk synth --context version=$RELEASE_TAG_VERSION -o cdk.out - - name: zip output - run: zip -r cdk.out.zip cdk.out - - name: Archive CDK artifacts - uses: actions/upload-artifact@v3 - with: - name: cdk-layer-artefact - path: layer/cdk.out.zip - - deploy-beta: - needs: - - build-layer - uses: ./.github/workflows/reusable_deploy_layer_stack.yml - secrets: inherit - with: - stage: "BETA" - artefact-name: "cdk-layer-artefact" - environment: "layer-beta" - - deploy-prod: - needs: - - deploy-beta - uses: ./.github/workflows/reusable_deploy_layer_stack.yml - secrets: inherit - with: - stage: "PROD" - artefact-name: "cdk-layer-artefact" - environment: "layer-prod" diff --git a/.github/workflows/publish_v2_layer.yml b/.github/workflows/publish_v2_layer.yml index 77f1f9dc627..738dd0bead1 100644 --- a/.github/workflows/publish_v2_layer.yml +++ b/.github/workflows/publish_v2_layer.yml @@ -10,15 +10,16 @@ on: latest_published_version: description: "Latest PyPi published version to rebuild latest docs for, e.g. v2.0.0" required: true - # workflow_run: - # workflows: ["Publish to PyPi"] - # types: - # - completed + workflow_call: + inputs: + latest_published_version: + type: string + description: "Latest PyPi published version to rebuild latest docs for, e.g. v2.0.0" + required: true jobs: build-layer: runs-on: ubuntu-latest - if: ${{ (github.event.workflow_run.conclusion == 'success') || (github.event_name == 'workflow_dispatch') }} defaults: run: working-directory: ./layer @@ -74,8 +75,7 @@ jobs: path: layer/cdk.out.zip deploy-beta: - needs: - - build-layer + needs: build-layer uses: ./.github/workflows/reusable_deploy_v2_layer_stack.yml secrets: inherit with: @@ -84,16 +84,15 @@ jobs: environment: "layer-beta" latest_published_version: ${{ inputs.latest_published_version }} - # deploy-prod: - # needs: - # - deploy-beta - # uses: ./.github/workflows/reusable_deploy_layer_stack.yml - # secrets: inherit - # with: - # stage: "PROD" - # artefact-name: "cdk-layer-artefact" - # environment: "layer-prod" - # latest_published_version: ${{ inputs.latest_published_version }} + deploy-prod: + needs: deploy-beta + uses: ./.github/workflows/reusable_deploy_v2_layer_stack.yml + secrets: inherit + with: + stage: "PROD" + artefact-name: "cdk-layer-artefact" + environment: "layer-prod" + latest_published_version: ${{ inputs.latest_published_version }} deploy-sar-beta: needs: build-layer diff --git a/.github/workflows/rebuild_latest_docs.yml b/.github/workflows/rebuild_latest_docs.yml index eb995d95a12..1e8333d4540 100644 --- a/.github/workflows/rebuild_latest_docs.yml +++ b/.github/workflows/rebuild_latest_docs.yml @@ -10,8 +10,8 @@ on: workflow_dispatch: inputs: latest_published_version: - description: "Latest PyPi published version to rebuild latest docs for, e.g. v1.26.7" - default: "v1.28.0" + description: "Latest PyPi published version to rebuild latest docs for, e.g. v2.0.0" + default: "v2.0.0" required: true jobs: diff --git a/.github/workflows/reusable_deploy_layer_stack.yml b/.github/workflows/reusable_deploy_layer_stack.yml deleted file mode 100644 index 20d69b9c814..00000000000 --- a/.github/workflows/reusable_deploy_layer_stack.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: Deploy cdk stack - -permissions: - id-token: write - contents: read - -on: - workflow_call: - inputs: - stage: - description: "Deployment stage (BETA, PROD)" - required: true - type: string - artefact-name: - description: "CDK Layer Artefact name to download" - required: true - type: string - environment: - description: "GitHub Environment to use for encrypted secrets" - required: true - type: string - -jobs: - deploy-cdk-stack: - runs-on: ubuntu-latest - environment: ${{ inputs.environment }} - defaults: - run: - working-directory: ./layer - strategy: - fail-fast: false - matrix: - region: - [ - "af-south-1", - "eu-central-1", - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", - "ap-east-1", - "ap-south-1", - "ap-northeast-1", - "ap-northeast-2", - "ap-southeast-1", - "ap-southeast-2", - "ca-central-1", - "eu-west-1", - "eu-west-2", - "eu-west-3", - "eu-south-1", - "eu-north-1", - "sa-east-1", - "ap-southeast-3", - "ap-northeast-3", - "me-south-1", - ] - steps: - - name: checkout - uses: actions/checkout@v3 - - name: Install poetry - run: pipx install poetry - - name: aws credentials - uses: aws-actions/configure-aws-credentials@v1 - with: - aws-region: ${{ matrix.region }} - role-to-assume: ${{ secrets.AWS_LAYERS_ROLE_ARN }} - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: "16.12" - - name: Setup python - uses: actions/setup-python@v4 - with: - python-version: "3.9" - cache: "pip" - - name: Resolve and install project dependencies - # CDK spawns system python when compiling stack - # therefore it ignores both activated virtual env and cached interpreter by GH - run: | - poetry export --format requirements.txt --output requirements.txt - pip install -r requirements.txt - - name: install cdk and deps - run: | - npm install -g aws-cdk@2.29.0 - cdk --version - - name: install deps - run: poetry install - - name: Download artifact - uses: actions/download-artifact@v3 - with: - name: ${{ inputs.artefact-name }} - path: layer - - name: unzip artefact - run: unzip cdk.out.zip - - name: CDK Deploy Layer - run: cdk deploy --app cdk.out --context region=${{ matrix.region }} 'LayerStack' --require-approval never --verbose - - name: CDK Deploy Canary - run: cdk deploy --app cdk.out --context region=${{ matrix.region}} --parameters DeployStage="${{ inputs.stage }}" 'CanaryStack' --require-approval never --verbose diff --git a/.github/workflows/reusable_update_v2_layer_arn_docs.yml b/.github/workflows/reusable_update_v2_layer_arn_docs.yml index 857c8001bf9..ea13a63f64a 100644 --- a/.github/workflows/reusable_update_v2_layer_arn_docs.yml +++ b/.github/workflows/reusable_update_v2_layer_arn_docs.yml @@ -12,7 +12,7 @@ permissions: contents: write env: - BRANCH: v2 + BRANCH: develop jobs: publish_v2_layer_arn: diff --git a/.github/workflows/v2_on_push_docs.yml b/.github/workflows/v2_on_push_docs.yml deleted file mode 100644 index d70fedbc6c5..00000000000 --- a/.github/workflows/v2_on_push_docs.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Docs v2 - -on: - workflow_dispatch: -# push: -# branches: -# - v2 -# paths: -# - "docs/**" -# - "mkdocs.yml" -# - "examples/**" - -jobs: - changelog: - permissions: - contents: write - uses: ./.github/workflows/reusable_publish_changelog.yml - - release-docs: - needs: changelog - permissions: - contents: write - pages: write - uses: ./.github/workflows/reusable_publish_docs.yml - with: - version: v2 - alias: alpha -# Maintenance: Only necessary in repo migration -# - name: Create redirect from old docs -# run: | -# git checkout gh-pages -# test -f 404.html && echo "Redirect already set" && exit 0 -# git checkout develop -- 404.html -# git add 404.html -# git commit -m "chore: set docs redirect" --no-verify -# git push origin gh-pages -f diff --git a/.github/workflows/v2_rebuild_latest_docs.yml b/.github/workflows/v2_rebuild_latest_docs.yml deleted file mode 100644 index 6d833cc3fef..00000000000 --- a/.github/workflows/v2_rebuild_latest_docs.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: V2 Rebuild latest docs - -on: - workflow_dispatch: - -jobs: - release-docs: - permissions: - contents: write - pages: write - uses: ./.github/workflows/reusable_publish_docs.yml - with: - version: v2 - alias: alpha From 11ae5a9c7cfab3bd779b9b23ab6561d2d3230a54 Mon Sep 17 00:00:00 2001 From: Ruben Fonseca Date: Thu, 20 Oct 2022 13:24:10 +0200 Subject: [PATCH 28/30] feat(ci): release docs as alpha when doing a pre-release (#1624) Co-authored-by: Heitor Lessa --- .github/workflows/on_release_notes.yml | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/on_release_notes.yml b/.github/workflows/on_release_notes.yml index 8d6754b88a0..71c9d07c930 100644 --- a/.github/workflows/on_release_notes.yml +++ b/.github/workflows/on_release_notes.yml @@ -44,6 +44,11 @@ on: default: false type: boolean required: false + pre_release: + description: "Publishes documentation using a pre-release tag. You are still responsible for passing a pre-release version tag to the workflow." + default: false + type: boolean + required: false jobs: release: @@ -113,15 +118,30 @@ jobs: contents: write uses: ./.github/workflows/reusable_publish_changelog.yml + # When doing a pre-release, we want to publish the docs as "alpha" instead of replacing the latest docs + prepare_docs_alias: + runs-on: ubuntu-latest + outputs: + DOCS_ALIAS: ${{ steps.set-alias.outputs.DOCS_ALIAS }} + steps: + - name: Set docs alias + id: set-alias + run: | + DOCS_ALIAS=latest + if [[ "${{ github.event.release.prerelease || inputs.pre_release }}" == true ]] ; then + DOCS_ALIAS=alpha + fi + echo DOCS_ALIAS="$DOCS_ALIAS" >> "$GITHUB_OUTPUT" + docs: - needs: [release, changelog] + needs: [release, changelog, prepare_docs_alias] permissions: contents: write pages: write uses: ./.github/workflows/reusable_publish_docs.yml with: version: ${{ needs.release.outputs.RELEASE_VERSION }} - alias: latest + alias: ${{ needs.prepare_docs_alias.outputs.DOCS_ALIAS }} detached_mode: true publish_layer: From ec4204f3c3d51acfa73c70cd3629dd413b0e65d2 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Thu, 20 Oct 2022 13:55:20 +0100 Subject: [PATCH 29/30] docs(upgrade_guide): add latest changes and quick summary (#1623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: heitorlessa Co-authored-by: Rúben Fonseca --- docs/media/upgrade_idempotency_after.png | Bin 0 -> 80104 bytes docs/media/upgrade_idempotency_before.png | Bin 0 -> 77771 bytes docs/upgrade.md | 144 ++++++++++++---------- 3 files changed, 77 insertions(+), 67 deletions(-) create mode 100644 docs/media/upgrade_idempotency_after.png create mode 100644 docs/media/upgrade_idempotency_before.png diff --git a/docs/media/upgrade_idempotency_after.png b/docs/media/upgrade_idempotency_after.png new file mode 100644 index 0000000000000000000000000000000000000000..8faa8bed124871796e129959e98886176188a6a7 GIT binary patch literal 80104 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

xRD8W^#}%X&L=9uQ6_jQc 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 Urn^Iv0`hf9ipq&p3+V^^AF}Z~mH+?% literal 0 HcmV?d00001 diff --git a/docs/media/upgrade_idempotency_before.png b/docs/media/upgrade_idempotency_before.png new file mode 100644 index 0000000000000000000000000000000000000000..6f1f7f998c1b200dac678d0318cbd60d0321421d GIT binary patch literal 77771 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 Q0C$J3mXT(gx>NlB08nw(ga7~l literal 0 HcmV?d00001 diff --git a/docs/upgrade.md b/docs/upgrade.md index fcce2f1958d..391bc084d7f 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -7,70 +7,39 @@ description: Guide to update between major Powertools versions ## Migrate to v2 from v1 -The transition from Powertools for Python v1 to v2 is as painless as possible, as we aimed for minimal breaking changes. -Changes at a glance: +We've made minimal breaking changes to make your transition to v2 as smooth as possible. -* The API for **event handler's `Response`** has minor changes to support multi value headers and cookies. -* The **legacy SQS batch processor** was removed. -* The **Idempotency key** format changed slightly, invalidating all the existing cached results. -* The **Feature Flags and AppConfig Parameter utility** API calls have changed and you must update your IAM permissions. -* The **`DynamoDBStreamEvent`** replaced `AttributeValue` with native Python types. +### Quick summary -???+ important - Powertools for Python v2 drops suport for Python 3.6, following the Python 3.6 End-Of-Life (EOL) reached on December 23, 2021. +| Area | Change | Code change required | IAM Permissions change required | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | ------------------------------- | +| **Batch** | Removed legacy [SQS batch processor](#legacy-sqs-batch-processor) in favour of **`BatchProcessor`**. | Yes | - | +| **Environment variables** | Removed legacy **`POWERTOOLS_EVENT_HANDLER_DEBUG`** in favour of [`POWERTOOLS_DEV`](index.md#optimizing-for-non-production-environments){target="_blank"}. | - | - | +| **Event Handler** | Updated [headers response format](#event-handler-headers-response-format) due to [multi-value headers and cookie support](./core/event_handler/api_gateway.md#fine-grained-responses){target="_blank"}. | Tests only | - | +| **Event Source Data Classes** | Replaced [DynamoDBStreamEvent](#dynamodbstreamevent-in-event-source-data-classes) `AttributeValue` with native Python types. | Yes | - | +| **Feature Flags** / **Parameters** | Updated [AppConfig API calls](#feature-flags-and-appconfig-parameter-utility) due to **`GetConfiguration`** API deprecation. | - | Yes | +| **Idempotency** | Updated [partition key](#idempotency-key-format) to include fully qualified function/method names. | - | - | -### Initial Steps +### First Steps Before you start, we suggest making a copy of your current working project or create a new branch with git. 1. **Upgrade** Python to at least v3.7 - -2. **Ensure** you have the latest `aws-lambda-powertools` - - ```bash - pip install aws-lambda-powertools -U - ``` - +2. **Ensure** you have the latest version via [Lambda Layer or PyPi](index.md#install){target="_blank"} 3. **Review** the following sections to confirm whether they affect your code -## Event Handler Response (headers and cookies) - -The `Response` class of the event handler utility changed slightly: - -1. The `headers` parameter now expects either a value or list of values per header (type `Union[str, Dict[str, List[str]]]`) -2. We introduced a new `cookies` parameter (type `List[str]`) - -???+ note - Code that set headers as `Dict[str, str]` will still work unchanged. - -```python hl_lines="6 12 13" -@app.get("/todos") -def get_todos(): - # Before - return Response( - # ... - headers={"Content-Type": "text/plain"} - ) - - # After - return Response( - # ... - headers={"Content-Type": ["text/plain"]}, - cookies=[Cookie(name="session_id", value="12345", secure=True, http_only=True)], - ) -``` - ## Legacy SQS Batch Processor -The deprecated `PartialSQSProcessor` and `sqs_batch_processor` were removed. -You can migrate to the [native batch processing](https://aws.amazon.com/about-aws/whats-new/2021/11/aws-lambda-partial-batch-response-sqs-event-source/) capability by: +We removed the deprecated `PartialSQSProcessor` class and `sqs_batch_processor` decorator. -1. If you use **`sqs_batch_decorator`** you can now use **`batch_processor`** decorator -2. If you use **`PartialSQSProcessor`** you can now use **`BatchProcessor`** -3. [Enable the functionality](../utilities/batch#required-resources) on SQS +You can migrate to `BatchProcessor` with the following changes: + +1. If you use **`sqs_batch_decorator`**, change to **`batch_processor`** decorator +2. If you use **`PartialSQSProcessor`**, change to **`BatchProcessor`** +3. [Enable **`ReportBatchItemFailures`** in your Lambda Event Source](../utilities/batch#required-resources){target="_blank"} 4. Change your Lambda Handler to return the new response format -=== "Decorator: Before" +=== "[Before] Decorator" ```python hl_lines="1 6" from aws_lambda_powertools.utilities.batch import sqs_batch_processor @@ -83,9 +52,9 @@ You can migrate to the [native batch processing](https://aws.amazon.com/about-aw return {"statusCode": 200} ``` -=== "Decorator: After" +=== "[After] Decorator" - ```python hl_lines="3 5 11" + ```python hl_lines="3 5 11 13" import json from aws_lambda_powertools.utilities.batch import BatchProcessor, EventType, batch_processor @@ -101,7 +70,7 @@ You can migrate to the [native batch processing](https://aws.amazon.com/about-aw return processor.response() ``` -=== "Context manager: Before" +=== "[Before] Context manager" ```python hl_lines="1-2 4 14 19" from aws_lambda_powertools.utilities.batch import PartialSQSProcessor @@ -125,9 +94,9 @@ You can migrate to the [native batch processing](https://aws.amazon.com/about-aw return result ``` -=== "Context manager: After" +=== "[After] Context manager" - ```python hl_lines="1 11" + ```python hl_lines="1 11 16" from aws_lambda_powertools.utilities.batch import BatchProcessor, EventType, batch_processor @@ -146,27 +115,35 @@ You can migrate to the [native batch processing](https://aws.amazon.com/about-aw return processor.response() ``` -## Idempotency key format - -The format of the Idempotency key was changed. This is used store the invocation results on a persistent store like DynamoDB. +## Event Handler headers response format -No changes are necessary in your code, but remember that existing Idempotency records will be ignored when you upgrade, as new executions generate keys with the new format. +!!! note "No code changes required" -Prior to this change, the Idempotency key was generated using only the caller function name (e.g: `lambda_handler#282e83393862a613b612c00283fef4c8`). -After this change, the key is generated using the `module name` + `qualified function name` + `idempotency key` (e.g: `app.classExample.function#app.handler#282e83393862a613b612c00283fef4c8`). +This only applies if you're using `APIGatewayRestResolver` and asserting custom header values in your tests. -Using qualified names prevents distinct functions with the same name to contend for the same Idempotency key. +Previously, custom headers were available under `headers` key in the Event Handler response. -## Feature Flags and AppConfig Parameter utility +```python title="V1 response headers" hl_lines="2" +{ + "headers": { + "Content-Type": "application/json" + } +} +``` -AWS AppConfig deprecated the current API (GetConfiguration) - [more details here](https://github.com/awslabs/aws-lambda-powertools-python/issues/1506#issuecomment-1266645884). +In V2, we add all headers under `multiValueHeaders` key. This enables seamless support for multi-value headers and cookies in [fine grained responses](./core/event_handler/api_gateway.md#fine-grained-responses){target="_blank"}. -You must update your IAM permissions to allow `appconfig:GetLatestConfiguration` and `appconfig:StartConfigurationSession`. There are no code changes required. +```python title="V2 response headers" hl_lines="2" +{ + "multiValueHeaders": { + "Content-Type": "application/json" + } +} +``` ## DynamoDBStreamEvent in Event Source Data Classes -???+ info - This also applies if you're using [**`BatchProcessor`**](https://awslabs.github.io/aws-lambda-powertools-python/latest/utilities/batch/#processing-messages-from-dynamodb){target="_blank"} to handle DynamoDB Stream events. +!!! info "This also applies if you're using [**DynamoDB BatchProcessor**](https://awslabs.github.io/aws-lambda-powertools-python/latest/utilities/batch/#processing-messages-from-dynamodb){target="_blank"}." You will now receive native Python types when accessing DynamoDB records via `keys`, `new_image`, and `old_image` attributes in `DynamoDBStreamEvent`. @@ -206,3 +183,36 @@ def lambda_handler(event: DynamoDBStreamEvent, context): send_to_sqs(new_image) # Here new_image is just a Python Dict type ``` + +## Feature Flags and AppConfig Parameter utility + +!!! note "No code changes required" + +We replaced `GetConfiguration` API ([now deprecated](https://github.com/awslabs/aws-lambda-powertools-python/issues/1506#issuecomment-1266645884){target="_blank"}) with `GetLatestConfiguration` and `StartConfigurationSession`. + +As such, you must update your IAM Role permissions to allow the following IAM actions: + +* `appconfig:GetLatestConfiguration` +* `appconfig:StartConfigurationSession` + +## Idempotency partition key format + +!!! note "No code changes required" + +We replaced the DynamoDB partition key format to include fully qualified function/method names. This means that recent non-expired idempotent transactions will be ignored. + +Previously, we used the function/method name to generate the partition key value. + +> e.g. `HelloWorldFunction.lambda_handler#99914b932bd37a50b983c5e7c90ae93b` + +![Idempotency Before](./media/upgrade_idempotency_before.png) + +In V2, we now distinguish between distinct classes or modules that may have the same function/method name. + +[For example](https://github.com/awslabs/aws-lambda-powertools-python/issues/1330){target="_blank"}, an ABC or Protocol class may have multiple implementations of `process_payment` method and may have different results. + + + +> e.g. `HelloWorldFunction.app.lambda_handler#99914b932bd37a50b983c5e7c90ae93b` + +![Idempotency Before](./media/upgrade_idempotency_after.png) From b4a12b28fddb30c3fc3767a2821a2121c3dfa93d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BAben=20Fonseca?= Date: Thu, 20 Oct 2022 15:24:32 +0200 Subject: [PATCH 30/30] fix: lock dependencies --- poetry.lock | 64 ++++++++++++++++++++++++++--------------------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/poetry.lock b/poetry.lock index daa05ac8a61..6fa066bb224 100644 --- a/poetry.lock +++ b/poetry.lock @@ -13,7 +13,7 @@ tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900 tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] [[package]] -name = "aws-cdk.aws-apigatewayv2-alpha" +name = "aws-cdk-aws-apigatewayv2-alpha" version = "2.46.0a0" description = "The CDK Construct Library for AWS::APIGatewayv2" category = "dev" @@ -28,7 +28,7 @@ publication = ">=0.0.3" typeguard = ">=2.13.3,<2.14.0" [[package]] -name = "aws-cdk.aws-apigatewayv2-integrations-alpha" +name = "aws-cdk-aws-apigatewayv2-integrations-alpha" version = "2.46.0a0" description = "Integrations for AWS APIGateway V2" category = "dev" @@ -128,14 +128,14 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "boto3" -version = "1.24.91" +version = "1.24.94" description = "The AWS SDK for Python" category = "main" optional = false python-versions = ">= 3.7" [package.dependencies] -botocore = ">=1.27.91,<1.28.0" +botocore = ">=1.27.94,<1.28.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.6.0,<0.7.0" @@ -144,7 +144,7 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.27.91" +version = "1.27.94" description = "Low-level, data-driven core of boto 3." category = "main" optional = false @@ -160,16 +160,16 @@ crt = ["awscrt (==0.14.0)"] [[package]] name = "cattrs" -version = "22.1.0" +version = "22.2.0" description = "Composable complex class support for attrs and dataclasses." category = "dev" optional = false -python-versions = ">=3.7,<4.0" +python-versions = ">=3.7" [package.dependencies] attrs = ">=20" -exceptiongroup = {version = "*", markers = "python_version <= \"3.10\""} -typing_extensions = {version = "*", markers = "python_version >= \"3.7\" and python_version < \"3.8\""} +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} +typing_extensions = {version = "*", markers = "python_version < \"3.8\""} [[package]] name = "certifi" @@ -238,14 +238,14 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "constructs" -version = "10.1.131" +version = "10.1.134" description = "A programming model for software-defined state" category = "dev" optional = false python-versions = "~=3.7" [package.dependencies] -jsii = ">=1.69.0,<2.0.0" +jsii = ">=1.70.0,<2.0.0" publication = ">=0.0.3" typeguard = ">=2.13.3,<2.14.0" @@ -581,7 +581,7 @@ pbr = "*" [[package]] name = "jsii" -version = "1.69.0" +version = "1.70.0" description = "Python client for jsii runtime" category = "dev" optional = false @@ -589,7 +589,7 @@ python-versions = "~=3.7" [package.dependencies] attrs = ">=21.2,<23.0" -cattrs = ">=1.8,<22.2" +cattrs = ">=1.8,<22.3" publication = ">=0.0.3" python-dateutil = "*" typeguard = ">=2.13.3,<2.14.0" @@ -995,7 +995,7 @@ python-versions = ">=3.7" [[package]] name = "pbr" -version = "5.10.0" +version = "5.11.0" description = "Python Build Reasonableness" category = "dev" optional = false @@ -1385,7 +1385,7 @@ python-versions = ">=3.6" [[package]] name = "stevedore" -version = "3.5.1" +version = "3.5.2" description = "Manage dynamic plugins for Python applications" category = "dev" optional = false @@ -1528,18 +1528,18 @@ validation = ["fastjsonschema"] [metadata] lock-version = "1.1" python-versions = "^3.7.4" -content-hash = "b5cc8cfb68d83b842c7c95beede8788b16905ef398c71b0704ed8ae83156ea30" +content-hash = "3efc3b4c3dd7b7a6fe37f519330fb3e450c84ca57b190e7a05fabd444eef8aaa" [metadata.files] attrs = [ {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, ] -"aws-cdk.aws-apigatewayv2-alpha" = [ +aws-cdk-aws-apigatewayv2-alpha = [ {file = "aws-cdk.aws-apigatewayv2-alpha-2.46.0a0.tar.gz", hash = "sha256:10d9324da26db7aeee3a45853a2e249b6b85866fcc8f8f43fa1a0544ce582482"}, {file = "aws_cdk.aws_apigatewayv2_alpha-2.46.0a0-py3-none-any.whl", hash = "sha256:2cdeac84fb1fe219e5686ee95d9528a1810e9d426b2bb7f305ea07cb43e328a8"}, ] -"aws-cdk.aws-apigatewayv2-integrations-alpha" = [ +aws-cdk-aws-apigatewayv2-integrations-alpha = [ {file = "aws-cdk.aws-apigatewayv2-integrations-alpha-2.46.0a0.tar.gz", hash = "sha256:91a792c94500987b69fd97cb00afec5ace00f2039ffebebd99f91ee6b47c3c8b"}, {file = "aws_cdk.aws_apigatewayv2_integrations_alpha-2.46.0a0-py3-none-any.whl", hash = "sha256:c7bbe1c08019cee41c14b6c1513f673d60b337422ef338c67f9a0cb3e17cc963"}, ] @@ -1584,16 +1584,16 @@ black = [ {file = "black-22.10.0.tar.gz", hash = "sha256:f513588da599943e0cde4e32cc9879e825d58720d6557062d1098c5ad80080e1"}, ] boto3 = [ - {file = "boto3-1.24.91-py3-none-any.whl", hash = "sha256:b295640bc1be637f8f7c8c8fca70781048d6397196109e59f20541824fab4b67"}, - {file = "boto3-1.24.91.tar.gz", hash = "sha256:3225366014949039e6687387242e73f237f0fee0a9b7c20461894f1ee40686b8"}, + {file = "boto3-1.24.94-py3-none-any.whl", hash = "sha256:f13db0beb3c9fe2cc1ed0f031189f144610d2909b5874a616e77b0bd1ae3b686"}, + {file = "boto3-1.24.94.tar.gz", hash = "sha256:f4842b395d1580454756622069f4ca0408993885ecede967001d2c101201cdfa"}, ] botocore = [ - {file = "botocore-1.27.91-py3-none-any.whl", hash = "sha256:1d6e97bd8653f732c7078b34aa2bb438e750898957e5a0a74b6c72918bc1d0f7"}, - {file = "botocore-1.27.91.tar.gz", hash = "sha256:c8fac203a391cc2e4b682877bfce70e723e33c529b35b399a1d574605fbeb1af"}, + {file = "botocore-1.27.94-py3-none-any.whl", hash = "sha256:8237c070d2ab29fac4fbcfe9dd2e84e0ee147402e0fed3ac1629f92459c7f1d2"}, + {file = "botocore-1.27.94.tar.gz", hash = "sha256:572224608a0b7662966fc303b768e2eba61bf53bdbf314481cd9e63a0d8e1a66"}, ] cattrs = [ - {file = "cattrs-22.1.0-py3-none-any.whl", hash = "sha256:d55c477b4672f93606e992049f15d526dc7867e6c756cd6256d4af92e2b1e364"}, - {file = "cattrs-22.1.0.tar.gz", hash = "sha256:94b67b64cf92c994f8784c40c082177dc916e0489a73a9a36b24eb18a9db40c6"}, + {file = "cattrs-22.2.0-py3-none-any.whl", hash = "sha256:bc12b1f0d000b9f9bee83335887d532a1d3e99a833d1bf0882151c97d3e68c21"}, + {file = "cattrs-22.2.0.tar.gz", hash = "sha256:f0eed5642399423cf656e7b66ce92cdc5b963ecafd041d1b24d136fdde7acf6d"}, ] certifi = [ {file = "certifi-2022.9.24-py3-none-any.whl", hash = "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"}, @@ -1620,8 +1620,8 @@ colorama = [ {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, ] constructs = [ - {file = "constructs-10.1.131-py3-none-any.whl", hash = "sha256:3dc720ce1593ad8e05c76b11abee1ab196e7a9ced3a5e1b4539c7d06cd9cd5cf"}, - {file = "constructs-10.1.131.tar.gz", hash = "sha256:90b2ca25b6f26f7547d1ea695bb55f9ce2f08072d322af5afcce0347a3add9af"}, + {file = "constructs-10.1.134-py3-none-any.whl", hash = "sha256:b3f05ad138af83473cc9bd5f8949558bd31d38fb32c09fcc56d0de9057c2e61d"}, + {file = "constructs-10.1.134.tar.gz", hash = "sha256:4ab253a74e62a2c918456d20dff42ec0abb2e4393a6bab0218c81c09e19c1a41"}, ] coverage = [ {file = "coverage-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef8674b0ee8cc11e2d574e3e2998aea5df5ab242e012286824ea3c6970580e53"}, @@ -1782,8 +1782,8 @@ jschema-to-python = [ {file = "jschema_to_python-1.2.3.tar.gz", hash = "sha256:76ff14fe5d304708ccad1284e4b11f96a658949a31ee7faed9e0995279549b91"}, ] jsii = [ - {file = "jsii-1.69.0-py3-none-any.whl", hash = "sha256:f3ae5cdf5e854b4d59256dc1f8818cd3fabb8eb43fbd3134a8e8aef962643005"}, - {file = "jsii-1.69.0.tar.gz", hash = "sha256:7c7ed2a913372add17d63322a640c6435324770eb78c6b89e4c701e07d9c84db"}, + {file = "jsii-1.70.0-py3-none-any.whl", hash = "sha256:d0867c0d2f60ceda1664c026033ae34ea36178c7027315e577ded13043827664"}, + {file = "jsii-1.70.0.tar.gz", hash = "sha256:9fc57ff37868364ba3417b26dc97189cd0cc71282196a3f4765768c067354be0"}, ] jsonpatch = [ {file = "jsonpatch-1.32-py2.py3-none-any.whl", hash = "sha256:26ac385719ac9f54df8a2f0827bb8253aa3ea8ab7b3368457bcdb8c14595a397"}, @@ -1971,8 +1971,8 @@ pathspec = [ {file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"}, ] pbr = [ - {file = "pbr-5.10.0-py2.py3-none-any.whl", hash = "sha256:da3e18aac0a3c003e9eea1a81bd23e5a3a75d745670dcf736317b7d966887fdf"}, - {file = "pbr-5.10.0.tar.gz", hash = "sha256:cfcc4ff8e698256fc17ea3ff796478b050852585aa5bae79ecd05b2ab7b39b9a"}, + {file = "pbr-5.11.0-py2.py3-none-any.whl", hash = "sha256:db2317ff07c84c4c63648c9064a79fe9d9f5c7ce85a9099d4b6258b3db83225a"}, + {file = "pbr-5.11.0.tar.gz", hash = "sha256:b97bc6695b2aff02144133c2e7399d5885223d42b7912ffaec2ca3898e673bfe"}, ] pdoc3 = [ {file = "pdoc3-0.10.0-py3-none-any.whl", hash = "sha256:ba45d1ada1bd987427d2bf5cdec30b2631a3ff5fb01f6d0e77648a572ce6028b"}, @@ -2239,8 +2239,8 @@ smmap = [ {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, ] stevedore = [ - {file = "stevedore-3.5.1-py3-none-any.whl", hash = "sha256:df36e6c003264de286d6e589994552d3254052e7fc6a117753d87c471f06de2a"}, - {file = "stevedore-3.5.1.tar.gz", hash = "sha256:1fecadf3d7805b940227f10e6a0140b202c9a24ba5c60cb539159046dc11e8d7"}, + {file = "stevedore-3.5.2-py3-none-any.whl", hash = "sha256:fa2630e3d0ad3e22d4914aff2501445815b9a4467a6edc49387c667a38faf5bf"}, + {file = "stevedore-3.5.2.tar.gz", hash = "sha256:cf99f41fc0d5a4f185ca4d3d42b03be9011b0a1ec1a4ea1a282be1b4b306dcc2"}, ] tomli = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},