From 5c0a14dbd21bbb930617275ff1d98d45b302abb8 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Sat, 22 Jan 2022 11:18:48 +0100 Subject: [PATCH 01/22] docs(layer): update to 1.24.2 --- docs/index.md | 50 +++++++++++++++++++++++++------------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/index.md b/docs/index.md index 84944b06561..6a07e80a315 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,7 +24,7 @@ Core utilities such as Tracing, Logging, Metrics, and Event Handler will be avai Powertools is available in the following formats: -* **Lambda Layer**: [**arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPython:8 :clipboard:**](#){: .copyMe} +* **Lambda Layer**: [**arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPython:9 :clipboard:**](#){: .copyMe} * **PyPi**: **`pip install aws-lambda-powertools`** ### Lambda Layer @@ -37,23 +37,23 @@ You can include Lambda Powertools Lambda Layer using [AWS Lambda Console](https: | Region | Layer ARN |--------------------------- | --------------------------- - | `us-east-1` | [arn:aws:lambda:us-east-1:017000801446:layer:AWSLambdaPowertoolsPython:8 :clipboard:](#){: .copyMe} - | `us-east-2` | [arn:aws:lambda:us-east-2:017000801446:layer:AWSLambdaPowertoolsPython:8 :clipboard:](#){: .copyMe} - | `us-west-1` | [arn:aws:lambda:us-west-1:017000801446:layer:AWSLambdaPowertoolsPython:8 :clipboard:](#){: .copyMe} - | `us-west-2` | [arn:aws:lambda:us-west-2:017000801446:layer:AWSLambdaPowertoolsPython:8 :clipboard:](#){: .copyMe} - | `ap-south-1` | [arn:aws:lambda:ap-south-1:017000801446:layer:AWSLambdaPowertoolsPython:8 :clipboard:](#){: .copyMe} - | `ap-northeast-1` | [arn:aws:lambda:ap-northeast-1:017000801446:layer:AWSLambdaPowertoolsPython:8 :clipboard:](#){: .copyMe} - | `ap-northeast-2` | [arn:aws:lambda:ap-northeast-2:017000801446:layer:AWSLambdaPowertoolsPython:8 :clipboard:](#){: .copyMe} - | `ap-northeast-3` | [arn:aws:lambda:ap-northeast-3:017000801446:layer:AWSLambdaPowertoolsPython:8 :clipboard:](#){: .copyMe} - | `ap-southeast-1` | [arn:aws:lambda:ap-southeast-1:017000801446:layer:AWSLambdaPowertoolsPython:8 :clipboard:](#){: .copyMe} - | `ap-southeast-2` | [arn:aws:lambda:ap-southeast-2:017000801446:layer:AWSLambdaPowertoolsPython:8 :clipboard:](#){: .copyMe} - | `eu-central-1` | [arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPython:8 :clipboard:](#){: .copyMe} - | `eu-west-1` | [arn:aws:lambda:eu-west-1:017000801446:layer:AWSLambdaPowertoolsPython:8 :clipboard:](#){: .copyMe} - | `eu-west-2` | [arn:aws:lambda:eu-west-2:017000801446:layer:AWSLambdaPowertoolsPython:8 :clipboard:](#){: .copyMe} - | `eu-west-3` | [arn:aws:lambda:eu-west-3:017000801446:layer:AWSLambdaPowertoolsPython:8 :clipboard:](#){: .copyMe} - | `eu-north-1` | [arn:aws:lambda:eu-north-1:017000801446:layer:AWSLambdaPowertoolsPython:8 :clipboard:](#){: .copyMe} - | `ca-central-1` | [arn:aws:lambda:ca-central-1:017000801446:layer:AWSLambdaPowertoolsPython:8 :clipboard:](#){: .copyMe} - | `sa-east-1` | [arn:aws:lambda:sa-east-1:017000801446:layer:AWSLambdaPowertoolsPython:8 :clipboard:](#){: .copyMe} + | `us-east-1` | [arn:aws:lambda:us-east-1:017000801446:layer:AWSLambdaPowertoolsPython:9 :clipboard:](#){: .copyMe} + | `us-east-2` | [arn:aws:lambda:us-east-2:017000801446:layer:AWSLambdaPowertoolsPython:9 :clipboard:](#){: .copyMe} + | `us-west-1` | [arn:aws:lambda:us-west-1:017000801446:layer:AWSLambdaPowertoolsPython:9 :clipboard:](#){: .copyMe} + | `us-west-2` | [arn:aws:lambda:us-west-2:017000801446:layer:AWSLambdaPowertoolsPython:9 :clipboard:](#){: .copyMe} + | `ap-south-1` | [arn:aws:lambda:ap-south-1:017000801446:layer:AWSLambdaPowertoolsPython:9 :clipboard:](#){: .copyMe} + | `ap-northeast-1` | [arn:aws:lambda:ap-northeast-1:017000801446:layer:AWSLambdaPowertoolsPython:9 :clipboard:](#){: .copyMe} + | `ap-northeast-2` | [arn:aws:lambda:ap-northeast-2:017000801446:layer:AWSLambdaPowertoolsPython:9 :clipboard:](#){: .copyMe} + | `ap-northeast-3` | [arn:aws:lambda:ap-northeast-3:017000801446:layer:AWSLambdaPowertoolsPython:9 :clipboard:](#){: .copyMe} + | `ap-southeast-1` | [arn:aws:lambda:ap-southeast-1:017000801446:layer:AWSLambdaPowertoolsPython:9 :clipboard:](#){: .copyMe} + | `ap-southeast-2` | [arn:aws:lambda:ap-southeast-2:017000801446:layer:AWSLambdaPowertoolsPython:9 :clipboard:](#){: .copyMe} + | `eu-central-1` | [arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPython:9 :clipboard:](#){: .copyMe} + | `eu-west-1` | [arn:aws:lambda:eu-west-1:017000801446:layer:AWSLambdaPowertoolsPython:9 :clipboard:](#){: .copyMe} + | `eu-west-2` | [arn:aws:lambda:eu-west-2:017000801446:layer:AWSLambdaPowertoolsPython:9 :clipboard:](#){: .copyMe} + | `eu-west-3` | [arn:aws:lambda:eu-west-3:017000801446:layer:AWSLambdaPowertoolsPython:9 :clipboard:](#){: .copyMe} + | `eu-north-1` | [arn:aws:lambda:eu-north-1:017000801446:layer:AWSLambdaPowertoolsPython:9 :clipboard:](#){: .copyMe} + | `ca-central-1` | [arn:aws:lambda:ca-central-1:017000801446:layer:AWSLambdaPowertoolsPython:9 :clipboard:](#){: .copyMe} + | `sa-east-1` | [arn:aws:lambda:sa-east-1:017000801446:layer:AWSLambdaPowertoolsPython:9 :clipboard:](#){: .copyMe} === "SAM" @@ -62,7 +62,7 @@ You can include Lambda Powertools Lambda Layer using [AWS Lambda Console](https: Type: AWS::Serverless::Function Properties: Layers: - - !Sub arn:aws:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPython:8 + - !Sub arn:aws:lambda:${AWS::Region}:017000801446:layer:AWSLambdaPowertoolsPython:9 ``` === "Serverless framework" @@ -72,7 +72,7 @@ You can include Lambda Powertools Lambda Layer using [AWS Lambda Console](https: hello: handler: lambda_function.lambda_handler layers: - - arn:aws:lambda:${aws:region}:017000801446:layer:AWSLambdaPowertoolsPython:8 + - arn:aws:lambda:${aws:region}:017000801446:layer:AWSLambdaPowertoolsPython:9 ``` === "CDK" @@ -88,7 +88,7 @@ You can include Lambda Powertools Lambda Layer using [AWS Lambda Console](https: 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:8" + layer_version_arn=f"arn:aws:lambda:{env.region}:017000801446:layer:AWSLambdaPowertoolsPython:9" ) aws_lambda.Function(self, 'sample-app-lambda', @@ -137,7 +137,7 @@ You can include Lambda Powertools Lambda Layer using [AWS Lambda Console](https: role = aws_iam_role.iam_for_lambda.arn handler = "index.test" runtime = "python3.9" - layers = ["arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPython:8"] + layers = ["arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPython:9"] source_code_hash = filebase64sha256("lambda_function_payload.zip") } @@ -156,7 +156,7 @@ You can include Lambda Powertools Lambda Layer using [AWS Lambda Console](https: ? 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:8 + ? Enter up to 5 existing Lambda layer ARNs (comma-separated): arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPython:9 ❯ amplify push -y @@ -167,7 +167,7 @@ You can include Lambda Powertools Lambda Layer using [AWS Lambda Console](https: - 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:8 + ? Enter up to 5 existing Lambda layer ARNs (comma-separated): arn:aws:lambda:eu-central-1:017000801446:layer:AWSLambdaPowertoolsPython:9 ? Do you want to edit the local lambda function now? No ``` @@ -175,7 +175,7 @@ You can include Lambda Powertools Lambda Layer using [AWS Lambda Console](https: 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:AWSLambdaPowertoolsPython:8 --region {region} + aws lambda get-layer-version-by-arn --arn arn:aws:lambda:{region}:017000801446:layer:AWSLambdaPowertoolsPython:9 --region {region} ``` The pre-signed URL to download this Lambda Layer will be within `Location` key. From 204ece594c24c8ad532f57de54fd77b723f1ffe0 Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Sat, 22 Jan 2022 22:35:59 -0800 Subject: [PATCH 02/22] fix(logger): test generates logfile Currently test_copy_config_to_ext_loggers_clean_old_handlers creates a file called "logfile" --- tests/functional/test_logger_utils.py | 40 ++++++++++----------------- 1 file changed, 14 insertions(+), 26 deletions(-) diff --git a/tests/functional/test_logger_utils.py b/tests/functional/test_logger_utils.py index 1317fefc6ab..8b802586389 100644 --- a/tests/functional/test_logger_utils.py +++ b/tests/functional/test_logger_utils.py @@ -30,9 +30,8 @@ class LogLevel(Enum): @pytest.fixture def logger(stdout, log_level): def _logger(): - logging.basicConfig(stream=stdout, level=log_level.NOTSET.value) - logger = logging.getLogger(name=service_name()) - return logger + logging.basicConfig(stream=stdout, level=log_level.INFO.value) + return logging.getLogger(name=service_name()) return _logger @@ -51,10 +50,7 @@ def service_name(): def test_copy_config_to_ext_loggers(stdout, logger, log_level): - - msg = "test message" - - # GIVEN a external logger and powertools logger initialized + # GIVEN an external logger and powertools logger initialized logger_1 = logger() logger_2 = logger() @@ -62,6 +58,7 @@ def test_copy_config_to_ext_loggers(stdout, logger, log_level): # WHEN configuration copied from powertools logger to ALL external loggers AND our external logger used utils.copy_config_to_registered_loggers(source_logger=powertools_logger) + msg = "test message1" logger_1.info(msg) logger_2.info(msg) logs = capture_multiple_logging_statements_output(stdout) @@ -77,15 +74,13 @@ def test_copy_config_to_ext_loggers(stdout, logger, log_level): def test_copy_config_to_ext_loggers_include(stdout, logger, log_level): - - msg = "test message" - - # GIVEN a external logger and powertools logger initialized + # GIVEN an external logger and powertools logger initialized logger = logger() powertools_logger = Logger(service=service_name(), level=log_level.INFO.value, stream=stdout) # WHEN configuration copied from powertools logger to ALL external loggers AND our external logger used utils.copy_config_to_registered_loggers(source_logger=powertools_logger, include={logger.name}) + msg = "test message2" logger.info(msg) log = capture_logging_output(stdout) @@ -99,8 +94,7 @@ def test_copy_config_to_ext_loggers_include(stdout, logger, log_level): def test_copy_config_to_ext_loggers_wrong_include(stdout, logger, log_level): - - # GIVEN a external logger and powertools logger initialized + # GIVEN an external logger and powertools logger initialized logger = logger() powertools_logger = Logger(service=service_name(), level=log_level.INFO.value, stream=stdout) @@ -112,8 +106,7 @@ def test_copy_config_to_ext_loggers_wrong_include(stdout, logger, log_level): def test_copy_config_to_ext_loggers_exclude(stdout, logger, log_level): - - # GIVEN a external logger and powertools logger initialized + # GIVEN an external logger and powertools logger initialized logger = logger() powertools_logger = Logger(service=service_name(), level=log_level.INFO.value, stream=stdout) @@ -125,10 +118,7 @@ def test_copy_config_to_ext_loggers_exclude(stdout, logger, log_level): def test_copy_config_to_ext_loggers_include_exclude(stdout, logger, log_level): - - msg = "test message" - - # GIVEN a external logger and powertools logger initialized + # GIVEN an external logger and powertools logger initialized logger_1 = logger() logger_2 = logger() @@ -138,6 +128,7 @@ def test_copy_config_to_ext_loggers_include_exclude(stdout, logger, log_level): utils.copy_config_to_registered_loggers( source_logger=powertools_logger, include={logger_1.name, logger_2.name}, exclude={logger_1.name} ) + msg = "test message3" logger_2.info(msg) log = capture_logging_output(stdout) @@ -152,10 +143,9 @@ def test_copy_config_to_ext_loggers_include_exclude(stdout, logger, log_level): def test_copy_config_to_ext_loggers_clean_old_handlers(stdout, logger, log_level): - - # GIVEN a external logger with handler and powertools logger initialized + # GIVEN an external logger with handler and powertools logger initialized logger = logger() - handler = logging.FileHandler("logfile") + handler = logging.NullHandler() logger.addHandler(handler) powertools_logger = Logger(service=service_name(), level=log_level.INFO.value, stream=stdout) @@ -169,10 +159,7 @@ def test_copy_config_to_ext_loggers_clean_old_handlers(stdout, logger, log_level def test_copy_config_to_ext_loggers_custom_log_level(stdout, logger, log_level): - - msg = "test message" - - # GIVEN a external logger and powertools logger initialized + # GIVEN an external logger and powertools logger initialized logger = logger() powertools_logger = Logger(service=service_name(), level=log_level.CRITICAL.value, stream=stdout) level = log_level.WARNING.name @@ -180,6 +167,7 @@ def test_copy_config_to_ext_loggers_custom_log_level(stdout, logger, log_level): # WHEN configuration copied from powertools logger to ALL external loggers # AND our external logger used with custom log_level utils.copy_config_to_registered_loggers(source_logger=powertools_logger, include={logger.name}, log_level=level) + msg = "test message4" logger.warning(msg) log = capture_logging_output(stdout) From 33a86be96bef42e4710d9d441fc5338662ba4c33 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Wed, 26 Jan 2022 09:14:27 +0100 Subject: [PATCH 03/22] fix(ci): pr label regex for special chars in title --- .github/workflows/label_pr_on_title.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/label_pr_on_title.yml b/.github/workflows/label_pr_on_title.yml index 8d7871c899f..2e4afb3dac8 100644 --- a/.github/workflows/label_pr_on_title.yml +++ b/.github/workflows/label_pr_on_title.yml @@ -53,12 +53,12 @@ jobs: const pr_number = Number(fs.readFileSync('./number')); const pr_title = fs.readFileSync('./title', 'utf-8').trim(); - const FEAT_REGEX = /feat(\((\w+)\))?(\:.+)/ - const BUG_REGEX = /(fix|bug)(\((\w+)\))?(\:.+)/ - const DOCS_REGEX = /(docs|doc)(\((\w+)\))?(\:.+)/ - const CHORE_REGEX = /(chore)(\((\w+)\))?(\:.+)/ - const DEPRECATED_REGEX = /(deprecated)(\((\w+)\))?(\:.+)/ - const REFACTOR_REGEX = /(refactor)(\((\w+)\))?(\:.+)/ + const FEAT_REGEX = /feat(\((.+)\))?(\:.+)/ + const BUG_REGEX = /(fix|bug)(\((.+)\))?(\:.+)/ + const DOCS_REGEX = /(docs|doc)(\((.+)\))?(\:.+)/ + const CHORE_REGEX = /(chore)(\((.+)\))?(\:.+)/ + const DEPRECATED_REGEX = /(deprecated)(\((.+)\))?(\:.+)/ + const REFACTOR_REGEX = /(refactor)(\((.+)\))?(\:.+)/ const labels = { "feature": FEAT_REGEX, From ed7a9788deca93fce28596c702587d20aa2af6d7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Jan 2022 15:51:40 +0100 Subject: [PATCH 04/22] chore(deps-dev): bump flake8-bugbear from 21.11.29 to 22.1.11 (#955) --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 1ed434d9afa..402faa5b636 100644 --- a/poetry.lock +++ b/poetry.lock @@ -243,7 +243,7 @@ toml = "*" [[package]] name = "flake8-bugbear" -version = "21.11.29" +version = "22.1.11" description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle." category = "dev" optional = false @@ -1056,7 +1056,7 @@ pydantic = ["pydantic", "email-validator"] [metadata] lock-version = "1.1" python-versions = "^3.6.2" -content-hash = "c1df73741840dc9ee5e8dbbf6bfa02e09d3c84f53318e3e36cba9b30f6f2d2e4" +content-hash = "9e9ce00400070ccabc8efa6babe951d01f2b2c8b04a504b9fb06cbbf81dc1c7b" [metadata.files] atomicwrites = [ @@ -1180,8 +1180,8 @@ flake8-black = [ {file = "flake8_black-0.2.3-py3-none-any.whl", hash = "sha256:cc080ba5b3773b69ba102b6617a00cc4ecbad8914109690cfda4d565ea435d96"}, ] flake8-bugbear = [ - {file = "flake8-bugbear-21.11.29.tar.gz", hash = "sha256:8b04cb2fafc6a78e1a9d873bd3988e4282f7959bb6b0d7c1ae648ec09b937a7b"}, - {file = "flake8_bugbear-21.11.29-py36.py37.py38-none-any.whl", hash = "sha256:179e41ddae5de5e3c20d1f61736feeb234e70958fbb56ab3c28a67739c8e9a82"}, + {file = "flake8-bugbear-22.1.11.tar.gz", hash = "sha256:4c2a4136bd4ecb8bf02d5159af302ffc067642784c9d0488b33ce4610da825ee"}, + {file = "flake8_bugbear-22.1.11-py3-none-any.whl", hash = "sha256:ce7ae44aaaf67ef192b8a6de94a5ac617144e1675ad0654fdea556f48dc18d9b"}, ] flake8-builtins = [ {file = "flake8-builtins-1.5.3.tar.gz", hash = "sha256:09998853b2405e98e61d2ff3027c47033adbdc17f9fe44ca58443d876eb00f3b"}, diff --git a/pyproject.toml b/pyproject.toml index 2efaf62f8b1..422c88e627b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ bandit = "^1.7.1" radon = "^5.1.0" xenon = "^0.8.0" flake8-eradicate = "^1.2.0" -flake8-bugbear = "^21.11.29" +flake8-bugbear = "^22.1.11" mkdocs-material = "^7.3.6" mkdocs-git-revision-date-plugin = "^0.3.1" mike = "^0.6.0" From dea696ed62976d3feb23dc4d1fe403555e95562b Mon Sep 17 00:00:00 2001 From: mploski Date: Mon, 31 Jan 2022 14:47:22 +0100 Subject: [PATCH 05/22] feat(mypy): complete mypy support for the entire codebase (#943) Co-authored-by: Michal Ploski Co-authored-by: Heitor Lessa Co-authored-by: heitorlessa --- .github/workflows/python_build.yml | 2 ++ CONTRIBUTING.md | 9 ++++--- Makefile | 2 +- .../event_handler/api_gateway.py | 6 ++--- aws_lambda_powertools/metrics/metrics.py | 5 ++-- aws_lambda_powertools/shared/functions.py | 12 +++++---- .../utilities/batch/exceptions.py | 2 +- aws_lambda_powertools/utilities/batch/sqs.py | 14 +++++++--- .../utilities/idempotency/idempotency.py | 4 +-- .../utilities/idempotency/persistence/base.py | 7 +++-- .../utilities/parameters/base.py | 26 ++++++++++--------- .../utilities/parameters/ssm.py | 6 +++-- .../utilities/parser/envelopes/apigw.py | 2 +- .../utilities/parser/envelopes/apigwv2.py | 2 +- .../parser/envelopes/event_bridge.py | 2 +- .../utilities/parser/envelopes/kinesis.py | 11 +++++--- .../utilities/parser/envelopes/sns.py | 6 +++-- .../utilities/parser/models/alb.py | 6 ++--- .../utilities/parser/models/apigw.py | 6 ++--- .../utilities/parser/models/apigwv2.py | 6 ++--- .../utilities/parser/models/cloudwatch.py | 6 ++--- .../utilities/parser/models/dynamodb.py | 8 +++--- .../utilities/parser/models/event_bridge.py | 6 ++--- .../utilities/parser/models/kinesis.py | 6 ++--- .../parser/models/s3_object_event.py | 6 ++--- .../utilities/parser/models/sns.py | 8 +++--- .../utilities/parser/models/sqs.py | 6 ++--- mypy.ini | 3 +++ .../idempotency/test_idempotency.py | 23 ++++++++++++++++ tests/functional/test_metrics.py | 20 ++++++++++++++ 30 files changed, 145 insertions(+), 83 deletions(-) diff --git a/.github/workflows/python_build.yml b/.github/workflows/python_build.yml index 17aa08ead81..7af7bb8e4ba 100644 --- a/.github/workflows/python_build.yml +++ b/.github/workflows/python_build.yml @@ -30,6 +30,8 @@ jobs: run: make dev - name: Formatting and Linting run: make lint + - name: Static type checking + run: make mypy - name: Test with pytest run: make test - name: Security baseline diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0941fbc535b..060726ec11a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,13 +49,14 @@ You might find useful to run both the documentation website and the API referenc Category | Convention ------------------------------------------------- | --------------------------------------------------------------------------------- -**Docstring** | We use a slight variation of numpy convention with markdown to help generate more readable API references. -**Style guide** | We use black as well as flake8 extensions to enforce beyond good practices [PEP8](https://pep8.org/). We strive to make use of type annotation as much as possible, but don't overdo in creating custom types. +**Docstring** | We use a slight variation of Numpy convention with markdown to help generate more readable API references. +**Style guide** | We use black as well as flake8 extensions to enforce beyond good practices [PEP8](https://pep8.org/). We use type annotations and enforce static type checking at CI (mypy). **Core utilities** | Core utilities use a Class, always accept `service` as a constructor parameter, can work in isolation, and are also available in other languages implementation. **Utilities** | Utilities are not as strict as core and focus on solving a developer experience problem while following the project [Tenets](https://awslabs.github.io/aws-lambda-powertools-python/#tenets). **Exceptions** | Specific exceptions live within utilities themselves and use `Error` suffix e.g. `MetricUnitError`. -**Git commits** | We follow [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). These are not enforced as we squash and merge PRs, but PR titles are enforced during CI. -**Documentation** | API reference docs are generated from docstrings which should have Examples section to allow developers to have what they need within their own IDE. Documentation website covers the wider usage, tips, and strive to be concise. +**Git commits** | We follow [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). We do not enforce conventional commits on contributors to lower the entry bar. Instead, we enforce a conventional PR title so our label automation and changelog are generated correctly. +**API documentation** | API reference docs are generated from docstrings which should have Examples section to allow developers to have what they need within their own IDE. Documentation website covers the wider usage, tips, and strive to be concise. +**Documentation** | We treat it like a product. We sub-divide content aimed at getting started (80% of customers) vs advanced usage (20%). We also ensure customers know how to unit test their code when using our features. ## Finding contributions to work on diff --git a/Makefile b/Makefile index 5b8e9b0d689..fc350ac6923 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ coverage-html: pre-commit: pre-commit run --show-diff-on-failure -pr: lint pre-commit test security-baseline complexity-baseline +pr: lint mypy pre-commit test security-baseline complexity-baseline build: pr poetry build diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 5017597c0f1..f423f1291fa 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -10,7 +10,7 @@ from enum import Enum from functools import partial from http import HTTPStatus -from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, Union +from typing import Any, Callable, Dict, List, Match, Optional, Pattern, Set, Tuple, Type, Union from aws_lambda_powertools.event_handler import content_types from aws_lambda_powertools.event_handler.exceptions import NotFoundError, ServiceError @@ -167,7 +167,7 @@ class Route: """Internally used Route Configuration""" def __init__( - self, method: str, rule: Any, func: Callable, cors: bool, compress: bool, cache_control: Optional[str] + self, method: str, rule: Pattern, func: Callable, cors: bool, compress: bool, cache_control: Optional[str] ): self.method = method.upper() self.rule = rule @@ -555,7 +555,7 @@ def _resolve(self) -> ResponseBuilder: for route in self._routes: if method != route.method: continue - match_results: Optional[re.Match] = route.rule.match(path) + match_results: Optional[Match] = route.rule.match(path) if match_results: logger.debug("Found a registered route. Calling function") return self._call_route(route, match_results.groupdict()) # pass fn args diff --git a/aws_lambda_powertools/metrics/metrics.py b/aws_lambda_powertools/metrics/metrics.py index 927b6873648..00e083d4a7f 100644 --- a/aws_lambda_powertools/metrics/metrics.py +++ b/aws_lambda_powertools/metrics/metrics.py @@ -53,7 +53,7 @@ def lambda_handler(): ---------- service : str, optional service name to be used as metric dimension, by default "service_undefined" - namespace : str + namespace : str, optional Namespace for metrics Raises @@ -209,5 +209,6 @@ def __add_cold_start_metric(self, context: Any) -> None: logger.debug("Adding cold start metric and function_name dimension") with single_metric(name="ColdStart", unit=MetricUnit.Count, value=1, namespace=self.namespace) as metric: metric.add_dimension(name="function_name", value=context.function_name) - metric.add_dimension(name="service", value=self.service) + if self.service: + metric.add_dimension(name="service", value=str(self.service)) is_cold_start = False diff --git a/aws_lambda_powertools/shared/functions.py b/aws_lambda_powertools/shared/functions.py index 11c4e4ce77c..37621f8274a 100644 --- a/aws_lambda_powertools/shared/functions.py +++ b/aws_lambda_powertools/shared/functions.py @@ -1,4 +1,4 @@ -from typing import Any, Optional, Union +from typing import Optional, Union def strtobool(value: str) -> bool: @@ -38,21 +38,23 @@ def resolve_truthy_env_var_choice(env: str, choice: Optional[bool] = None) -> bo return choice if choice is not None else strtobool(env) -def resolve_env_var_choice(env: Any, choice: Optional[Any] = None) -> Union[bool, Any]: +def resolve_env_var_choice( + env: Optional[str] = None, choice: Optional[Union[str, float]] = None +) -> Optional[Union[str, float]]: """Pick explicit choice over env, if available, otherwise return env value received NOTE: Environment variable should be resolved by the caller. Parameters ---------- - env : Any + env : str, Optional environment variable actual value - choice : bool + choice : str|float, optional explicit choice Returns ------- - choice : str + choice : str, Optional resolved choice as either bool or environment value """ return choice if choice is not None else env diff --git a/aws_lambda_powertools/utilities/batch/exceptions.py b/aws_lambda_powertools/utilities/batch/exceptions.py index dc4ca300c7c..d90c25f12bc 100644 --- a/aws_lambda_powertools/utilities/batch/exceptions.py +++ b/aws_lambda_powertools/utilities/batch/exceptions.py @@ -5,7 +5,7 @@ from types import TracebackType from typing import List, Optional, Tuple, Type -ExceptionInfo = Tuple[Type[BaseException], BaseException, TracebackType] +ExceptionInfo = Tuple[Optional[Type[BaseException]], Optional[BaseException], Optional[TracebackType]] class BaseBatchProcessingError(Exception): diff --git a/aws_lambda_powertools/utilities/batch/sqs.py b/aws_lambda_powertools/utilities/batch/sqs.py index 38773a399dd..411e400615d 100644 --- a/aws_lambda_powertools/utilities/batch/sqs.py +++ b/aws_lambda_powertools/utilities/batch/sqs.py @@ -5,11 +5,13 @@ """ import logging import sys -from typing import Callable, Dict, List, Optional, Tuple +from typing import 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 @@ -84,11 +86,17 @@ def _get_queue_url(self) -> Optional[str]: *_, 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: + def _get_entries_to_clean(self) -> List[Dict[str, str]]: """ Format messages to use in batch deletion """ - return [{"Id": msg["messageId"], "ReceiptHandle": msg["receiptHandle"]} for msg in self.success_messages] + 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: """ diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py index 42b8052fd32..4a7d8e71e1d 100644 --- a/aws_lambda_powertools/utilities/idempotency/idempotency.py +++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py @@ -112,7 +112,7 @@ def process_order(customer_id: str, order: dict, **kwargs): return {"StatusCode": 200} """ - if function is None: + if not function: return cast( AnyCallableT, functools.partial( @@ -132,7 +132,7 @@ def decorate(*args, **kwargs): payload = kwargs.get(data_keyword_argument) - if payload is None: + if not payload: raise RuntimeError( f"Unable to extract '{data_keyword_argument}' from keyword arguments." f" Ensure this exists in your function's signature as well as the caller used it as a keyword argument" diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index b07662e6432..e6ffea10de8 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -92,16 +92,16 @@ def status(self) -> str: else: raise IdempotencyInvalidStatusError(self._status) - def response_json_as_dict(self) -> dict: + def response_json_as_dict(self) -> Optional[dict]: """ Get response data deserialized to python dict Returns ------- - dict + Optional[dict] previous response data deserialized """ - return json.loads(self.response_data) + return json.loads(self.response_data) if self.response_data else None class BasePersistenceLayer(ABC): @@ -121,7 +121,6 @@ def __init__(self): self.raise_on_no_idempotency_key = False self.expires_after_seconds: int = 60 * 60 # 1 hour default self.use_local_cache = False - self._cache: Optional[LRUDict] = None self.hash_function = None def configure(self, config: IdempotencyConfig, function_name: Optional[str] = None) -> None: diff --git a/aws_lambda_powertools/utilities/parameters/base.py b/aws_lambda_powertools/utilities/parameters/base.py index b059a3b2483..7e8588eb895 100644 --- a/aws_lambda_powertools/utilities/parameters/base.py +++ b/aws_lambda_powertools/utilities/parameters/base.py @@ -44,7 +44,7 @@ def get( transform: Optional[str] = None, force_fetch: bool = False, **sdk_options, - ) -> Union[str, list, dict, bytes]: + ) -> Optional[Union[str, dict, bytes]]: """ Retrieve a parameter value or return the cached value @@ -81,6 +81,7 @@ def get( # of supported transform is small and the probability that a given # parameter will always be used in a specific transform, this should be # an acceptable tradeoff. + value: Optional[Union[str, bytes, dict]] = None key = (name, transform) if not force_fetch and self._has_not_expired(key): @@ -92,7 +93,7 @@ def get( except Exception as exc: raise GetParameterError(str(exc)) - if transform is not None: + if transform: if isinstance(value, bytes): value = value.decode("utf-8") value = transform_value(value, transform) @@ -146,26 +147,25 @@ def get_multiple( TransformParameterError When the parameter provider fails to transform a parameter value. """ - key = (path, transform) if not force_fetch and self._has_not_expired(key): return self.store[key].value try: - values: Dict[str, Union[str, bytes, dict, None]] = self._get_multiple(path, **sdk_options) + values = self._get_multiple(path, **sdk_options) # Encapsulate all errors into a generic GetParameterError except Exception as exc: raise GetParameterError(str(exc)) - if transform is not None: - for (key, value) in values.items(): - _transform = get_transform_method(key, transform) - if _transform is None: + if transform: + transformed_values: dict = {} + for (item, value) in values.items(): + _transform = get_transform_method(item, transform) + if not _transform: continue - - values[key] = transform_value(value, _transform, raise_on_transform_error) - + transformed_values[item] = transform_value(value, _transform, raise_on_transform_error) + values.update(transformed_values) self.store[key] = ExpirableValue(values, datetime.now() + timedelta(seconds=max_age)) return values @@ -217,7 +217,9 @@ def get_transform_method(key: str, transform: Optional[str] = None) -> Optional[ return None -def transform_value(value: str, transform: str, raise_on_transform_error: bool = True) -> Union[dict, bytes, None]: +def transform_value( + value: str, transform: str, raise_on_transform_error: Optional[bool] = True +) -> Optional[Union[dict, bytes]]: """ Apply a transform to a value diff --git a/aws_lambda_powertools/utilities/parameters/ssm.py b/aws_lambda_powertools/utilities/parameters/ssm.py index 4cbb16354c7..fd55e40a95f 100644 --- a/aws_lambda_powertools/utilities/parameters/ssm.py +++ b/aws_lambda_powertools/utilities/parameters/ssm.py @@ -87,7 +87,9 @@ def __init__(self, config: Optional[Config] = None, boto3_session: Optional[boto super().__init__() - def get( + # We break Liskov substitution principle due to differences in signatures of this method and superclass get method + # We ignore mypy error, as changes to the signature here or in a superclass is a breaking change to users + def get( # type: ignore[override] self, name: str, max_age: int = DEFAULT_MAX_AGE_SECS, @@ -95,7 +97,7 @@ def get( decrypt: bool = False, force_fetch: bool = False, **sdk_options - ) -> Union[str, list, dict, bytes]: + ) -> Optional[Union[str, dict, bytes]]: """ Retrieve a parameter value or return the cached value diff --git a/aws_lambda_powertools/utilities/parser/envelopes/apigw.py b/aws_lambda_powertools/utilities/parser/envelopes/apigw.py index 6b74a3037e9..a9af93e9b9c 100644 --- a/aws_lambda_powertools/utilities/parser/envelopes/apigw.py +++ b/aws_lambda_powertools/utilities/parser/envelopes/apigw.py @@ -27,6 +27,6 @@ def parse(self, data: Optional[Union[Dict[str, Any], Any]], model: Type[Model]) Parsed detail payload with model provided """ logger.debug(f"Parsing incoming data with Api Gateway model {APIGatewayProxyEventModel}") - parsed_envelope = APIGatewayProxyEventModel.parse_obj(data) + parsed_envelope: APIGatewayProxyEventModel = APIGatewayProxyEventModel.parse_obj(data) logger.debug(f"Parsing event payload in `detail` with {model}") return self._parse(data=parsed_envelope.body, model=model) diff --git a/aws_lambda_powertools/utilities/parser/envelopes/apigwv2.py b/aws_lambda_powertools/utilities/parser/envelopes/apigwv2.py index a627e4da0e5..336645a2b73 100644 --- a/aws_lambda_powertools/utilities/parser/envelopes/apigwv2.py +++ b/aws_lambda_powertools/utilities/parser/envelopes/apigwv2.py @@ -27,6 +27,6 @@ def parse(self, data: Optional[Union[Dict[str, Any], Any]], model: Type[Model]) Parsed detail payload with model provided """ logger.debug(f"Parsing incoming data with Api Gateway model V2 {APIGatewayProxyEventV2Model}") - parsed_envelope = APIGatewayProxyEventV2Model.parse_obj(data) + parsed_envelope: APIGatewayProxyEventV2Model = APIGatewayProxyEventV2Model.parse_obj(data) logger.debug(f"Parsing event payload in `detail` with {model}") return self._parse(data=parsed_envelope.body, model=model) diff --git a/aws_lambda_powertools/utilities/parser/envelopes/event_bridge.py b/aws_lambda_powertools/utilities/parser/envelopes/event_bridge.py index ad1df09a65e..239bfd72025 100644 --- a/aws_lambda_powertools/utilities/parser/envelopes/event_bridge.py +++ b/aws_lambda_powertools/utilities/parser/envelopes/event_bridge.py @@ -27,6 +27,6 @@ def parse(self, data: Optional[Union[Dict[str, Any], Any]], model: Type[Model]) Parsed detail payload with model provided """ logger.debug(f"Parsing incoming data with EventBridge model {EventBridgeModel}") - parsed_envelope = EventBridgeModel.parse_obj(data) + parsed_envelope: EventBridgeModel = EventBridgeModel.parse_obj(data) logger.debug(f"Parsing event payload in `detail` with {model}") return self._parse(data=parsed_envelope.detail, model=model) diff --git a/aws_lambda_powertools/utilities/parser/envelopes/kinesis.py b/aws_lambda_powertools/utilities/parser/envelopes/kinesis.py index 9db8e4450f2..9ff221a7b7b 100644 --- a/aws_lambda_powertools/utilities/parser/envelopes/kinesis.py +++ b/aws_lambda_powertools/utilities/parser/envelopes/kinesis.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Dict, List, Optional, Type, Union +from typing import Any, Dict, List, Optional, Type, Union, cast from ..models import KinesisDataStreamModel from ..types import Model @@ -37,6 +37,9 @@ def parse(self, data: Optional[Union[Dict[str, Any], Any]], model: Type[Model]) logger.debug(f"Parsing incoming data with Kinesis model {KinesisDataStreamModel}") parsed_envelope: KinesisDataStreamModel = KinesisDataStreamModel.parse_obj(data) logger.debug(f"Parsing Kinesis records in `body` with {model}") - return [ - self._parse(data=record.kinesis.data.decode("utf-8"), model=model) for record in parsed_envelope.Records - ] + models = [] + for record in parsed_envelope.Records: + # We allow either AWS expected contract (bytes) or a custom Model, see #943 + data = cast(bytes, record.kinesis.data) + models.append(self._parse(data=data.decode("utf-8"), model=model)) + return models diff --git a/aws_lambda_powertools/utilities/parser/envelopes/sns.py b/aws_lambda_powertools/utilities/parser/envelopes/sns.py index d4fa7c2f663..50b9d406c23 100644 --- a/aws_lambda_powertools/utilities/parser/envelopes/sns.py +++ b/aws_lambda_powertools/utilities/parser/envelopes/sns.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Dict, List, Optional, Type, Union +from typing import Any, Dict, List, Optional, Type, Union, cast from ..models import SnsModel, SnsNotificationModel, SqsModel from ..types import Model @@ -69,6 +69,8 @@ def parse(self, data: Optional[Union[Dict[str, Any], Any]], model: Type[Model]) parsed_envelope = SqsModel.parse_obj(data) output = [] for record in parsed_envelope.Records: - sns_notification = SnsNotificationModel.parse_raw(record.body) + # We allow either AWS expected contract (str) or a custom Model, see #943 + body = cast(str, record.body) + sns_notification = SnsNotificationModel.parse_raw(body) output.append(self._parse(data=sns_notification.Message, model=model)) return output diff --git a/aws_lambda_powertools/utilities/parser/models/alb.py b/aws_lambda_powertools/utilities/parser/models/alb.py index 1112d0c04e4..d903e9f0fd8 100644 --- a/aws_lambda_powertools/utilities/parser/models/alb.py +++ b/aws_lambda_powertools/utilities/parser/models/alb.py @@ -1,9 +1,7 @@ -from typing import Dict, Union +from typing import Dict, Type, Union from pydantic import BaseModel -from aws_lambda_powertools.utilities.parser.types import Model - class AlbRequestContextData(BaseModel): targetGroupArn: str @@ -16,7 +14,7 @@ class AlbRequestContext(BaseModel): class AlbModel(BaseModel): httpMethod: str path: str - body: Union[str, Model] + body: Union[str, Type[BaseModel]] isBase64Encoded: bool headers: Dict[str, str] queryStringParameters: Dict[str, str] diff --git a/aws_lambda_powertools/utilities/parser/models/apigw.py b/aws_lambda_powertools/utilities/parser/models/apigw.py index ce519b8e0e3..78b40cd2c0c 100644 --- a/aws_lambda_powertools/utilities/parser/models/apigw.py +++ b/aws_lambda_powertools/utilities/parser/models/apigw.py @@ -1,10 +1,10 @@ from datetime import datetime -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Type, Union from pydantic import BaseModel, root_validator from pydantic.networks import IPvAnyNetwork -from aws_lambda_powertools.utilities.parser.types import Literal, Model +from aws_lambda_powertools.utilities.parser.types import Literal class ApiGatewayUserCertValidity(BaseModel): @@ -89,4 +89,4 @@ class APIGatewayProxyEventModel(BaseModel): pathParameters: Optional[Dict[str, str]] stageVariables: Optional[Dict[str, str]] isBase64Encoded: bool - body: Optional[Union[str, Model]] + body: Optional[Union[str, Type[BaseModel]]] diff --git a/aws_lambda_powertools/utilities/parser/models/apigwv2.py b/aws_lambda_powertools/utilities/parser/models/apigwv2.py index ddaf2d7ef82..f97dad3bcb0 100644 --- a/aws_lambda_powertools/utilities/parser/models/apigwv2.py +++ b/aws_lambda_powertools/utilities/parser/models/apigwv2.py @@ -1,10 +1,10 @@ from datetime import datetime -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Type, Union from pydantic import BaseModel, Field from pydantic.networks import IPvAnyNetwork -from aws_lambda_powertools.utilities.parser.types import Literal, Model +from aws_lambda_powertools.utilities.parser.types import Literal class RequestContextV2AuthorizerIamCognito(BaseModel): @@ -67,5 +67,5 @@ class APIGatewayProxyEventV2Model(BaseModel): pathParameters: Optional[Dict[str, str]] stageVariables: Optional[Dict[str, str]] requestContext: RequestContextV2 - body: Optional[Union[str, Model]] + body: Optional[Union[str, Type[BaseModel]]] isBase64Encoded: bool diff --git a/aws_lambda_powertools/utilities/parser/models/cloudwatch.py b/aws_lambda_powertools/utilities/parser/models/cloudwatch.py index 9b954ec3b13..71e560276a4 100644 --- a/aws_lambda_powertools/utilities/parser/models/cloudwatch.py +++ b/aws_lambda_powertools/utilities/parser/models/cloudwatch.py @@ -3,19 +3,17 @@ import logging import zlib from datetime import datetime -from typing import List, Union +from typing import List, Type, Union from pydantic import BaseModel, Field, validator -from aws_lambda_powertools.utilities.parser.types import Model - logger = logging.getLogger(__name__) class CloudWatchLogsLogEvent(BaseModel): id: str # noqa AA03 VNE003 timestamp: datetime - message: Union[str, Model] + message: Union[str, Type[BaseModel]] class CloudWatchLogsDecode(BaseModel): diff --git a/aws_lambda_powertools/utilities/parser/models/dynamodb.py b/aws_lambda_powertools/utilities/parser/models/dynamodb.py index fe7514bada0..772b8fb580f 100644 --- a/aws_lambda_powertools/utilities/parser/models/dynamodb.py +++ b/aws_lambda_powertools/utilities/parser/models/dynamodb.py @@ -1,16 +1,16 @@ from datetime import date -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Type, Union from pydantic import BaseModel -from aws_lambda_powertools.utilities.parser.types import Literal, Model +from aws_lambda_powertools.utilities.parser.types import Literal class DynamoDBStreamChangedRecordModel(BaseModel): ApproximateCreationDateTime: Optional[date] Keys: Dict[str, Dict[str, Any]] - NewImage: Optional[Union[Dict[str, Any], Model]] - OldImage: Optional[Union[Dict[str, Any], Model]] + NewImage: Optional[Union[Dict[str, Any], Type[BaseModel]]] + OldImage: Optional[Union[Dict[str, Any], Type[BaseModel]]] SequenceNumber: str SizeBytes: int StreamViewType: Literal["NEW_AND_OLD_IMAGES", "KEYS_ONLY", "NEW_IMAGE", "OLD_IMAGE"] diff --git a/aws_lambda_powertools/utilities/parser/models/event_bridge.py b/aws_lambda_powertools/utilities/parser/models/event_bridge.py index f98a263c680..68359f867bd 100644 --- a/aws_lambda_powertools/utilities/parser/models/event_bridge.py +++ b/aws_lambda_powertools/utilities/parser/models/event_bridge.py @@ -1,10 +1,8 @@ from datetime import datetime -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Type, Union from pydantic import BaseModel, Field -from aws_lambda_powertools.utilities.parser.types import Model - class EventBridgeModel(BaseModel): version: str @@ -15,5 +13,5 @@ class EventBridgeModel(BaseModel): region: str resources: List[str] detail_type: str = Field(None, alias="detail-type") - detail: Union[Dict[str, Any], Model] + detail: Union[Dict[str, Any], Type[BaseModel]] replay_name: Optional[str] = Field(None, alias="replay-name") diff --git a/aws_lambda_powertools/utilities/parser/models/kinesis.py b/aws_lambda_powertools/utilities/parser/models/kinesis.py index 1c7c31c97b4..be868ca44ba 100644 --- a/aws_lambda_powertools/utilities/parser/models/kinesis.py +++ b/aws_lambda_powertools/utilities/parser/models/kinesis.py @@ -1,11 +1,11 @@ import base64 import logging from binascii import Error as BinAsciiError -from typing import List, Union +from typing import List, Type, Union from pydantic import BaseModel, validator -from aws_lambda_powertools.utilities.parser.types import Literal, Model +from aws_lambda_powertools.utilities.parser.types import Literal logger = logging.getLogger(__name__) @@ -14,7 +14,7 @@ class KinesisDataStreamRecordPayload(BaseModel): kinesisSchemaVersion: str partitionKey: str sequenceNumber: str - data: Union[bytes, Model] # base64 encoded str is parsed into bytes + data: Union[bytes, Type[BaseModel]] # base64 encoded str is parsed into bytes approximateArrivalTimestamp: float @validator("data", pre=True, allow_reuse=True) diff --git a/aws_lambda_powertools/utilities/parser/models/s3_object_event.py b/aws_lambda_powertools/utilities/parser/models/s3_object_event.py index 778786bc8cb..ef59e9c2f98 100644 --- a/aws_lambda_powertools/utilities/parser/models/s3_object_event.py +++ b/aws_lambda_powertools/utilities/parser/models/s3_object_event.py @@ -1,9 +1,7 @@ -from typing import Dict, Optional, Union +from typing import Dict, Optional, Type, Union from pydantic import BaseModel, HttpUrl -from aws_lambda_powertools.utilities.parser.types import Model - class S3ObjectContext(BaseModel): inputS3Url: HttpUrl @@ -14,7 +12,7 @@ class S3ObjectContext(BaseModel): class S3ObjectConfiguration(BaseModel): accessPointArn: str supportingAccessPointArn: str - payload: Union[str, Model] + payload: Union[str, Type[BaseModel]] class S3ObjectUserRequest(BaseModel): diff --git a/aws_lambda_powertools/utilities/parser/models/sns.py b/aws_lambda_powertools/utilities/parser/models/sns.py index cdcd9549a98..e329162e5c8 100644 --- a/aws_lambda_powertools/utilities/parser/models/sns.py +++ b/aws_lambda_powertools/utilities/parser/models/sns.py @@ -1,10 +1,12 @@ from datetime import datetime -from typing import Dict, List, Optional, Union +from typing import Dict, List, Optional +from typing import Type as TypingType +from typing import Union from pydantic import BaseModel, root_validator from pydantic.networks import HttpUrl -from aws_lambda_powertools.utilities.parser.types import Literal, Model +from aws_lambda_powertools.utilities.parser.types import Literal class SnsMsgAttributeModel(BaseModel): @@ -18,7 +20,7 @@ class SnsNotificationModel(BaseModel): UnsubscribeUrl: HttpUrl Type: Literal["Notification"] MessageAttributes: Optional[Dict[str, SnsMsgAttributeModel]] - Message: Union[str, Model] + Message: Union[str, TypingType[BaseModel]] MessageId: str SigningCertUrl: HttpUrl Signature: str diff --git a/aws_lambda_powertools/utilities/parser/models/sqs.py b/aws_lambda_powertools/utilities/parser/models/sqs.py index 47871ab8840..1d56c4f8e34 100644 --- a/aws_lambda_powertools/utilities/parser/models/sqs.py +++ b/aws_lambda_powertools/utilities/parser/models/sqs.py @@ -1,9 +1,9 @@ from datetime import datetime -from typing import Dict, List, Optional, Union +from typing import Dict, List, Optional, Type, Union from pydantic import BaseModel -from aws_lambda_powertools.utilities.parser.types import Literal, Model +from aws_lambda_powertools.utilities.parser.types import Literal class SqsAttributesModel(BaseModel): @@ -52,7 +52,7 @@ class SqsMsgAttributeModel(BaseModel): class SqsRecordModel(BaseModel): messageId: str receiptHandle: str - body: Union[str, Model] + body: Union[str, Type[BaseModel]] attributes: SqsAttributesModel messageAttributes: Dict[str, SqsMsgAttributeModel] md5OfBody: str diff --git a/mypy.ini b/mypy.ini index faf6014a54d..3061cc4a2d9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -31,3 +31,6 @@ ignore_missing_imports = True [mypy-aws_xray_sdk.ext.aiohttp.client] ignore_missing_imports = True + +[mypy-dataclasses] +ignore_missing_imports = True \ No newline at end of file diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index 0732f1d58b1..5b76cda0475 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -648,6 +648,29 @@ def test_data_record_invalid_status_value(): assert e.value.args[0] == "UNSUPPORTED_STATUS" +def test_data_record_json_to_dict_mapping(): + # GIVEN a data record with status "INPROGRESS" and provided response data + data_record = DataRecord( + "key", status="INPROGRESS", response_data='{"body": "execution finished","statusCode": "200"}' + ) + + # WHEN translating response data to dictionary + response_data = data_record.response_json_as_dict() + + # THEN return dictionary + assert isinstance(response_data, dict) + + +def test_data_record_json_to_dict_mapping_when_response_data_none(): + # GIVEN a data record with status "INPROGRESS" and not set response data + data_record = DataRecord("key", status="INPROGRESS", response_data=None) + # WHEN translating response data to dictionary + response_data = data_record.response_json_as_dict() + + # THEN return null value + assert response_data is None + + @pytest.mark.parametrize("idempotency_config", [{"use_local_cache": True}], indirect=True) def test_in_progress_never_saved_to_cache( idempotency_config: IdempotencyConfig, persistence_store: DynamoDBPersistenceLayer diff --git a/tests/functional/test_metrics.py b/tests/functional/test_metrics.py index ae160c65d87..9d03a25e8b6 100644 --- a/tests/functional/test_metrics.py +++ b/tests/functional/test_metrics.py @@ -493,9 +493,29 @@ def lambda_handler(evt, context): output = capture_metrics_output(capsys) + # THEN ColdStart metric and function_name and service dimension should be logged + assert output["ColdStart"] == [1.0] + assert output["function_name"] == "example_fn" + assert output['service'] == service + +def test_log_metrics_capture_cold_start_metric_no_service(capsys, namespace): + # GIVEN Metrics is initialized without service + my_metrics = Metrics(namespace=namespace) + + # WHEN log_metrics is used with capture_cold_start_metric + @my_metrics.log_metrics(capture_cold_start_metric=True) + def lambda_handler(evt, context): + pass + + LambdaContext = namedtuple("LambdaContext", "function_name") + lambda_handler({}, LambdaContext("example_fn")) + + output = capture_metrics_output(capsys) + # THEN ColdStart metric and function_name dimension should be logged assert output["ColdStart"] == [1.0] assert output["function_name"] == "example_fn" + assert output.get('service') is None def test_emit_cold_start_metric_only_once(capsys, namespace, service, metric): From ffa4a35d1354b9af988d5607ce3a07d577e47a8d Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Mon, 31 Jan 2022 06:20:21 -0800 Subject: [PATCH 06/22] feat(logger): log_event support event data classes (e.g. S3Event) (#984) --- aws_lambda_powertools/logging/logger.py | 2 +- tests/functional/test_logger.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/aws_lambda_powertools/logging/logger.py b/aws_lambda_powertools/logging/logger.py index 938742fb0a3..49321181b48 100644 --- a/aws_lambda_powertools/logging/logger.py +++ b/aws_lambda_powertools/logging/logger.py @@ -349,7 +349,7 @@ def decorate(event, context, **kwargs): if log_event: logger.debug("Event received") - self.info(event) + self.info(getattr(event, "raw_event", event)) return lambda_handler(event, context) diff --git a/tests/functional/test_logger.py b/tests/functional/test_logger.py index 6b05119b88b..20b0a74fc64 100644 --- a/tests/functional/test_logger.py +++ b/tests/functional/test_logger.py @@ -17,6 +17,7 @@ from aws_lambda_powertools.logging.formatter import BasePowertoolsFormatter from aws_lambda_powertools.logging.logger import set_package_logger from aws_lambda_powertools.shared import constants +from aws_lambda_powertools.utilities.data_classes import S3Event, event_source @pytest.fixture @@ -635,3 +636,21 @@ def test_use_datetime(stdout, service_name, utc): assert re.fullmatch( f"custom timestamp: milliseconds=[0-9]+ microseconds=[0-9]+ timezone={re.escape(expected_tz)}", log["timestamp"] ) + + +def test_inject_lambda_context_log_event_request_data_classes(lambda_context, stdout, lambda_event, service_name): + # GIVEN Logger is initialized + logger = Logger(service=service_name, stream=stdout) + + # WHEN a lambda function is decorated with logger instructed to log event + # AND the event is an event source data class + @event_source(data_class=S3Event) + @logger.inject_lambda_context(log_event=True) + def handler(event, context): + logger.info("Hello") + + handler(lambda_event, lambda_context) + + # THEN logger should log event received from Lambda + logged_event, _ = capture_multiple_logging_statements_output(stdout) + assert logged_event["message"] == lambda_event From 0c35e5a89bd3c5d635d754faf25bd4c0f4ef3485 Mon Sep 17 00:00:00 2001 From: Heitor Lessa Date: Mon, 31 Jan 2022 17:18:32 +0100 Subject: [PATCH 07/22] fix(apigateway): remove indentation in debug_mode (#987) --- .../event_handler/api_gateway.py | 6 +----- docs/core/event_handler/api_gateway.md | 2 ++ .../functional/event_handler/test_api_gateway.py | 15 +++++++++------ 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index f423f1291fa..ddf1276f298 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -446,10 +446,6 @@ def __init__( # Allow for a custom serializer or a concise json serialization self._serializer = serializer or partial(json.dumps, separators=(",", ":"), cls=Encoder) - if self._debug: - # Always does a pretty print when in debug mode - self._serializer = partial(json.dumps, indent=4, cls=Encoder) - def route( self, rule: str, @@ -496,7 +492,7 @@ def resolve(self, event, context) -> Dict[str, Any]: Returns the dict response """ if self._debug: - print(self._json_dump(event)) + print(self._json_dump(event), end="") BaseRouter.current_event = self._to_proxy_event(event) BaseRouter.lambda_context = context return self._resolve().build(self.current_event, self._cors) diff --git a/docs/core/event_handler/api_gateway.md b/docs/core/event_handler/api_gateway.md index ae0960875c2..f5e83068db7 100644 --- a/docs/core/event_handler/api_gateway.md +++ b/docs/core/event_handler/api_gateway.md @@ -940,6 +940,8 @@ This will enable full tracebacks errors in the response, print request and respo ???+ danger This might reveal sensitive information in your logs and relax CORS restrictions, use it sparingly. + It's best to use for local development only! + ```python hl_lines="3" title="Enabling debug mode" from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver diff --git a/tests/functional/event_handler/test_api_gateway.py b/tests/functional/event_handler/test_api_gateway.py index 76ecbc7cdd7..09d32b7ffc1 100644 --- a/tests/functional/event_handler/test_api_gateway.py +++ b/tests/functional/event_handler/test_api_gateway.py @@ -32,6 +32,12 @@ from tests.functional.utils import load_event +@pytest.fixture +def json_dump(): + # our serializers reduce length to save on costs; fixture to replicate separators + return lambda obj: json.dumps(obj, separators=(",", ":")) + + def read_media(file_name: str) -> bytes: path = Path(str(Path(__file__).parent.parent.parent.parent) + "/docs/media/" + file_name) return path.read_bytes() @@ -506,13 +512,10 @@ def custom_method(): assert headers["Access-Control-Allow-Methods"] == "CUSTOM" -def test_service_error_responses(): +def test_service_error_responses(json_dump): # SCENARIO handling different kind of service errors being raised app = ApiGatewayResolver(cors=CORSConfig()) - def json_dump(obj): - return json.dumps(obj, separators=(",", ":")) - # GIVEN an BadRequestError @app.get(rule="/bad-request-error", cors=False) def bad_request_error(): @@ -641,7 +644,7 @@ def test_debug_mode_environment_variable(monkeypatch): assert app._debug -def test_debug_json_formatting(): +def test_debug_json_formatting(json_dump): # GIVEN debug is True app = ApiGatewayResolver(debug=True) response = {"message": "Foo"} @@ -654,7 +657,7 @@ def foo(): result = app({"path": "/foo", "httpMethod": "GET"}, None) # THEN return a pretty print json in the body - assert result["body"] == json.dumps(response, indent=4) + assert result["body"] == json_dump(response) def test_debug_print_event(capsys): From 7a6e49a1225941c389a0378db75bb9f60c129b7d Mon Sep 17 00:00:00 2001 From: "@m1ru1" Date: Tue, 1 Feb 2022 04:28:50 +0800 Subject: [PATCH 08/22] Extra indentation for py and sam yaml (#988) --- docs/tutorial/index.md | 106 ++++++++++++++++++++--------------------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md index c6b99d1938b..a348f122a24 100644 --- a/docs/tutorial/index.md +++ b/docs/tutorial/index.md @@ -227,75 +227,75 @@ A first attempt at the routing logic might look similar to the following code sn === "app.py" ```python hl_lines="4 9 13 27-29 35-36" - import json + import json - def hello_name(event, **kargs): - username = event["pathParameters"]["name"] - return {"statusCode": 200, "body": json.dumps({"message": f"hello {username}!"})} + def hello_name(event, **kargs): + username = event["pathParameters"]["name"] + return {"statusCode": 200, "body": json.dumps({"message": f"hello {username}!"})} - def hello(**kargs): - return {"statusCode": 200, "body": json.dumps({"message": "hello unknown!"})} + def hello(**kargs): + return {"statusCode": 200, "body": json.dumps({"message": "hello unknown!"})} - class Router: - def __init__(self): - self.routes = {} + class Router: + def __init__(self): + self.routes = {} - def set(self, path, method, handler): - self.routes[f"{path}-{method}"] = handler + def set(self, path, method, handler): + self.routes[f"{path}-{method}"] = handler - def get(self, path, method): - try: - route = self.routes[f"{path}-{method}"] - except KeyError: - raise RuntimeError(f"Cannot route request to the correct method. path={path}, method={method}") - return route + def get(self, path, method): + try: + route = self.routes[f"{path}-{method}"] + except KeyError: + raise RuntimeError(f"Cannot route request to the correct method. path={path}, method={method}") + return route - router = Router() - router.set(path="/hello", method="GET", handler=hello) - router.set(path="/hello/{name}", method="GET", handler=hello_name) + router = Router() + router.set(path="/hello", method="GET", handler=hello) + router.set(path="/hello/{name}", method="GET", handler=hello_name) - def lambda_handler(event, context): - path = event["resource"] - http_method = event["httpMethod"] - method = router.get(path=path, method=http_method) - return method(event=event) + def lambda_handler(event, context): + path = event["resource"] + http_method = event["httpMethod"] + method = router.get(path=path, method=http_method) + return method(event=event) ``` === "template.yaml" ```yaml hl_lines="15-24" - AWSTemplateFormatVersion: "2010-09-09" - Transform: AWS::Serverless-2016-10-31 - Description: Sample SAM Template for powertools-quickstart - Globals: - Function: - Timeout: 3 - Resources: - HelloWorldFunction: - Type: AWS::Serverless::Function - Properties: - CodeUri: hello_world/ - Handler: app.lambda_handler - Runtime: python3.9 - Events: - HelloWorld: - Type: Api - Properties: - Path: /hello - Method: get - HelloWorldName: - Type: Api - Properties: - Path: /hello/{name} - Method: get - Outputs: - HelloWorldApi: - Description: "API Gateway endpoint URL for Prod stage for Hello World function" - Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/" + AWSTemplateFormatVersion: "2010-09-09" + Transform: AWS::Serverless-2016-10-31 + Description: Sample SAM Template for powertools-quickstart + Globals: + Function: + Timeout: 3 + Resources: + HelloWorldFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: hello_world/ + Handler: app.lambda_handler + Runtime: python3.9 + Events: + HelloWorld: + Type: Api + Properties: + Path: /hello + Method: get + HelloWorldName: + Type: Api + Properties: + Path: /hello/{name} + Method: get + Outputs: + HelloWorldApi: + Description: "API Gateway endpoint URL for Prod stage for Hello World function" + Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/" ``` Let's break this down: From 5bddf705fc929d9964452aef12f2b64466df500b Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Tue, 1 Feb 2022 20:40:12 +0000 Subject: [PATCH 09/22] docs: add better BDD coments --- tests/functional/test_logger_utils.py | 37 +++++++++++++++------------ 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/tests/functional/test_logger_utils.py b/tests/functional/test_logger_utils.py index 8b802586389..bf6d7d4ed5b 100644 --- a/tests/functional/test_logger_utils.py +++ b/tests/functional/test_logger_utils.py @@ -50,20 +50,21 @@ def service_name(): def test_copy_config_to_ext_loggers(stdout, logger, log_level): - # GIVEN an external logger and powertools logger initialized + # GIVEN two external loggers and powertools logger initialized logger_1 = logger() logger_2 = logger() powertools_logger = Logger(service=service_name(), level=log_level.INFO.value, stream=stdout) - # WHEN configuration copied from powertools logger to ALL external loggers AND our external logger used + # WHEN configuration copied from powertools logger to ALL external loggers + # AND external loggers used utils.copy_config_to_registered_loggers(source_logger=powertools_logger) msg = "test message1" logger_1.info(msg) logger_2.info(msg) logs = capture_multiple_logging_statements_output(stdout) - # THEN + # THEN all external loggers used Powertools handler, formatter and log level for index, logger in enumerate([logger_1, logger_2]): assert len(logger.handlers) == 1 assert type(logger.handlers[0]) is logging.StreamHandler @@ -78,13 +79,14 @@ def test_copy_config_to_ext_loggers_include(stdout, logger, log_level): logger = logger() powertools_logger = Logger(service=service_name(), level=log_level.INFO.value, stream=stdout) - # WHEN configuration copied from powertools logger to ALL external loggers AND our external logger used + # WHEN configuration copied from powertools logger to INCLUDED external loggers + # AND our external logger used utils.copy_config_to_registered_loggers(source_logger=powertools_logger, include={logger.name}) msg = "test message2" logger.info(msg) log = capture_logging_output(stdout) - # THEN + # THEN included external loggers used Powertools handler, formatter and log level. assert len(logger.handlers) == 1 assert type(logger.handlers[0]) is logging.StreamHandler assert type(logger.handlers[0].formatter) is formatter.LambdaPowertoolsFormatter @@ -98,10 +100,10 @@ def test_copy_config_to_ext_loggers_wrong_include(stdout, logger, log_level): logger = logger() powertools_logger = Logger(service=service_name(), level=log_level.INFO.value, stream=stdout) - # WHEN configuration copied from powertools logger to ALL external loggers AND our external logger used + # THEN included external loggers used Powertools handler, formatter and log level. utils.copy_config_to_registered_loggers(source_logger=powertools_logger, include={"non-existing-logger"}) - # THEN + # THEN existing external logger is not modified assert not logger.handlers @@ -110,21 +112,22 @@ def test_copy_config_to_ext_loggers_exclude(stdout, logger, log_level): logger = logger() powertools_logger = Logger(service=service_name(), level=log_level.INFO.value, stream=stdout) - # WHEN configuration copied from powertools logger to ALL external loggers AND our external logger used + # WHEN configuration copied from powertools logger to ALL BUT external logger utils.copy_config_to_registered_loggers(source_logger=powertools_logger, exclude={logger.name}) - # THEN + # THEN external logger is not modified assert not logger.handlers def test_copy_config_to_ext_loggers_include_exclude(stdout, logger, log_level): - # GIVEN an external logger and powertools logger initialized + # GIVEN two external loggers and powertools logger initialized logger_1 = logger() logger_2 = logger() powertools_logger = Logger(service=service_name(), level=log_level.INFO.value, stream=stdout) - # WHEN configuration copied from powertools logger to ALL external loggers AND our external logger used + # WHEN configuration copied from powertools logger to INCLUDED external loggers + # AND external logger_1 is also in EXCLUDE list utils.copy_config_to_registered_loggers( source_logger=powertools_logger, include={logger_1.name, logger_2.name}, exclude={logger_1.name} ) @@ -132,7 +135,7 @@ def test_copy_config_to_ext_loggers_include_exclude(stdout, logger, log_level): logger_2.info(msg) log = capture_logging_output(stdout) - # THEN + # THEN logger_1 is not modified and Logger_2 used Powertools handler, formatter and log level assert not logger_1.handlers assert len(logger_2.handlers) == 1 assert type(logger_2.handlers[0]) is logging.StreamHandler @@ -149,10 +152,10 @@ def test_copy_config_to_ext_loggers_clean_old_handlers(stdout, logger, log_level logger.addHandler(handler) powertools_logger = Logger(service=service_name(), level=log_level.INFO.value, stream=stdout) - # WHEN configuration copied from powertools logger to ALL external loggers AND our external logger used + # WHEN configuration copied from powertools logger to ALL external loggers utils.copy_config_to_registered_loggers(source_logger=powertools_logger) - # THEN + # THEN old logger's handler removed and Powertools configuration used instead assert len(logger.handlers) == 1 assert type(logger.handlers[0]) is logging.StreamHandler assert type(logger.handlers[0].formatter) is formatter.LambdaPowertoolsFormatter @@ -164,14 +167,14 @@ def test_copy_config_to_ext_loggers_custom_log_level(stdout, logger, log_level): powertools_logger = Logger(service=service_name(), level=log_level.CRITICAL.value, stream=stdout) level = log_level.WARNING.name - # WHEN configuration copied from powertools logger to ALL external loggers - # AND our external logger used with custom log_level + # WHEN configuration copied from powertools logger to INCLUDED external logger + # AND external logger used with custom log_level utils.copy_config_to_registered_loggers(source_logger=powertools_logger, include={logger.name}, log_level=level) msg = "test message4" logger.warning(msg) log = capture_logging_output(stdout) - # THEN + # THEN external logger used Powertools handler, formatter and CUSTOM log level. assert len(logger.handlers) == 1 assert type(logger.handlers[0]) is logging.StreamHandler assert type(logger.handlers[0].formatter) is formatter.LambdaPowertoolsFormatter From 26c50df9ece3c1c1b90b226d0e9e7d29ccb0761b Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Tue, 1 Feb 2022 20:48:45 +0000 Subject: [PATCH 10/22] chore: use isinstance over type --- tests/functional/test_logger_utils.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/functional/test_logger_utils.py b/tests/functional/test_logger_utils.py index bf6d7d4ed5b..abf816b5f18 100644 --- a/tests/functional/test_logger_utils.py +++ b/tests/functional/test_logger_utils.py @@ -67,8 +67,8 @@ def test_copy_config_to_ext_loggers(stdout, logger, log_level): # THEN all external loggers used Powertools handler, formatter and log level for index, logger in enumerate([logger_1, logger_2]): assert len(logger.handlers) == 1 - assert type(logger.handlers[0]) is logging.StreamHandler - assert type(logger.handlers[0].formatter) is formatter.LambdaPowertoolsFormatter + assert isinstance(logger.handlers[0], logging.StreamHandler) + assert isinstance(logger.handlers[0].formatter, formatter.LambdaPowertoolsFormatter) assert logger.level == log_level.INFO.value assert logs[index]["message"] == msg assert logs[index]["level"] == log_level.INFO.name @@ -88,8 +88,8 @@ def test_copy_config_to_ext_loggers_include(stdout, logger, log_level): # THEN included external loggers used Powertools handler, formatter and log level. assert len(logger.handlers) == 1 - assert type(logger.handlers[0]) is logging.StreamHandler - assert type(logger.handlers[0].formatter) is formatter.LambdaPowertoolsFormatter + assert isinstance(logger.handlers[0], logging.StreamHandler) + assert isinstance(logger.handlers[0].formatter, formatter.LambdaPowertoolsFormatter) assert logger.level == log_level.INFO.value assert log["message"] == msg assert log["level"] == log_level.INFO.name @@ -138,8 +138,8 @@ def test_copy_config_to_ext_loggers_include_exclude(stdout, logger, log_level): # THEN logger_1 is not modified and Logger_2 used Powertools handler, formatter and log level assert not logger_1.handlers assert len(logger_2.handlers) == 1 - assert type(logger_2.handlers[0]) is logging.StreamHandler - assert type(logger_2.handlers[0].formatter) is formatter.LambdaPowertoolsFormatter + assert isinstance(logger_2.handlers[0], logging.StreamHandler) + assert isinstance(logger_2.handlers[0].formatter, formatter.LambdaPowertoolsFormatter) assert logger_2.level == log_level.INFO.value assert log["message"] == msg assert log["level"] == log_level.INFO.name @@ -157,8 +157,8 @@ def test_copy_config_to_ext_loggers_clean_old_handlers(stdout, logger, log_level # THEN old logger's handler removed and Powertools configuration used instead assert len(logger.handlers) == 1 - assert type(logger.handlers[0]) is logging.StreamHandler - assert type(logger.handlers[0].formatter) is formatter.LambdaPowertoolsFormatter + assert isinstance(logger.handlers[0], logging.StreamHandler) + assert isinstance(logger.handlers[0].formatter, formatter.LambdaPowertoolsFormatter) def test_copy_config_to_ext_loggers_custom_log_level(stdout, logger, log_level): @@ -176,8 +176,8 @@ def test_copy_config_to_ext_loggers_custom_log_level(stdout, logger, log_level): # THEN external logger used Powertools handler, formatter and CUSTOM log level. assert len(logger.handlers) == 1 - assert type(logger.handlers[0]) is logging.StreamHandler - assert type(logger.handlers[0].formatter) is formatter.LambdaPowertoolsFormatter + assert isinstance(logger.handlers[0], logging.StreamHandler) + assert isinstance(logger.handlers[0].formatter, formatter.LambdaPowertoolsFormatter) assert powertools_logger.level == log_level.CRITICAL.value assert logger.level == log_level.WARNING.value assert log["message"] == msg From f4473d3761b07d8836b29a067bc345cfdfa0e57b Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Wed, 2 Feb 2022 02:35:21 +0000 Subject: [PATCH 11/22] chore: correct docs --- tests/functional/test_logger_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional/test_logger_utils.py b/tests/functional/test_logger_utils.py index abf816b5f18..cd6abd11d15 100644 --- a/tests/functional/test_logger_utils.py +++ b/tests/functional/test_logger_utils.py @@ -100,7 +100,7 @@ def test_copy_config_to_ext_loggers_wrong_include(stdout, logger, log_level): logger = logger() powertools_logger = Logger(service=service_name(), level=log_level.INFO.value, stream=stdout) - # THEN included external loggers used Powertools handler, formatter and log level. + # WHEN configuration copied from powertools logger to INCLUDED NON EXISTING utils.copy_config_to_registered_loggers(source_logger=powertools_logger, include={"non-existing-logger"}) # THEN existing external logger is not modified From 5ea99b33d5728def37b4424184b359efec419cf5 Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Wed, 2 Feb 2022 02:43:04 +0000 Subject: [PATCH 12/22] chore: correct docs --- tests/functional/test_logger_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional/test_logger_utils.py b/tests/functional/test_logger_utils.py index cd6abd11d15..85f60db97f8 100644 --- a/tests/functional/test_logger_utils.py +++ b/tests/functional/test_logger_utils.py @@ -100,7 +100,7 @@ def test_copy_config_to_ext_loggers_wrong_include(stdout, logger, log_level): logger = logger() powertools_logger = Logger(service=service_name(), level=log_level.INFO.value, stream=stdout) - # WHEN configuration copied from powertools logger to INCLUDED NON EXISTING + # WHEN configuration copied from powertools logger to INCLUDED NON EXISTING external loggers utils.copy_config_to_registered_loggers(source_logger=powertools_logger, include={"non-existing-logger"}) # THEN existing external logger is not modified From 168ec482502fb4a1a22038e3a2041459926e6591 Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Thu, 3 Feb 2022 02:57:11 -0800 Subject: [PATCH 13/22] feat(event-handler): new resolvers to fix current_event typing (#978) Co-authored-by: Heitor Lessa Co-authored-by: heitorlessa --- .../event_handler/api_gateway.py | 74 +- docs/core/event_handler/api_gateway.md | 195 ++-- docs/tutorial/idempotency.md | 1009 ----------------- docs/tutorial/index.md | 34 +- docs/utilities/feature_flags.md | 4 +- .../event_handler/test_api_gateway.py | 14 +- tests/functional/test_metrics.py | 5 +- 7 files changed, 201 insertions(+), 1134 deletions(-) delete mode 100644 docs/tutorial/idempotency.md diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index ddf1276f298..8e4ea866dd1 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -47,9 +47,9 @@ class CORSConfig: Simple cors example using the default permissive cors, not this should only be used during early prototyping ```python - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver - app = ApiGatewayResolver() + app = APIGatewayRestResolver() @app.get("/my/path", cors=True) def with_cors(): @@ -61,7 +61,7 @@ def with_cors(): ```python from aws_lambda_powertools.event_handler.api_gateway import ( - ApiGatewayResolver, CORSConfig + APIGatewayRestResolver, CORSConfig ) cors_config = CORSConfig( @@ -71,7 +71,7 @@ def with_cors(): max_age=100, allow_credentials=True, ) - app = ApiGatewayResolver(cors=cors_config) + app = APIGatewayRestResolver(cors=cors_config) @app.get("/my/path") def with_cors(): @@ -252,10 +252,10 @@ def get(self, rule: str, cors: Optional[bool] = None, compress: bool = False, ca ```python from aws_lambda_powertools import Tracer - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver tracer = Tracer() - app = ApiGatewayResolver() + app = APIGatewayRestResolver() @app.get("/get-call") def simple_get(): @@ -277,10 +277,10 @@ def post(self, rule: str, cors: Optional[bool] = None, compress: bool = False, c ```python from aws_lambda_powertools import Tracer - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver tracer = Tracer() - app = ApiGatewayResolver() + app = APIGatewayRestResolver() @app.post("/post-call") def simple_post(): @@ -303,10 +303,10 @@ def put(self, rule: str, cors: Optional[bool] = None, compress: bool = False, ca ```python from aws_lambda_powertools import Tracer - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver tracer = Tracer() - app = ApiGatewayResolver() + app = APIGatewayRestResolver() @app.put("/put-call") def simple_put(): @@ -331,10 +331,10 @@ def delete( ```python from aws_lambda_powertools import Tracer - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver tracer = Tracer() - app = ApiGatewayResolver() + app = APIGatewayRestResolver() @app.delete("/delete-call") def simple_delete(): @@ -358,10 +358,10 @@ def patch( ```python from aws_lambda_powertools import Tracer - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver tracer = Tracer() - app = ApiGatewayResolver() + app = APIGatewayRestResolver() @app.patch("/patch-call") def simple_patch(): @@ -387,10 +387,10 @@ class ApiGatewayResolver(BaseRouter): ```python from aws_lambda_powertools import Tracer - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver tracer = Tracer() - app = ApiGatewayResolver() + app = APIGatewayRestResolver() @app.get("/get-call") def simple_get(): @@ -731,3 +731,45 @@ def register_route(func: Callable): self._routes[(rule, methods, cors, compress, cache_control)] = func return register_route + + +class APIGatewayRestResolver(ApiGatewayResolver): + current_event: APIGatewayProxyEvent + + def __init__( + self, + cors: Optional[CORSConfig] = None, + debug: Optional[bool] = None, + serializer: Optional[Callable[[Dict], str]] = None, + strip_prefixes: Optional[List[str]] = None, + ): + """Amazon API Gateway REST and HTTP API v1 payload resolver""" + super().__init__(ProxyEventType.APIGatewayProxyEvent, cors, debug, serializer, strip_prefixes) + + +class APIGatewayHttpResolver(ApiGatewayResolver): + current_event: APIGatewayProxyEventV2 + + def __init__( + self, + cors: Optional[CORSConfig] = None, + debug: Optional[bool] = None, + serializer: Optional[Callable[[Dict], str]] = None, + strip_prefixes: Optional[List[str]] = None, + ): + """Amazon API Gateway HTTP API v2 payload resolver""" + super().__init__(ProxyEventType.APIGatewayProxyEventV2, cors, debug, serializer, strip_prefixes) + + +class ALBResolver(ApiGatewayResolver): + current_event: ALBEvent + + def __init__( + self, + cors: Optional[CORSConfig] = None, + debug: Optional[bool] = None, + serializer: Optional[Callable[[Dict], str]] = None, + strip_prefixes: Optional[List[str]] = None, + ): + """Amazon Application Load Balancer (ALB) resolver""" + super().__init__(ProxyEventType.ALBEvent, cors, debug, serializer, strip_prefixes) diff --git a/docs/core/event_handler/api_gateway.md b/docs/core/event_handler/api_gateway.md index f5e83068db7..dccd9154afe 100644 --- a/docs/core/event_handler/api_gateway.md +++ b/docs/core/event_handler/api_gateway.md @@ -3,7 +3,7 @@ title: REST API description: Core utility --- -Event handler for Amazon API Gateway REST/HTTP APIs and Application Loader Balancer (ALB). +Event handler for Amazon API Gateway REST and HTTP APIs, and Application Loader Balancer (ALB). ### Key Features @@ -12,7 +12,7 @@ Event handler for Amazon API Gateway REST/HTTP APIs and Application Loader Balan * Integrates with [Data classes utilities](../../utilities/data_classes.md){target="_blank"} to easily access event and identity information * Built-in support for Decimals JSON encoding * Support for dynamic path expressions -* Router to allow for splitting up the handler accross multiple files +* Router to allow for splitting up the handler across multiple files ## Getting started @@ -63,25 +63,36 @@ Resources: Method: ANY # Send requests using any http method to the lambda function ``` -### API Gateway decorator +### Event Resolvers -You can define your functions to match a path and HTTP method, when you use the decorator `ApiGatewayResolver`. +Before you decorate your functions to handle a given path and HTTP method(s), you need to initialize a resolver. -Here's an example where we have two separate functions to resolve two paths: `/hello`. +A resolver will handle request resolution, include [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`, and `ALBResolver`. + +???+ info + We will use `APIGatewayRestResolver` as the default across examples. + +#### API Gateway REST API + +When using Amazon API Gateway REST API to front your Lambda functions, you can use `APIGatewayRestResolver`. + +Here's an example on how we can handle the `/hello` path. ???+ info - We automatically serialize `Dict` responses as JSON, trim whitespaces for compact responses, and set content-type to `application/json`. + We automatically serialize `Dict` responses as JSON, trim whitespace for compact responses, and set content-type to `application/json`. === "app.py" ```python hl_lines="3 7 9 12 18" from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.logging import correlation_paths - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver tracer = Tracer() logger = Logger() - app = ApiGatewayResolver() # by default API Gateway REST API (v1) + app = APIGatewayRestResolver() @app.get("/hello") @tracer.capture_method @@ -106,32 +117,32 @@ Here's an example where we have two separate functions to resolve two paths: `/h "httpMethod": "GET", "isBase64Encoded": false, "queryStringParameters": { - "foo": "bar" + "foo": "bar" }, "multiValueQueryStringParameters": {}, "pathParameters": { - "hello": "/hello" + "hello": "/hello" }, "stageVariables": {}, "headers": { - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", - "Accept-Encoding": "gzip, deflate, sdch", - "Accept-Language": "en-US,en;q=0.8", - "Cache-Control": "max-age=0", - "CloudFront-Forwarded-Proto": "https", - "CloudFront-Is-Desktop-Viewer": "true", - "CloudFront-Is-Mobile-Viewer": "false", - "CloudFront-Is-SmartTV-Viewer": "false", - "CloudFront-Is-Tablet-Viewer": "false", - "CloudFront-Viewer-Country": "US", - "Host": "1234567890.execute-api.us-east-1.amazonaws.com", - "Upgrade-Insecure-Requests": "1", - "User-Agent": "Custom User Agent String", - "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)", - "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==", - "X-Forwarded-For": "127.0.0.1, 127.0.0.2", - "X-Forwarded-Port": "443", - "X-Forwarded-Proto": "https" + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Encoding": "gzip, deflate, sdch", + "Accept-Language": "en-US,en;q=0.8", + "Cache-Control": "max-age=0", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-Country": "US", + "Host": "1234567890.execute-api.us-east-1.amazonaws.com", + "Upgrade-Insecure-Requests": "1", + "User-Agent": "Custom User Agent String", + "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)", + "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==", + "X-Forwarded-For": "127.0.0.1, 127.0.0.2", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https" }, "multiValueHeaders": {}, "requestContext": { @@ -176,18 +187,23 @@ Here's an example where we have two separate functions to resolve two paths: `/h } ``` -#### HTTP API +#### API Gateway HTTP API + +When using Amazon API Gateway HTTP API to front your Lambda functions, you can use `APIGatewayHttpResolver`. + +???+ note + Using HTTP API v1 payload? Use `APIGatewayRestResolver` instead. `APIGatewayHttpResolver` defaults to v2 payload. -When using API Gateway HTTP API to front your Lambda functions, you can instruct `ApiGatewayResolver` to conform with their contract via `proxy_type` param: +Here's an example on how we can handle the `/hello` path. ```python hl_lines="3 7" title="Using HTTP API resolver" from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.logging import correlation_paths -from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver, ProxyEventType +from aws_lambda_powertools.event_handler import APIGatewayHttpResolver tracer = Tracer() logger = Logger() -app = ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEventV2) +app = APIGatewayHttpResolver() @app.get("/hello") @tracer.capture_method @@ -201,18 +217,18 @@ def lambda_handler(event, context): return app.resolve(event, context) ``` -#### ALB +#### Application Load Balancer -When using ALB to front your Lambda functions, you can instruct `ApiGatewayResolver` to conform with their contract via `proxy_type` param: +When using Amazon Application Load Balancer to front your Lambda functions, you can use `ALBResolver`. ```python hl_lines="3 7" title="Using ALB resolver" from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.logging import correlation_paths -from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver, ProxyEventType +from aws_lambda_powertools.event_handler import ALBResolver tracer = Tracer() logger = Logger() -app = ApiGatewayResolver(proxy_type=ProxyEventType.ALBEvent) +app = ALBResolver() @app.get("/hello") @tracer.capture_method @@ -235,11 +251,11 @@ You can use `/path/{dynamic_value}` when configuring dynamic URL paths. This all ```python hl_lines="9 11" from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.logging import correlation_paths - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver tracer = Tracer() logger = Logger() - app = ApiGatewayResolver() + app = APIGatewayRestResolver() @app.get("/hello/") @tracer.capture_method @@ -273,11 +289,11 @@ You can also nest paths as configured earlier in [our sample infrastructure](#re ```python hl_lines="9 11" from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.logging import correlation_paths - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver tracer = Tracer() logger = Logger() - app = ApiGatewayResolver() + app = APIGatewayRestResolver() @app.get("//") @tracer.capture_method @@ -317,9 +333,9 @@ You can also combine nested paths with greedy regex to catch in between routes. === "app.py" ```python hl_lines="5" - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver - app = ApiGatewayResolver() + app = APIGatewayRestResolver() @app.get(".+") def catch_any_route_after_any(): @@ -348,11 +364,11 @@ You can use named decorators to specify the HTTP method that should be handled i ```python hl_lines="9-10" from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.logging import correlation_paths - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver tracer = Tracer() logger = Logger() - app = ApiGatewayResolver() + app = APIGatewayRestResolver() # Only POST HTTP requests to the path /hello will route to this function @app.post("/hello") @@ -387,11 +403,11 @@ HTTP methods. ```python hl_lines="9-10" from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.logging import correlation_paths - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver tracer = Tracer() logger = Logger() - app = ApiGatewayResolver() + app = APIGatewayRestResolver() # PUT and POST HTTP requests to the path /hello will route to this function @app.route("/hello", method=["PUT", "POST"]) @@ -425,7 +441,7 @@ HTTP methods. By integrating with [Data classes utilities](../../utilities/data_classes.md){target="_blank"}, you have access to request details, Lambda context and also some convenient methods. -These are made available in the response returned when instantiating `ApiGatewayResolver`, for example `app.current_event` and `app.lambda_context`. +These are made available in the response returned when instantiating `APIGatewayRestResolver`, for example `app.current_event` and `app.lambda_context`. #### Query strings and payload @@ -434,9 +450,9 @@ Within `app.current_event` property, you can access query strings as dictionary You can access the raw payload via `body` property, or if it's a JSON string you can quickly deserialize it via `json_body` property. ```python hl_lines="7-9 11" title="Accessing query strings, JSON payload, and raw payload" -from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver +from aws_lambda_powertools.event_handler import APIGatewayRestResolver -app = ApiGatewayResolver() +app = APIGatewayRestResolver() @app.get("/hello") def get_hello_you(): @@ -456,9 +472,9 @@ def lambda_handler(event, context): Similarly to [Query strings](#query-strings-and-payload), you can access headers as dictionary via `app.current_event.headers`, or by name via `get_header_value`. ```python hl_lines="7-8" title="Accessing HTTP Headers" -from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver +from aws_lambda_powertools.event_handler import APIGatewayRestResolver -app = ApiGatewayResolver() +app = APIGatewayRestResolver() @app.get("/hello") def get_hello_you(): @@ -482,12 +498,12 @@ You can use **`not_found`** decorator to override this behaviour, and return a c from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.logging import correlation_paths from aws_lambda_powertools.event_handler import content_types -from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver, Response +from aws_lambda_powertools.event_handler.api_gateway import APIGatewayRestResolver, Response from aws_lambda_powertools.event_handler.exceptions import NotFoundError tracer = Tracer() logger = Logger() -app = ApiGatewayResolver() +app = APIGatewayRestResolver() @app.not_found @tracer.capture_method @@ -521,11 +537,11 @@ You can use **`exception_handler`** decorator with any Python exception. This al from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.logging import correlation_paths from aws_lambda_powertools.event_handler import content_types -from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver, Response +from aws_lambda_powertools.event_handler import APIGatewayRestResolver, Response tracer = Tracer() logger = Logger() -app = ApiGatewayResolver() +app = APIGatewayRestResolver() @app.exception_handler(ValueError) def handle_value_error(ex: ValueError): @@ -553,7 +569,6 @@ def lambda_handler(event, context): return app.resolve(event, context) ``` - ### Raising HTTP errors You can easily raise any HTTP Error back to the client using `ServiceError` exception. @@ -566,7 +581,7 @@ Additionally, we provide pre-defined errors for the most popular ones such as HT ```python hl_lines="4-10 20 25 30 35 39" title="Raising common HTTP Status errors (4xx, 5xx)" from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.logging import correlation_paths -from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver +from aws_lambda_powertools.event_handler import APIGatewayRestResolver from aws_lambda_powertools.event_handler.exceptions import ( BadRequestError, InternalServerError, @@ -578,7 +593,7 @@ from aws_lambda_powertools.event_handler.exceptions import ( tracer = Tracer() logger = Logger() -app = ApiGatewayResolver() +app = APIGatewayRestResolver() @app.get(rule="/bad-request-error") def bad_request_error(): @@ -613,7 +628,7 @@ def handler(event, context): ### Custom Domain API Mappings -When using Custom Domain API Mappings feature, you must use **`strip_prefixes`** param in the `ApiGatewayResolver` constructor. +When using Custom Domain API Mappings feature, you must use **`strip_prefixes`** param in the `APIGatewayRestResolver` constructor. Scenario: You have a custom domain `api.mydomain.dev` and set an API Mapping `payment` to forward requests to your Payments API, the path argument will be `/payment/`. @@ -624,11 +639,11 @@ This will lead to a HTTP 404 despite having your Lambda configured correctly. Se ```python hl_lines="7" from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.logging import correlation_paths - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver tracer = Tracer() logger = Logger() - app = ApiGatewayResolver(strip_prefixes=["/payment"]) + app = APIGatewayRestResolver(strip_prefixes=["/payment"]) @app.get("/subscriptions/") @tracer.capture_method @@ -661,7 +676,7 @@ This will lead to a HTTP 404 despite having your Lambda configured correctly. Se ### CORS -You can configure CORS at the `ApiGatewayResolver` constructor via `cors` parameter using the `CORSConfig` class. +You can configure CORS at the `APIGatewayRestResolver` constructor via `cors` parameter using the `CORSConfig` class. This will ensure that CORS headers are always returned as part of the response when your functions match the path invoked. @@ -670,13 +685,13 @@ This will ensure that CORS headers are always returned as part of the response w ```python hl_lines="9 11" from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.logging import correlation_paths - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver, CORSConfig + from aws_lambda_powertools.event_handler import APIGatewayRestResolver, CORSConfig tracer = Tracer() logger = Logger() cors_config = CORSConfig(allow_origin="https://example.com", max_age=300) - app = ApiGatewayResolver(cors=cors_config) + app = APIGatewayRestResolver(cors=cors_config) @app.get("/hello/") @tracer.capture_method @@ -754,9 +769,9 @@ You can use the `Response` class to have full control over the response, for exa === "app.py" ```python hl_lines="10-14" - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver, Response + from aws_lambda_powertools.event_handler.api_gateway import APIGatewayRestResolver, Response - app = ApiGatewayResolver() + app = APIGatewayRestResolver() @app.get("/hello") def get_hello_you(): @@ -796,9 +811,9 @@ You can compress with gzip and base64 encode your responses via `compress` param === "app.py" ```python hl_lines="5 7" - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver - app = ApiGatewayResolver() + app = APIGatewayRestResolver() @app.get("/hello", compress=True) def get_hello_you(): @@ -850,9 +865,9 @@ Like `compress` feature, the client must send the `Accept` header with the corre import os from pathlib import Path - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver, Response + from aws_lambda_powertools.event_handler.api_gateway import APIGatewayRestResolver, Response - app = ApiGatewayResolver() + app = APIGatewayRestResolver() logo_file: bytes = Path(os.getenv("LAMBDA_TASK_ROOT") + "/logo.svg").read_bytes() @app.get("/logo") @@ -943,9 +958,9 @@ This will enable full tracebacks errors in the response, print request and respo It's best to use for local development only! ```python hl_lines="3" title="Enabling debug mode" -from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver +from aws_lambda_powertools.event_handler import APIGatewayRestResolver -app = ApiGatewayResolver(debug=True) +app = APIGatewayRestResolver(debug=True) @app.get("/hello") def get_hello_universe(): @@ -965,6 +980,8 @@ from enum import Enum from json import JSONEncoder from typing import Dict +from aws_lambda_powertools.event_handler import APIGatewayRestResolver + class CustomEncoder(JSONEncoder): """Your customer json encoder""" def default(self, obj): @@ -979,11 +996,11 @@ class CustomEncoder(JSONEncoder): return JSONEncoder.default(self, obj) def custom_serializer(obj) -> str: - """Your custom serializer function ApiGatewayResolver will use""" + """Your custom serializer function APIGatewayRestResolver will use""" return json.dumps(obj, cls=CustomEncoder) # Assigning your custom serializer -app = ApiGatewayResolver(serializer=custom_serializer) +app = APIGatewayRestResolver(serializer=custom_serializer) class Color(Enum): RED = 1 @@ -1006,7 +1023,7 @@ Let's assume you have `app.py` as your Lambda function entrypoint and routes in === "users.py" - We import **Router** instead of **ApiGatewayResolver**; syntax wise is exactly the same. + We import **Router** instead of **APIGatewayRestResolver**; syntax wise is exactly the same. ```python hl_lines="4 8 12 15 21" import itertools @@ -1045,13 +1062,13 @@ Let's assume you have `app.py` as your Lambda function entrypoint and routes in from typing import Dict from aws_lambda_powertools import Logger - from aws_lambda_powertools.event_handler import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver from aws_lambda_powertools.utilities.typing import LambdaContext import users logger = Logger() - app = ApiGatewayResolver() + app = APIGatewayRestResolver() app.include_router(users.router) @@ -1070,12 +1087,12 @@ When necessary, you can set a prefix when including a router object. This means ```python hl_lines="9" from typing import Dict - from aws_lambda_powertools.event_handler import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver from aws_lambda_powertools.utilities.typing import LambdaContext import users - app = ApiGatewayResolver() + app = APIGatewayRestResolver() app.include_router(users.router, prefix="/users") # prefix '/users' to any route in `users.router` @@ -1208,8 +1225,7 @@ This sample project contains a Users function with two distinct set of routes, ` from typing import Dict from aws_lambda_powertools import Logger, Tracer - from aws_lambda_powertools.event_handler import ApiGatewayResolver - from aws_lambda_powertools.event_handler.api_gateway import ProxyEventType + from aws_lambda_powertools.event_handler import APIGatewayRestResolver from aws_lambda_powertools.logging.correlation_paths import APPLICATION_LOAD_BALANCER from aws_lambda_powertools.utilities.typing import LambdaContext @@ -1217,7 +1233,7 @@ This sample project contains a Users function with two distinct set of routes, ` tracer = Tracer() logger = Logger() - app = ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent) + app = APIGatewayRestResolver() app.include_router(health.router) app.include_router(users.router) @@ -1354,10 +1370,10 @@ You can test your routes by passing a proxy event request where `path` and `http ```python from aws_lambda_powertools import Logger from aws_lambda_powertools.logging import correlation_paths - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver logger = Logger() - app = ApiGatewayResolver() # by default API Gateway REST API (v1) + app = APIGatewayRestResolver() # API Gateway REST API (v1) @app.get("/hello") def get_hello_universe(): @@ -1376,3 +1392,14 @@ You can test your routes by passing a proxy event request where `path` and `http Chalice is a full featured microframework that manages application and infrastructure. This utility, however, is largely focused on routing to reduce boilerplate and expects you to setup and manage infrastructure with your framework of choice. That said, [Chalice has native integration with Lambda Powertools](https://aws.github.io/chalice/topics/middleware.html){target="_blank"} if you're looking for a more opinionated and web framework feature set. + +**What happened to `ApiGatewayResolver`?** + +It's been superseded by more explicit resolvers like `APIGatewayRestResolver`, `APIGatewayHttpResolver`, and `ALBResolver`. + +`ApiGatewayResolver` handled multiple types of event resolvers for convenience via `proxy_type` param. However, +it made it impossible for static checkers like Mypy and IDEs IntelliSense to know what properties a `current_event` would have due to late bound resolution. + +This provided a suboptimal experience for customers not being able to find all properties available besides common ones between API Gateway REST, HTTP, and ALB - while manually annotating `app.current_event` would work it is not the experience we want to provide to customers. + +`ApiGatewayResolver` will be deprecated in v2 and have appropriate warnings as soon as we have a v2 draft. diff --git a/docs/tutorial/idempotency.md b/docs/tutorial/idempotency.md deleted file mode 100644 index 4b03b66abd4..00000000000 --- a/docs/tutorial/idempotency.md +++ /dev/null @@ -1,1009 +0,0 @@ ---- -title: Idempotency -description: Utility ---- - -The idempotency utility provides a simple solution to convert your Lambda functions into idempotent operations which -are safe to retry. - -## Terminology - -The property of idempotency means that an operation does not cause additional side effects if it is called more than -once with the same input parameters. - -**Idempotent operations will return the same result when they are called multiple -times with the same parameters**. This makes idempotent operations safe to retry. - -**Idempotency key** is a hash representation of either the entire event or a specific configured subset of the event, and invocation results are **JSON serialized** and stored in your persistence storage layer. - -## Key features - -* Prevent Lambda handler from executing more than once on the same event payload during a time window -* Ensure Lambda handler returns the same result when called with the same payload -* Select a subset of the event as the idempotency key using JMESPath expressions -* Set a time window in which records with the same payload should be considered duplicates - -## Getting started - -### Required resources - -Before getting started, you need to create a persistent storage layer where the idempotency utility can store its state - your lambda functions will need read and write access to it. - -As of now, Amazon DynamoDB is the only supported persistent storage layer, so you'll need to create a table first. - -**Default table configuration** - -If you're not [changing the default configuration for the DynamoDB persistence layer](#dynamodbpersistencelayer), this is the expected default configuration: - -Configuration | Value | Notes -------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------- -Partition key | `id` | -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. - -```yaml hl_lines="5-13 21-23" title="AWS Serverless Application Model (SAM) example" -Resources: - IdempotencyTable: - Type: AWS::DynamoDB::Table - Properties: - AttributeDefinitions: - - AttributeName: id - AttributeType: S - KeySchema: - - AttributeName: id - KeyType: HASH - TimeToLiveSpecification: - AttributeName: expiration - Enabled: true - BillingMode: PAY_PER_REQUEST - - HelloWorldFunction: - Type: AWS::Serverless::Function - Properties: - Runtime: python3.8 - ... - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref IdempotencyTable -``` - -???+ warning "Warning: Large responses with DynamoDB persistence layer" - When using this utility with DynamoDB, your function's responses must be [smaller than 400KB](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Limits.html#limits-items). - - Larger items cannot be written to DynamoDB and will cause exceptions. - -???+ info "Info: DynamoDB" - Each function invocation will generally make 2 requests to DynamoDB. If the - result returned by your Lambda is less than 1kb, you can expect 2 WCUs per invocation. For retried invocations, you will - see 1WCU and 1RCU. Review the [DynamoDB pricing documentation](https://aws.amazon.com/dynamodb/pricing/) to - estimate the cost. - -### Idempotent decorator - -You can quickly start by initializing the `DynamoDBPersistenceLayer` class and using it with the `idempotent` decorator on your lambda handler. - -=== "app.py" - - ```python hl_lines="1-3 5 7 14" - from aws_lambda_powertools.utilities.idempotency import ( - DynamoDBPersistenceLayer, idempotent - ) - - persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") - - @idempotent(persistence_store=persistence_layer) - def handler(event, context): - payment = create_subscription_payment( - user=event['user'], - product=event['product_id'] - ) - ... - return { - "payment_id": payment.id, - "message": "success", - "statusCode": 200, - } - ``` - -=== "Example event" - - ```json - { - "username": "xyz", - "product_id": "123456789" - } - ``` - -### Idempotent_function decorator - -Similar to [idempotent decorator](#idempotent-decorator), you can use `idempotent_function` decorator for any synchronous Python function. - -When using `idempotent_function`, you must tell us which keyword parameter in your function signature has the data we should use via **`data_keyword_argument`**. - -!!! info "We support JSON serializable data, [Python Dataclasses](https://docs.python.org/3.7/library/dataclasses.html){target="_blank"}, [Parser/Pydantic Models](parser.md){target="_blank"}, and our [Event Source Data Classes](./data_classes.md){target="_blank"}." - -???+ warning - Make sure to call your decorated function using keyword arguments - -=== "batch_sample.py" - - This example also demonstrates how you can integrate with [Batch utility](batch.md), so you can process each record in an idempotent manner. - - ```python hl_lines="4-5 16 21 29" - from aws_lambda_powertools.utilities.batch import (BatchProcessor, EventType, - batch_processor) - from aws_lambda_powertools.utilities.data_classes.sqs_event import SQSRecord - from aws_lambda_powertools.utilities.idempotency import ( - DynamoDBPersistenceLayer, IdempotencyConfig, idempotent_function) - - - processor = BatchProcessor(event_type=EventType.SQS) - dynamodb = DynamoDBPersistenceLayer(table_name="idem") - config = IdempotencyConfig( - event_key_jmespath="messageId", # see Choosing a payload subset section - use_local_cache=True, - ) - - - @idempotent_function(data_keyword_argument="record", config=config, persistence_store=dynamodb) - def record_handler(record: SQSRecord): - return {"message": record["body"]} - - - @idempotent_function(data_keyword_argument="data", config=config, persistence_store=dynamodb) - def dummy(arg_one, arg_two, data: dict, **kwargs): - return {"data": data} - - - @batch_processor(record_handler=record_handler, processor=processor) - def lambda_handler(event, context): - # `data` parameter must be called as a keyword argument to work - dummy("hello", "universe", data="test") - return processor.response() - ``` - -=== "Batch event" - - ```json hl_lines="4" - { - "Records": [ - { - "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", - "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", - "body": "Test message.", - "attributes": { - "ApproximateReceiveCount": "1", - "SentTimestamp": "1545082649183", - "SenderId": "AIDAIENQZJOLO23YVJ4VO", - "ApproximateFirstReceiveTimestamp": "1545082649185" - }, - "messageAttributes": { - "testAttr": { - "stringValue": "100", - "binaryValue": "base64Str", - "dataType": "Number" - } - }, - "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", - "awsRegion": "us-east-2" - } - ] - } - ``` - -=== "dataclass_sample.py" - - ```python hl_lines="3-4 23 32" - from dataclasses import dataclass - - from aws_lambda_powertools.utilities.idempotency import ( - DynamoDBPersistenceLayer, IdempotencyConfig, idempotent_function) - - dynamodb = DynamoDBPersistenceLayer(table_name="idem") - config = IdempotencyConfig( - event_key_jmespath="order_id", # see Choosing a payload subset section - use_local_cache=True, - ) - - @dataclass - class OrderItem: - sku: str - description: str - - @dataclass - class Order: - item: OrderItem - order_id: int - - - @idempotent_function(data_keyword_argument="order", config=config, persistence_store=dynamodb) - def process_order(order: Order): - return f"processed order {order.order_id}" - - - order_item = OrderItem(sku="fake", description="sample") - order = Order(item=order_item, order_id="fake-id") - - # `order` parameter must be called as a keyword argument to work - process_order(order=order) - ``` - -=== "parser_pydantic_sample.py" - - ```python hl_lines="1-2 22 31" - from aws_lambda_powertools.utilities.idempotency import ( - DynamoDBPersistenceLayer, IdempotencyConfig, idempotent_function) - from aws_lambda_powertools.utilities.parser import BaseModel - - dynamodb = DynamoDBPersistenceLayer(table_name="idem") - config = IdempotencyConfig( - event_key_jmespath="order_id", # see Choosing a payload subset section - use_local_cache=True, - ) - - - class OrderItem(BaseModel): - sku: str - description: str - - - class Order(BaseModel): - item: OrderItem - order_id: int - - - @idempotent_function(data_keyword_argument="order", config=config, persistence_store=dynamodb) - def process_order(order: Order): - return f"processed order {order.order_id}" - - - order_item = OrderItem(sku="fake", description="sample") - order = Order(item=order_item, order_id="fake-id") - - # `order` parameter must be called as a keyword argument to work - process_order(order=order) - ``` - -### Choosing a payload subset for idempotency - -???+ tip "Tip: Dealing with always changing payloads" - When dealing with a more elaborate payload, where parts of the payload always change, you should use **`event_key_jmespath`** parameter. - -Use [`IdempotencyConfig`](#customizing-the-default-behavior) to instruct the idempotent decorator to only use a portion of your payload to verify whether a request is idempotent, and therefore it should not be retried. - -> **Payment scenario** - -In this example, we have a Lambda handler that creates a payment for a user subscribing to a product. We want to ensure that we don't accidentally charge our customer by subscribing them more than once. - -Imagine the function executes successfully, but the client never receives the response due to a connection issue. It is safe to retry in this instance, as the idempotent decorator will return a previously saved response. - -???+ warning "Warning: Idempotency for JSON payloads" - The payload extracted by the `event_key_jmespath` is treated as a string by default, so will be sensitive to differences in whitespace even when the JSON payload itself is identical. - - To alter this behaviour, we can use the [JMESPath built-in function](jmespath_functions.md#powertools_json-function) `powertools_json()` to treat the payload as a JSON object (dict) rather than a string. - -=== "payment.py" - - ```python hl_lines="2-4 10 12 15 20" - import json - from aws_lambda_powertools.utilities.idempotency import ( - IdempotencyConfig, DynamoDBPersistenceLayer, idempotent - ) - - persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") - - # Treat everything under the "body" key - # in the event json object as our payload - config = IdempotencyConfig(event_key_jmespath="powertools_json(body)") - - @idempotent(config=config, persistence_store=persistence_layer) - def handler(event, context): - body = json.loads(event['body']) - payment = create_subscription_payment( - user=body['user'], - product=body['product_id'] - ) - ... - return { - "payment_id": payment.id, - "message": "success", - "statusCode": 200 - } - ``` - -=== "Example event" - - ```json hl_lines="28" - { - "version":"2.0", - "routeKey":"ANY /createpayment", - "rawPath":"/createpayment", - "rawQueryString":"", - "headers": { - "Header1": "value1", - "Header2": "value2" - }, - "requestContext":{ - "accountId":"123456789012", - "apiId":"api-id", - "domainName":"id.execute-api.us-east-1.amazonaws.com", - "domainPrefix":"id", - "http":{ - "method":"POST", - "path":"/createpayment", - "protocol":"HTTP/1.1", - "sourceIp":"ip", - "userAgent":"agent" - }, - "requestId":"id", - "routeKey":"ANY /createpayment", - "stage":"$default", - "time":"10/Feb/2021:13:40:43 +0000", - "timeEpoch":1612964443723 - }, - "body":"{\"user\":\"xyz\",\"product_id\":\"123456789\"}", - "isBase64Encoded":false - } - ``` - - -### Idempotency request flow - -This sequence diagram shows an example flow of what happens in the payment scenario: - -![Idempotent sequence](../media/idempotent_sequence.png) - -The client was successful in receiving the result after the retry. Since the Lambda handler was only executed once, our customer hasn't been charged twice. - -???+ note - Bear in mind that the entire Lambda handler is treated as a single idempotent operation. If your Lambda handler can cause multiple side effects, consider splitting it into separate functions. - -### Handling exceptions - -If you are using the `idempotent` decorator on your Lambda handler, any unhandled exceptions that are raised during the code execution will cause **the record in the persistence layer to be deleted**. -This means that new invocations will execute your code again despite having the same payload. If you don't want the record to be deleted, you need to catch exceptions within the idempotent function and return a successful response. - - -![Idempotent sequence exception](../media/idempotent_sequence_exception.png) - -If you are using `idempotent_function`, any unhandled exceptions that are raised _inside_ the decorated function will cause the record in the persistence layer to be deleted, and allow the function to be executed again if retried. - -If an Exception is raised _outside_ the scope of the decorated function and after your function has been called, the persistent record will not be affected. In this case, idempotency will be maintained for your decorated function. Example: - -```python hl_lines="2-4 8-10" title="Exception not affecting idempotency record sample" -def lambda_handler(event, context): - # If an exception is raised here, no idempotent record will ever get created as the - # idempotent function does not get called - do_some_stuff() - - result = call_external_service(data={"user": "user1", "id": 5}) - - # This exception will not cause the idempotent record to be deleted, since it - # happens after the decorated function has been successfully called - raise Exception - - -@idempotent_function(data_keyword_argument="data", config=config, persistence_store=dynamodb) -def call_external_service(data: dict, **kwargs): - result = requests.post('http://example.com', json={"user": data['user'], "transaction_id": data['id']} - return result.json() -``` - -???+ warning - **We will raise `IdempotencyPersistenceLayerError`** if any of the calls to the persistence layer fail unexpectedly. - - As this happens outside the scope of your decorated function, you are not able to catch it if you're using the `idempotent` decorator on your Lambda handler. - -### Persistence layers - -#### DynamoDBPersistenceLayer - -This persistence layer is built-in, and you can either use an existing DynamoDB table or create a new one dedicated for idempotency state (recommended). - -```python hl_lines="5-9" title="Customizing DynamoDBPersistenceLayer to suit your table structure" -from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer - -persistence_layer = DynamoDBPersistenceLayer( - table_name="IdempotencyTable", - key_attr="idempotency_key", - expiry_attr="expires_at", - status_attr="current_status", - data_attr="result_data", - validation_key_attr="validation_key", -) -``` - -When using DynamoDB as a persistence layer, you can alter the attribute names by passing these parameters when initializing the persistence layer: - -Parameter | Required | Default | Description -------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------- -**table_name** | :heavy_check_mark: | | Table name to store state -**key_attr** | | `id` | Partition key of the table. Hashed representation of the payload (unless **sort_key_attr** is specified) -**expiry_attr** | | `expiration` | Unix timestamp of when record expires -**status_attr** | | `status` | Stores status of the lambda execution during and after invocation -**data_attr** | | `data` | Stores results of successfully executed Lambda handlers -**validation_key_attr** | | `validation` | Hashed representation of the parts of the event used for validation -**sort_key_attr** | | | Sort key of the table (if table is configured with a sort key). -**static_pk_value** | | `idempotency#{LAMBDA_FUNCTION_NAME}` | Static value to use as the partition key. Only used when **sort_key_attr** is set. - -## Advanced - -### Customizing the default behavior - -Idempotent decorator can be further configured with **`IdempotencyConfig`** as seen in the previous example. These are the available options for further configuration - -Parameter | Default | Description -------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------- -**event_key_jmespath** | `""` | JMESPath expression to extract the idempotency key from the event record using [built-in functions](/utilities/jmespath_functions) -**payload_validation_jmespath** | `""` | JMESPath expression to validate whether certain parameters have changed in the event while the event payload -**raise_on_no_idempotency_key** | `False` | Raise exception if no idempotency key was found in the request -**expires_after_seconds** | 3600 | The number of seconds to wait before a record is expired -**use_local_cache** | `False` | Whether to locally cache idempotency results -**local_cache_max_items** | 256 | Max number of items to store in local cache -**hash_function** | `md5` | Function to use for calculating hashes, as provided by [hashlib](https://docs.python.org/3/library/hashlib.html) in the standard library. - -### Handling concurrent executions with the same payload - -This utility will raise an **`IdempotencyAlreadyInProgressError`** exception if you receive **multiple invocations with the same payload while the first invocation hasn't completed yet**. - -???+ info - If you receive `IdempotencyAlreadyInProgressError`, you can safely retry the operation. - -This is a locking mechanism for correctness. Since we don't know the result from the first invocation yet, we can't safely allow another concurrent execution. - -### Using in-memory cache - -**By default, in-memory local caching is disabled**, since we don't know how much memory you consume per invocation compared to the maximum configured in your Lambda function. - -???+ note "Note: This in-memory cache is local to each Lambda execution environment" - This means it will be effective in cases where your function's concurrency is low in comparison to the number of "retry" invocations with the same payload, because cache might be empty. - -You can enable in-memory caching with the **`use_local_cache`** parameter: - -```python hl_lines="8 11" title="Caching idempotent transactions in-memory to prevent multiple calls to storage" -from aws_lambda_powertools.utilities.idempotency import ( - IdempotencyConfig, DynamoDBPersistenceLayer, idempotent -) - -persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") -config = IdempotencyConfig( - event_key_jmespath="body", - use_local_cache=True, -) - -@idempotent(config=config, persistence_store=persistence_layer) -def handler(event, context): - ... -``` - -When enabled, the default is to cache a maximum of 256 records in each Lambda execution environment - You can change it with the **`local_cache_max_items`** parameter. - -### Expiring idempotency records - -???+ note - By default, we expire idempotency records after **an hour** (3600 seconds). - -In most cases, it is not desirable to store the idempotency records forever. Rather, you want to guarantee that the same payload won't be executed within a period of time. - -You can change this window with the **`expires_after_seconds`** parameter: - -```python hl_lines="8 11" title="Adjusting cache TTL" -from aws_lambda_powertools.utilities.idempotency import ( - IdempotencyConfig, DynamoDBPersistenceLayer, idempotent -) - -persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") -config = IdempotencyConfig( - event_key_jmespath="body", - expires_after_seconds=5*60, # 5 minutes -) - -@idempotent(config=config, persistence_store=persistence_layer) -def handler(event, context): - ... -``` - -This will mark any records older than 5 minutes as expired, and the lambda handler will be executed as normal if it is invoked with a matching payload. - -???+ note "Note: DynamoDB time-to-live field" - This utility uses **`expiration`** as the TTL field in DynamoDB, as [demonstrated in the SAM example earlier](#required-resources). - -### Payload validation - -???+ question "Question: What if your function is invoked with the same payload except some outer parameters have changed?" - Example: A payment transaction for a given productID was requested twice for the same customer, **however the amount to be paid has changed in the second transaction**. - -By default, we will return the same result as it returned before, however in this instance it may be misleading; we provide a fail fast payload validation to address this edge case. - -With **`payload_validation_jmespath`**, you can provide an additional JMESPath expression to specify which part of the event body should be validated against previous idempotent invocations - -=== "app.py" - - ```python hl_lines="7 11 18 25" - from aws_lambda_powertools.utilities.idempotency import ( - IdempotencyConfig, DynamoDBPersistenceLayer, idempotent - ) - - config = IdempotencyConfig( - event_key_jmespath="[userDetail, productId]", - payload_validation_jmespath="amount" - ) - persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") - - @idempotent(config=config, persistence_store=persistence_layer) - def handler(event, context): - # Creating a subscription payment is a side - # effect of calling this function! - payment = create_subscription_payment( - user=event['userDetail']['username'], - product=event['product_id'], - amount=event['amount'] - ) - ... - return { - "message": "success", - "statusCode": 200, - "payment_id": payment.id, - "amount": payment.amount - } - ``` - -=== "Example Event 1" - - ```json hl_lines="8" - { - "userDetail": { - "username": "User1", - "user_email": "user@example.com" - }, - "productId": 1500, - "charge_type": "subscription", - "amount": 500 - } - ``` - -=== "Example Event 2" - - ```json hl_lines="8" - { - "userDetail": { - "username": "User1", - "user_email": "user@example.com" - }, - "productId": 1500, - "charge_type": "subscription", - "amount": 1 - } - ``` - -In this example, the **`userDetail`** and **`productId`** keys are used as the payload to generate the idempotency key, as per **`event_key_jmespath`** parameter. - -???+ note - If we try to send the same request but with a different amount, we will raise **`IdempotencyValidationError`**. - -Without payload validation, we would have returned the same result as we did for the initial request. Since we're also returning an amount in the response, this could be quite confusing for the client. - -By using **`payload_validation_jmespath="amount"`**, we prevent this potentially confusing behavior and instead raise an Exception. - -### Making idempotency key required - -If you want to enforce that an idempotency key is required, you can set **`raise_on_no_idempotency_key`** to `True`. - -This means that we will raise **`IdempotencyKeyError`** if the evaluation of **`event_key_jmespath`** is `None`. - -=== "app.py" - - ```python hl_lines="9-10 13" - from aws_lambda_powertools.utilities.idempotency import ( - IdempotencyConfig, DynamoDBPersistenceLayer, idempotent - ) - - persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") - - # Requires "user"."uid" and "order_id" to be present - config = IdempotencyConfig( - event_key_jmespath="[user.uid, order_id]", - raise_on_no_idempotency_key=True, - ) - - @idempotent(config=config, persistence_store=persistence_layer) - def handler(event, context): - pass - ``` - -=== "Success Event" - - ```json hl_lines="3 6" - { - "user": { - "uid": "BB0D045C-8878-40C8-889E-38B3CB0A61B1", - "name": "Foo" - }, - "order_id": 10000 - } - ``` - -=== "Failure Event" - - Notice that `order_id` is now accidentally within `user` key - - ```json hl_lines="3 5" - { - "user": { - "uid": "DE0D000E-1234-10D1-991E-EAC1DD1D52C8", - "name": "Joe Bloggs", - "order_id": 10000 - }, - } - ``` - -### Customizing boto configuration - -The **`boto_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 constructing the persistence store. - -=== "Custom session" - - ```python hl_lines="1 6 9 14" - import boto3 - from aws_lambda_powertools.utilities.idempotency import ( - IdempotencyConfig, DynamoDBPersistenceLayer, idempotent - ) - - boto3_session = boto3.session.Session() - persistence_layer = DynamoDBPersistenceLayer( - table_name="IdempotencyTable", - boto3_session=boto3_session - ) - - config = IdempotencyConfig(event_key_jmespath="body") - - @idempotent(config=config, persistence_store=persistence_layer) - def handler(event, context): - ... - ``` -=== "Custom config" - - ```python hl_lines="1 7 10" - from botocore.config import Config - from aws_lambda_powertools.utilities.idempotency import ( - IdempotencyConfig, DynamoDBPersistenceLayer, idempotent - ) - - config = IdempotencyConfig(event_key_jmespath="body") - boto_config = Config() - persistence_layer = DynamoDBPersistenceLayer( - table_name="IdempotencyTable", - boto_config=boto_config - ) - - @idempotent(config=config, persistence_store=persistence_layer) - def handler(event, context): - ... - ``` - -### Using a DynamoDB table with a composite primary key - -When using a composite primary key table (hash+range key), use `sort_key_attr` parameter when initializing your persistence layer. - -With this setting, we will save the idempotency key in the sort key instead of the primary key. By default, the primary key will now be set to `idempotency#{LAMBDA_FUNCTION_NAME}`. - -You can optionally set a static value for the partition key using the `static_pk_value` parameter. - -```python hl_lines="5" title="Reusing a DynamoDB table that uses a composite primary key" -from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer, idempotent - -persistence_layer = DynamoDBPersistenceLayer( - table_name="IdempotencyTable", - sort_key_attr='sort_key') - - -@idempotent(persistence_store=persistence_layer) -def handler(event, context): - return {"message": "success": "id": event['body']['id]} -``` - -The example function above would cause data to be stored in DynamoDB like this: - -| id | sort_key | expiration | status | data | -|------------------------------|----------------------------------|------------|-------------|-------------------------------------| -| idempotency#MyLambdaFunction | 1e956ef7da78d0cb890be999aecc0c9e | 1636549553 | COMPLETED | {"id": 12391, "message": "success"} | -| idempotency#MyLambdaFunction | 2b2cdb5f86361e97b4383087c1ffdf27 | 1636549571 | COMPLETED | {"id": 527212, "message": "success"}| -| idempotency#MyLambdaFunction | f091d2527ad1c78f05d54cc3f363be80 | 1636549585 | IN_PROGRESS | | - -### Bring your own persistent store - -This utility provides an abstract base class (ABC), so that you can implement your choice of persistent storage layer. - -You can inherit from the `BasePersistenceLayer` class and implement the abstract methods `_get_record`, `_put_record`, -`_update_record` and `_delete_record`. - -```python hl_lines="8-13 57 65 74 96 124" title="Excerpt DynamoDB Persisntence Layer implementation for reference" -import datetime -import logging -from typing import Any, Dict, Optional - -import boto3 -from botocore.config import Config - -from aws_lambda_powertools.utilities.idempotency import BasePersistenceLayer -from aws_lambda_powertools.utilities.idempotency.exceptions import ( - IdempotencyItemAlreadyExistsError, - IdempotencyItemNotFoundError, -) -from aws_lambda_powertools.utilities.idempotency.persistence.base import DataRecord - -logger = logging.getLogger(__name__) - - -class DynamoDBPersistenceLayer(BasePersistenceLayer): - def __init__( - self, - table_name: str, - key_attr: str = "id", - expiry_attr: str = "expiration", - status_attr: str = "status", - data_attr: str = "data", - validation_key_attr: str = "validation", - boto_config: Optional[Config] = None, - boto3_session: Optional[boto3.session.Session] = None, - ): - boto_config = boto_config or Config() - session = boto3_session or boto3.session.Session() - self._ddb_resource = session.resource("dynamodb", config=boto_config) - self.table_name = table_name - self.table = self._ddb_resource.Table(self.table_name) - self.key_attr = key_attr - self.expiry_attr = expiry_attr - self.status_attr = status_attr - self.data_attr = data_attr - self.validation_key_attr = validation_key_attr - super(DynamoDBPersistenceLayer, self).__init__() - - def _item_to_data_record(self, item: Dict[str, Any]) -> DataRecord: - """ - Translate raw item records from DynamoDB to DataRecord - - Parameters - ---------- - item: Dict[str, Union[str, int]] - Item format from dynamodb response - - Returns - ------- - DataRecord - representation of item - - """ - return DataRecord( - idempotency_key=item[self.key_attr], - status=item[self.status_attr], - expiry_timestamp=item[self.expiry_attr], - response_data=item.get(self.data_attr), - payload_hash=item.get(self.validation_key_attr), - ) - - def _get_record(self, idempotency_key) -> DataRecord: - response = self.table.get_item(Key={self.key_attr: idempotency_key}, ConsistentRead=True) - - try: - item = response["Item"] - except KeyError: - raise IdempotencyItemNotFoundError - return self._item_to_data_record(item) - - def _put_record(self, data_record: DataRecord) -> None: - item = { - self.key_attr: data_record.idempotency_key, - self.expiry_attr: data_record.expiry_timestamp, - self.status_attr: data_record.status, - } - - if self.payload_validation_enabled: - item[self.validation_key_attr] = data_record.payload_hash - - now = datetime.datetime.now() - try: - logger.debug(f"Putting record for idempotency key: {data_record.idempotency_key}") - self.table.put_item( - Item=item, - ConditionExpression=f"attribute_not_exists({self.key_attr}) OR {self.expiry_attr} < :now", - ExpressionAttributeValues={":now": int(now.timestamp())}, - ) - except self._ddb_resource.meta.client.exceptions.ConditionalCheckFailedException: - logger.debug(f"Failed to put record for already existing idempotency key: {data_record.idempotency_key}") - raise IdempotencyItemAlreadyExistsError - - def _update_record(self, data_record: DataRecord): - logger.debug(f"Updating record for idempotency key: {data_record.idempotency_key}") - update_expression = "SET #response_data = :response_data, #expiry = :expiry, #status = :status" - expression_attr_values = { - ":expiry": data_record.expiry_timestamp, - ":response_data": data_record.response_data, - ":status": data_record.status, - } - expression_attr_names = { - "#response_data": self.data_attr, - "#expiry": self.expiry_attr, - "#status": self.status_attr, - } - - if self.payload_validation_enabled: - update_expression += ", #validation_key = :validation_key" - expression_attr_values[":validation_key"] = data_record.payload_hash - expression_attr_names["#validation_key"] = self.validation_key_attr - - kwargs = { - "Key": {self.key_attr: data_record.idempotency_key}, - "UpdateExpression": update_expression, - "ExpressionAttributeValues": expression_attr_values, - "ExpressionAttributeNames": expression_attr_names, - } - - self.table.update_item(**kwargs) - - def _delete_record(self, data_record: DataRecord) -> None: - logger.debug(f"Deleting record for idempotency key: {data_record.idempotency_key}") - self.table.delete_item(Key={self.key_attr: data_record.idempotency_key},) -``` - -???+ danger - Pay attention to the documentation for each - you may need to perform additional checks inside these methods to ensure the idempotency guarantees remain intact. - - For example, the `_put_record` method needs to raise an exception if a non-expired record already exists in the data store with a matching key. - -## Compatibility with other utilities - -### Validation utility - -The idempotency utility can be used with the `validator` decorator. Ensure that idempotency is the innermost decorator. - -???+ warning - If you use an envelope with the validator, the event received by the idempotency utility will be the unwrapped - event - not the "raw" event Lambda was invoked with. - - Make sure to account for this behaviour, if you set the `event_key_jmespath`. - -```python hl_lines="9 10" title="Using Idempotency with JSONSchema Validation utility" -from aws_lambda_powertools.utilities.validation import validator, envelopes -from aws_lambda_powertools.utilities.idempotency import ( - IdempotencyConfig, DynamoDBPersistenceLayer, idempotent -) - -config = IdempotencyConfig(event_key_jmespath="[message, username]") -persistence_layer = DynamoDBPersistenceLayer(table_name="IdempotencyTable") - -@validator(envelope=envelopes.API_GATEWAY_HTTP) -@idempotent(config=config, persistence_store=persistence_layer) -def lambda_handler(event, context): - cause_some_side_effects(event['username') - return {"message": event['message'], "statusCode": 200} -``` - -???+ tip "Tip: JMESPath Powertools functions are also available" - Built-in functions known in the validation utility like `powertools_json`, `powertools_base64`, `powertools_base64_gzip` are also available to use in this utility. - - -## Testing your code - -The idempotency utility provides several routes to test your code. - -### Disabling the idempotency utility -When testing your code, you may wish to disable the idempotency logic altogether and focus on testing your business logic. To do this, you can set the environment variable `POWERTOOLS_IDEMPOTENCY_DISABLED` -with a truthy value. If you prefer setting this for specific tests, and are using Pytest, you can use [monkeypatch](https://docs.pytest.org/en/latest/monkeypatch.html) fixture: - -=== "tests.py" - - ```python hl_lines="2 3" - def test_idempotent_lambda_handler(monkeypatch): - # Set POWERTOOLS_IDEMPOTENCY_DISABLED before calling decorated functions - monkeypatch.setenv("POWERTOOLS_IDEMPOTENCY_DISABLED", 1) - - result = handler() - ... - ``` -=== "app.py" - - ```python - from aws_lambda_powertools.utilities.idempotency import ( - DynamoDBPersistenceLayer, idempotent - ) - - persistence_layer = DynamoDBPersistenceLayer(table_name="idempotency") - - @idempotent(persistence_store=persistence_layer) - def handler(event, context): - print('expensive operation') - return { - "payment_id": 12345, - "message": "success", - "statusCode": 200, - } - ``` - -### Testing with DynamoDB Local - -To test with [DynamoDB Local](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.DownloadingAndRunning.html), you can replace the `Table` resource used by the persistence layer with one you create inside your tests. This allows you to set the endpoint_url. - -=== "tests.py" - - ```python hl_lines="6 7 8" - import boto3 - - import app - - def test_idempotent_lambda(): - # Create our own Table resource using the endpoint for our DynamoDB Local instance - resource = boto3.resource("dynamodb", endpoint_url='http://localhost:8000') - table = resource.Table(app.persistence_layer.table_name) - app.persistence_layer.table = table - - result = app.handler({'testkey': 'testvalue'}, {}) - assert result['payment_id'] == 12345 - ``` - -=== "app.py" - - ```python - from aws_lambda_powertools.utilities.idempotency import ( - DynamoDBPersistenceLayer, idempotent - ) - - persistence_layer = DynamoDBPersistenceLayer(table_name="idempotency") - - @idempotent(persistence_store=persistence_layer) - def handler(event, context): - print('expensive operation') - return { - "payment_id": 12345, - "message": "success", - "statusCode": 200, - } - ``` - -### How do I mock all DynamoDB I/O operations - -The idempotency utility lazily creates the dynamodb [Table](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#table) which it uses to access DynamoDB. -This means it is possible to pass a mocked Table resource, or stub various methods. - -=== "tests.py" - - ```python hl_lines="6 7 8 9" - from unittest.mock import MagicMock - - import app - - def test_idempotent_lambda(): - table = MagicMock() - app.persistence_layer.table = table - result = app.handler({'testkey': 'testvalue'}, {}) - table.put_item.assert_called() - ... - ``` - -=== "app.py" - - ```python - from aws_lambda_powertools.utilities.idempotency import ( - DynamoDBPersistenceLayer, idempotent - ) - - persistence_layer = DynamoDBPersistenceLayer(table_name="idempotency") - - @idempotent(persistence_store=persistence_layer) - def handler(event, context): - print('expensive operation') - return { - "payment_id": 12345, - "message": "success", - "statusCode": 200, - } - ``` - -## Extra resources - -If you're interested in a deep dive on how Amazon uses idempotency when building our APIs, check out -[this article](https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/). diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md index a348f122a24..de5bad9690f 100644 --- a/docs/tutorial/index.md +++ b/docs/tutorial/index.md @@ -322,9 +322,9 @@ Let's include Lambda Powertools as a dependency in `requirement.txt`, and use Ev === "app.py" ```python hl_lines="1 3 6 11 17" - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver - app = ApiGatewayResolver() + app = APIGatewayRestResolver() @app.get("/hello/") @@ -349,7 +349,7 @@ Let's include Lambda Powertools as a dependency in `requirement.txt`, and use Ev Use `sam build && sam local start-api` and try run it locally again. ???+ note - If you're coming from [Flask](https://flask.palletsprojects.com/en/2.0.x/){target="_blank"}, you will be familiar with this experience already. [Event Handler for API Gateway](./core/event_handler/api_gateway.md){target="_blank"} uses `ApiGatewayResolver` to give a Flask-like experience while staying true to our tenet `Keep it lean`. + If you're coming from [Flask](https://flask.palletsprojects.com/en/2.0.x/){target="_blank"}, you will be familiar with this experience already. [Event Handler for API Gateway](./core/event_handler/api_gateway.md){target="_blank"} uses `APIGatewayRestResolver` to give a Flask-like experience while staying true to our tenet `Keep it lean`. We have added the route annotation as the decorator for our methods. It enables us to use the parameters passed in the request directly, and our responses are simply dictionaries. @@ -379,7 +379,7 @@ The first option could be to use the standard Python Logger, and use a specializ import os from pythonjsonlogger import jsonlogger - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver logger = logging.getLogger("APP") logHandler = logging.StreamHandler() @@ -388,7 +388,7 @@ The first option could be to use the standard Python Logger, and use a specializ logger.addHandler(logHandler) logger.setLevel(os.getenv("LOG_LEVEL", "INFO")) - app = ApiGatewayResolver() + app = APIGatewayRestResolver() @app.get("/hello/") @@ -453,12 +453,12 @@ As we already have Lambda Powertools as a dependency, we can simply import [Logg ```python title="Refactoring with Lambda Powertools Logger" hl_lines="1 3 5 12 18 22" from aws_lambda_powertools import Logger -from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver +from aws_lambda_powertools.event_handler import APIGatewayRestResolver from aws_lambda_powertools.logging import correlation_paths logger = Logger(service="APP") -app = ApiGatewayResolver() +app = APIGatewayRestResolver() @app.get("/hello/") @@ -539,12 +539,12 @@ Let's explore how we can instrument our code with [AWS X-Ray SDK](https://docs.a from aws_xray_sdk.core import xray_recorder from aws_lambda_powertools import Logger - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver from aws_lambda_powertools.logging import correlation_paths logger = Logger(service="APP") - app = ApiGatewayResolver() + app = APIGatewayRestResolver() @app.get("/hello/") @@ -649,12 +649,12 @@ Let's put them into action. from aws_xray_sdk.core import patch_all, xray_recorder from aws_lambda_powertools import Logger -from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver +from aws_lambda_powertools.event_handler import APIGatewayRestResolver from aws_lambda_powertools.logging import correlation_paths logger = Logger(service="APP") -app = ApiGatewayResolver() +app = APIGatewayRestResolver() cold_start = True patch_all() @@ -726,12 +726,12 @@ We can simplify our previous patterns by using [Lambda Powertools Tracer](core/t ```python title="Refactoring with Lambda Powertools Tracer" hl_lines="1 6 11 13 19 21 27" from aws_lambda_powertools import Logger, Tracer -from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver +from aws_lambda_powertools.event_handler import APIGatewayRestResolver from aws_lambda_powertools.logging import correlation_paths logger = Logger(service="APP") tracer = Tracer(service="APP") -app = ApiGatewayResolver() +app = APIGatewayRestResolver() @app.get("/hello/") @@ -806,7 +806,7 @@ Let's expand our application with custom metrics using AWS SDK to see how it wor import boto3 from aws_lambda_powertools import Logger, Tracer - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver from aws_lambda_powertools.logging import correlation_paths cold_start = True @@ -815,7 +815,7 @@ Let's expand our application with custom metrics using AWS SDK to see how it wor logger = Logger(service="APP") tracer = Tracer(service="APP") metrics = boto3.client("cloudwatch") - app = ApiGatewayResolver() + app = APIGatewayRestResolver() @tracer.capture_method @@ -941,7 +941,7 @@ Let's implement that using [Metrics](./core/metrics.md){target="_blank}: ```python title="Refactoring with Lambda Powertools Metrics" hl_lines="1 4 9 18 27 33" from aws_lambda_powertools import Logger, Tracer, Metrics -from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver +from aws_lambda_powertools.event_handler import APIGatewayRestResolver from aws_lambda_powertools.logging import correlation_paths from aws_lambda_powertools.metrics import MetricUnit @@ -949,7 +949,7 @@ from aws_lambda_powertools.metrics import MetricUnit logger = Logger(service="APP") tracer = Tracer(service="APP") metrics = Metrics(namespace="MyApp", service="APP") -app = ApiGatewayResolver() +app = APIGatewayRestResolver() @app.get("/hello/") diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 416b768e77e..d2590b93aa3 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -291,10 +291,10 @@ You can use `get_enabled_features` method for scenarios where you need a list of === "app.py" ```python hl_lines="17-20 23" - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.event_handler import APIGatewayRestResolver from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore - app = ApiGatewayResolver() + app = APIGatewayRestResolver() app_config = AppConfigStore( environment="dev", diff --git a/tests/functional/event_handler/test_api_gateway.py b/tests/functional/event_handler/test_api_gateway.py index 09d32b7ffc1..c21cd352961 100644 --- a/tests/functional/event_handler/test_api_gateway.py +++ b/tests/functional/event_handler/test_api_gateway.py @@ -12,7 +12,10 @@ from aws_lambda_powertools.event_handler import content_types from aws_lambda_powertools.event_handler.api_gateway import ( + ALBResolver, + APIGatewayHttpResolver, ApiGatewayResolver, + APIGatewayRestResolver, CORSConfig, ProxyEventType, Response, @@ -47,13 +50,14 @@ def read_media(file_name: str) -> bytes: def test_alb_event(): - # GIVEN a Application Load Balancer proxy type event - app = ApiGatewayResolver(proxy_type=ProxyEventType.ALBEvent) + # 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 @@ -68,12 +72,13 @@ def foo(): def test_api_gateway_v1(): # GIVEN a Http API V1 proxy type event - app = ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEvent) + app = APIGatewayRestResolver() @app.get("/my/path") def get_lambda() -> Response: assert isinstance(app.current_event, APIGatewayProxyEvent) assert app.lambda_context == {} + assert app.current_event.request_context.domain_name == "id.execute-api.us-east-1.amazonaws.com" return Response(200, content_types.APPLICATION_JSON, json.dumps({"foo": "value"})) # WHEN calling the event handler @@ -106,12 +111,13 @@ def get_lambda() -> Response: def test_api_gateway_v2(): # GIVEN a Http API V2 proxy type event - app = ApiGatewayResolver(proxy_type=ProxyEventType.APIGatewayProxyEventV2) + app = APIGatewayHttpResolver() @app.post("/my/path") def my_path() -> Response: assert isinstance(app.current_event, APIGatewayProxyEventV2) post_data = app.current_event.json_body + assert app.current_event.cookies[0] == "cookie1" return Response(200, content_types.TEXT_PLAIN, post_data["username"]) # WHEN calling the event handler diff --git a/tests/functional/test_metrics.py b/tests/functional/test_metrics.py index 9d03a25e8b6..98700f87c8b 100644 --- a/tests/functional/test_metrics.py +++ b/tests/functional/test_metrics.py @@ -496,7 +496,8 @@ def lambda_handler(evt, context): # THEN ColdStart metric and function_name and service dimension should be logged assert output["ColdStart"] == [1.0] assert output["function_name"] == "example_fn" - assert output['service'] == service + assert output["service"] == service + def test_log_metrics_capture_cold_start_metric_no_service(capsys, namespace): # GIVEN Metrics is initialized without service @@ -515,7 +516,7 @@ def lambda_handler(evt, context): # THEN ColdStart metric and function_name dimension should be logged assert output["ColdStart"] == [1.0] assert output["function_name"] == "example_fn" - assert output.get('service') is None + assert output.get("service") is None def test_emit_cold_start_metric_only_once(capsys, namespace, service, metric): From 1eb9bb375f56de180444c060a6f8ac56e0fe73b5 Mon Sep 17 00:00:00 2001 From: Heitor Lessa Date: Fri, 4 Feb 2022 14:46:12 +0100 Subject: [PATCH 14/22] docs(tutorial): fix broken internal links (#1000) --- .../event_handler/__init__.py | 4 +-- docs/tutorial/index.md | 28 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/aws_lambda_powertools/event_handler/__init__.py b/aws_lambda_powertools/event_handler/__init__.py index def92f706f9..0b0c08b20a1 100644 --- a/aws_lambda_powertools/event_handler/__init__.py +++ b/aws_lambda_powertools/event_handler/__init__.py @@ -2,7 +2,7 @@ Event handler decorators for common Lambda events """ -from .api_gateway import ApiGatewayResolver +from .api_gateway import ALBResolver, APIGatewayHttpResolver, ApiGatewayResolver, APIGatewayRestResolver from .appsync import AppSyncResolver -__all__ = ["AppSyncResolver", "ApiGatewayResolver"] +__all__ = ["AppSyncResolver", "APIGatewayRestResolver", "APIGatewayHttpResolver", "ALBResolver", "ApiGatewayResolver"] diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md index de5bad9690f..63c5ca38a13 100644 --- a/docs/tutorial/index.md +++ b/docs/tutorial/index.md @@ -312,10 +312,10 @@ However, it forces us to understand the internal structure of the API Gateway re ### Simplifying with Event Handler -We can massively simplify cross-cutting concerns while keeping it lightweight by using [Event Handler](./core/event_handler/api_gateway.md){target="_blank"}. +We can massively simplify cross-cutting concerns while keeping it lightweight by using [Event Handler](../core/event_handler/api_gateway.md){target="_blank"}. ???+ tip - This is available for both [REST API (API Gateway, ALB)](./core/event_handler/api_gateway.md){target="_blank"} and [GraphQL API (AppSync)](./core/event_handler/appsync.md){target="_blank"}. + This is available for both [REST API (API Gateway, ALB)](../core/event_handler/api_gateway.md){target="_blank"} and [GraphQL API (AppSync)](../core/event_handler/appsync.md){target="_blank"}. Let's include Lambda Powertools as a dependency in `requirement.txt`, and use Event Handler to refactor our previous example. @@ -349,13 +349,13 @@ Let's include Lambda Powertools as a dependency in `requirement.txt`, and use Ev Use `sam build && sam local start-api` and try run it locally again. ???+ note - If you're coming from [Flask](https://flask.palletsprojects.com/en/2.0.x/){target="_blank"}, you will be familiar with this experience already. [Event Handler for API Gateway](./core/event_handler/api_gateway.md){target="_blank"} uses `APIGatewayRestResolver` to give a Flask-like experience while staying true to our tenet `Keep it lean`. + If you're coming from [Flask](https://flask.palletsprojects.com/en/2.0.x/){target="_blank"}, you will be familiar with this experience already. [Event Handler for API Gateway](../core/event_handler/api_gateway.md){target="_blank"} uses `APIGatewayRestResolver` to give a Flask-like experience while staying true to our tenet `Keep it lean`. We have added the route annotation as the decorator for our methods. It enables us to use the parameters passed in the request directly, and our responses are simply dictionaries. Lastly, we used `return app.resolve(event, context)` so Event Handler can resolve routes, inject the current request, handle serialization, route validation, etc. -From here, we could handle [404 routes](./core/event_handler/api_gateway.md#handling-not-found-routes){target="_blank"}, [error handling](./core/event_handler/api_gateway.md#http://127.0.0.1:8000/core/event_handler/api_gateway/#exception-handling){target="_blank"}, [access query strings, payload](./core/event_handler/api_gateway.md#http://127.0.0.1:8000/core/event_handler/api_gateway#accessing-request-details){target="_blank"}, etc. +From here, we could handle [404 routes](../core/event_handler/api_gateway.md#handling-not-found-routes){target="_blank"}, [error handling](../core/event_handler/api_gateway.md#exception-handling){target="_blank"}, [access query strings, payload](../core/event_handler/api_gateway.md#accessing-request-details){target="_blank"}, etc. ???+ tip @@ -449,7 +449,7 @@ We could start by creating a dictionary with Lambda context information or somet ???+ question "Surely this could be easier, right?" Yes! Powertools Logger to the rescue :-) -As we already have Lambda Powertools as a dependency, we can simply import [Logger](./core/logger.md){target="_blank"}. +As we already have Lambda Powertools as a dependency, we can simply import [Logger](../core/logger.md){target="_blank"}. ```python title="Refactoring with Lambda Powertools Logger" hl_lines="1 3 5 12 18 22" from aws_lambda_powertools import Logger @@ -482,7 +482,7 @@ Let's break this down: * **L5**: We add Lambda Powertools Logger; the boilerplate is now done for you. By default, we set `INFO` as the logging level if `LOG_LEVEL` env var isn't set. * **L22**: We use `logger.inject_lambda_context` decorator to inject key information from Lambda context into every log. -* **L22**: We also instruct Logger to use the incoming API Gateway Request ID as a [correlation id](./core/logger.md##set_correlation_id-method) automatically. +* **L22**: We also instruct Logger to use the incoming API Gateway Request ID as a [correlation id](../core/logger.md##set_correlation_id-method) automatically. * **L22**: Since we're in dev, we also use `log_event=True` to automatically log each incoming request for debugging. This can be also set via [environment variables](./index.md#environment-variables){target="_blank"}. @@ -506,7 +506,7 @@ This is how the logs would look like now: We can now search our logs by the request ID to find a specific operation. Additionally, we can also search our logs for function name, Lambda request ID, Lambda function ARN, find out whether an operation was a cold start, etc. -From here, we could [set specific keys](./core/logger.md#append_keys-method){target="_blank"} to add additional contextual information about a given operation, [log exceptions](./core/logger.md#logging-exceptions){target="_blank"} to easily enumerate them later, [sample debug logs](./core/logger.md#sampling-debug-logs){target="_blank"}, etc. +From here, we could [set specific keys](../core/logger.md#append_keys-method){target="_blank"} to add additional contextual information about a given operation, [log exceptions](../core/logger.md#logging-exceptions){target="_blank"} to easily enumerate them later, [sample debug logs](../core/logger.md#sampling-debug-logs){target="_blank"}, etc. By having structured logs like this, we can easily search and analyse them in [CloudWatch Logs Insight](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/AnalyzingLogData.html){target="_blank"}. @@ -531,7 +531,7 @@ It's a [two-step process](https://docs.aws.amazon.com/lambda/latest/dg/services- 1. Enable tracing in your Lambda function. 2. Instrument your application code. -Let's explore how we can instrument our code with [AWS X-Ray SDK](https://docs.aws.amazon.com/xray-sdk-for-python/latest/reference/index.html){target="_blank"}, and then simplify it with [Lambda Powertools Tracer](core/tracer.md){target="_blank"} feature. +Let's explore how we can instrument our code with [AWS X-Ray SDK](https://docs.aws.amazon.com/xray-sdk-for-python/latest/reference/index.html){target="_blank"}, and then simplify it with [Lambda Powertools Tracer](../core/tracer.md){target="_blank"} feature. === "app.py" @@ -719,7 +719,7 @@ If you choose any of the traces available, try opening the `handler` subsegment Cross-cutting concerns like filtering traces by Cold Start, including response as well as exceptions as tracing metadata can take a considerable amount of boilerplate. -We can simplify our previous patterns by using [Lambda Powertools Tracer](core/tracer.md){target="_blank"}; a thin wrapper on top of X-Ray SDK. +We can simplify our previous patterns by using [Lambda Powertools Tracer](../core/tracer.md){target="_blank"}; a thin wrapper on top of X-Ray SDK. ???+ note You can now safely remove `aws-xray-sdk` from `requirements.txt`; keep `aws-lambda-powertools` only. @@ -768,7 +768,7 @@ Another subtle difference is that you can now run your Lambda functions and unit Lambda Powertools optimizes for Lambda compute environment. As such, we add these and other common approaches to accelerate your development, so you don't worry about implementing every cross-cutting concern. ???+ tip - You can [opt-out some of these behaviours](./core/tracer/#advanced){target="_blank"} like disabling response capturing, explicitly patching only X modules, etc. + You can [opt-out some of these behaviours](../core/tracer/#advanced){target="_blank"} like disabling response capturing, explicitly patching only X modules, etc. Repeat the process of building, deploying, and invoking your application via the API endpoint. Within the [AWS X-Ray Console](https://console.aws.amazon.com/xray/home#/traces/){target="_blank"}, you should see a similar view: @@ -930,14 +930,14 @@ Within `template.yaml`, we add [CloudWatchPutMetricPolicy](https://docs.aws.amaz ### Simplifying with Metrics -[Lambda Powertools Metrics](./core/metrics.md){target="_blank} uses [Amazon CloudWatch Embedded Metric Format (EMF)](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format.html) to create custom metrics **asynchronously** via a native integration with Lambda. +[Lambda Powertools Metrics](../core/metrics.md){target="_blank} uses [Amazon CloudWatch Embedded Metric Format (EMF)](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format.html) to create custom metrics **asynchronously** via a native integration with Lambda. In general terms, EMF is a specification that expects metrics in a JSON payload within CloudWatch Logs. Lambda ingests all logs emitted by a given function into CloudWatch Logs. CloudWatch automatically looks up for log entries that follow the EMF format and transforms them into a CloudWatch metric. ???+ info If you are interested in the details of the EMF mechanism, follow [blog post](https://aws.amazon.com/blogs/mt/enhancing-workload-observability-using-amazon-cloudwatch-embedded-metric-format/){target="_blank"}. -Let's implement that using [Metrics](./core/metrics.md){target="_blank}: +Let's implement that using [Metrics](../core/metrics.md){target="_blank}: ```python title="Refactoring with Lambda Powertools Metrics" hl_lines="1 4 9 18 27 33" from aws_lambda_powertools import Logger, Tracer, Metrics @@ -986,7 +986,7 @@ That's a lot less boilerplate code! Let's break this down: * **L9**: We initialize `Metrics` with our service name (`APP`) and metrics namespace (`MyApp`), reducing the need to add the `service` dimension for every metric and setting the namespace later * **L18, 27**: We use `add_metric` similarly to our custom function, except we now have an enum `MetricCount` to help us understand which Metric Units we have at our disposal * **L33**: We use `@metrics.log_metrics` decorator to ensure that our metrics are aligned with the EMF output and validated before-hand, like in case we forget to set namespace, or accidentally use a metric unit as a string that doesn't exist in CloudWatch. -* **L33**: We also use `capture_cold_start_metric=True` so we don't have to handle that logic either. Note that [Metrics](./core/metrics.md){target="_blank"} does not publish a warm invocation metric (ColdStart=0) for cost reasons. As such, treat the absence (sparse metric) as a non-cold start invocation. +* **L33**: We also use `capture_cold_start_metric=True` so we don't have to handle that logic either. Note that [Metrics](../core/metrics.md){target="_blank"} does not publish a warm invocation metric (ColdStart=0) for cost reasons. As such, treat the absence (sparse metric) as a non-cold start invocation. Repeat the process of building, deploying, and invoking your application via the API endpoint a few times to generate metrics - [Artillery](https://www.artillery.io/){target="_blank"} and [K6.io](https://k6.io/open-source){target="_blank"} are quick ways to generate some load. Within [CloudWatch Metrics view](https://console.aws.amazon.com/cloudwatch/home#metricsV2:graph=~()){target="_blank}, you should see `MyApp` custom namespace with your custom metrics there and `SuccessfulGreetings` available to graph. @@ -1028,7 +1028,7 @@ If you're curious about how the EMF portion of your function logs look like, you We covered a lot of ground here and we only scratched the surface of the feature set available within Lambda Powertools. -When it comes to the observability features ([Tracer](./core/tracer.md){target="_blank"}, [Metrics](./core/metrics.md){target="_blank"}, [Logging](./core/logger.md){target="_blank"}), don't stop there! The goal here is to ensure you can ask arbitrary questions to assess your system's health; these features are only part of the wider story! +When it comes to the observability features ([Tracer](../core/tracer.md){target="_blank"}, [Metrics](../core/metrics.md){target="_blank"}, [Logging](../core/logger.md){target="_blank"}), don't stop there! The goal here is to ensure you can ask arbitrary questions to assess your system's health; these features are only part of the wider story! This requires a change in mindset to ensure operational excellence is part of the software development lifecycle. From 1b7f248b425a6776219f9ad2a786997346bbde19 Mon Sep 17 00:00:00 2001 From: Ivo Houbrechts Date: Fri, 4 Feb 2022 14:50:40 +0100 Subject: [PATCH 15/22] fix(logger): exclude source_logger in copy_config_to_registered_loggers (#1001) Co-authored-by: ihoubr --- aws_lambda_powertools/logging/utils.py | 2 +- tests/functional/test_logger_utils.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/aws_lambda_powertools/logging/utils.py b/aws_lambda_powertools/logging/utils.py index f0e39ddf8f0..41c2f2927b0 100644 --- a/aws_lambda_powertools/logging/utils.py +++ b/aws_lambda_powertools/logging/utils.py @@ -38,7 +38,7 @@ def copy_config_to_registered_loggers( if exclude: exclude.add(source_logger.name) else: - exclude = set(source_logger.name) + exclude = {source_logger.name} # Prepare loggers set if include: diff --git a/tests/functional/test_logger_utils.py b/tests/functional/test_logger_utils.py index 85f60db97f8..6d048b22dd6 100644 --- a/tests/functional/test_logger_utils.py +++ b/tests/functional/test_logger_utils.py @@ -182,3 +182,14 @@ def test_copy_config_to_ext_loggers_custom_log_level(stdout, logger, log_level): assert logger.level == log_level.WARNING.value assert log["message"] == msg assert log["level"] == log_level.WARNING.name + + +def test_copy_config_to_ext_loggers_should_not_break_append_keys(stdout, logger, log_level): + # GIVEN powertools logger initialized + powertools_logger = Logger(service=service_name(), level=log_level.INFO.value, stream=stdout) + + # WHEN configuration copied from powertools logger to ALL external loggers + utils.copy_config_to_registered_loggers(source_logger=powertools_logger) + + # THEN append_keys should not raise an exception + powertools_logger.append_keys(key="value") From b52b430153e0f12227f06f8b1321eed8aaaaf429 Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Fri, 4 Feb 2022 16:03:47 +0100 Subject: [PATCH 16/22] docs(event-handler): improve testing section for graphql (#996) --- docs/core/event_handler/appsync.md | 85 +++++++++++++++++++++++++----- 1 file changed, 73 insertions(+), 12 deletions(-) diff --git a/docs/core/event_handler/appsync.md b/docs/core/event_handler/appsync.md index a4d5f635886..b825b71124f 100644 --- a/docs/core/event_handler/appsync.md +++ b/docs/core/event_handler/appsync.md @@ -775,27 +775,88 @@ You can test your resolvers by passing a mocked or actual AppSync Lambda event t You can use either `app.resolve(event, context)` or simply `app(event, context)`. -Here's an example from our internal functional test. +Here's an example of how you can test your synchronous resolvers: -=== "test_direct_resolver.py" +=== "test_resolver.py" ```python - def test_direct_resolver(): - # Check whether we can handle an example appsync direct resolver - # load_event primarily deserialize the JSON event into a dict - mock_event = load_event("appSyncDirectResolver.json") + import json + import pytest + from pathlib import Path - app = AppSyncResolver() + from src.index import app # import the instance of AppSyncResolver from your code - @app.resolver(field_name="createSomething") - def create_something(id: str): - assert app.lambda_context == {} - return id + def test_direct_resolver(): + # Load mock event from a file + json_file_path = Path("appSyncDirectResolver.json") + with open(json_file_path) as json_file: + mock_event = json.load(json_file) # Call the implicit handler result = app(mock_event, {}) - assert result == "my identifier" + assert result == "created this value" + ``` + +=== "src/index.py" + + ```python + + from aws_lambda_powertools.event_handler import AppSyncResolver + + app = AppSyncResolver() + + @app.resolver(field_name="createSomething") + def create_something(): + return "created this value" + + ``` + +=== "appSyncDirectResolver.json" + + ```json + --8<-- "tests/events/appSyncDirectResolver.json" + ``` + +And an example for testing asynchronous resolvers. Note that this requires the `pytest-asyncio` package: + + +=== "test_async_resolver.py" + + ```python + import json + import pytest + from pathlib import Path + + from src.index import app # import the instance of AppSyncResolver from your code + + @pytest.mark.asyncio + async def test_direct_resolver(): + # Load mock event from a file + json_file_path = Path("appSyncDirectResolver.json") + with open(json_file_path) as json_file: + mock_event = json.load(json_file) + + # Call the implicit handler + result = await app(mock_event, {}) + + assert result == "created this value" + ``` + +=== "src/index.py" + + ```python + import asyncio + + from aws_lambda_powertools.event_handler import AppSyncResolver + + app = AppSyncResolver() + + @app.resolver(field_name="createSomething") + async def create_something_async(): + await asyncio.sleep(1) # Do async stuff + return "created this value" + ``` === "appSyncDirectResolver.json" From 207b3df7c249fcfb276961c1ebb99844d831125f Mon Sep 17 00:00:00 2001 From: Heitor Lessa Date: Fri, 4 Feb 2022 17:40:32 +0100 Subject: [PATCH 17/22] docs(theme): upgrade mkdocs-material to 8.x (#1002) --- .gitignore | 2 +- docs/index.md | 4 +- .../images/powertools_docs_thumbnail.png | Bin 72206 -> 0 bytes docs/overrides/main.html | 8 +++ mkdocs.yml | 9 ++- poetry.lock | 52 +++--------------- pyproject.toml | 2 +- 7 files changed, 27 insertions(+), 50 deletions(-) delete mode 100644 docs/overrides/assets/images/powertools_docs_thumbnail.png create mode 100644 docs/overrides/main.html diff --git a/.gitignore b/.gitignore index ce5e32bd3e1..c5d27c9789a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ - # Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode # Edit at https://www.gitignore.io/?templates=osx,linux,python,windows,pycharm,visualstudiocode @@ -304,3 +303,4 @@ node_modules api/ site/ !404.html +!docs/overrides/*.html diff --git a/docs/index.md b/docs/index.md index 6a07e80a315..9f2f9ffe55f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -454,5 +454,7 @@ As a best practice, AWS Lambda Powertools module logging statements are suppress ```python title="Powertools debug mode example" from aws_lambda_powertools.logging.logger import set_package_logger -set_package_logger() +set_package_logger() # (1) ``` + +1. :information_source: this will configure our `aws_lambda_powertools` logger with debug. diff --git a/docs/overrides/assets/images/powertools_docs_thumbnail.png b/docs/overrides/assets/images/powertools_docs_thumbnail.png deleted file mode 100644 index e87b8f198736a930ab06ad3309665a02dd46dc4d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 72206 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 O_>q=WkSG<`^ZS2?lI*nr diff --git a/docs/overrides/main.html b/docs/overrides/main.html new file mode 100644 index 00000000000..0af326afb24 --- /dev/null +++ b/docs/overrides/main.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block outdated %} + You're not viewing the latest version. + + Click here to go to latest. + +{% endblock %} diff --git a/mkdocs.yml b/mkdocs.yml index 7f8366675ea..8c4416b8c64 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -52,6 +52,8 @@ theme: - navigation.top - navigation.instant - navigation.indexes + - navigation.tracking + - content.code.annotate icon: repo: fontawesome/brands/github logo: media/aws-logo-light.svg @@ -60,10 +62,10 @@ theme: markdown_extensions: - admonition - - pymdownx.tabbed + - pymdownx.tabbed: + alternate_style: true - pymdownx.highlight: linenums: true - - pymdownx.superfences - pymdownx.details - pymdownx.snippets: base_path: "." @@ -75,7 +77,7 @@ markdown_extensions: - attr_list - pymdownx.emoji - pymdownx.inlinehilite - - attr_list + - pymdownx.superfences copyright: Copyright © 2021 Amazon Web Services @@ -92,3 +94,4 @@ extra_javascript: extra: version: provider: mike + default: latest diff --git a/poetry.lock b/poetry.lock index 402faa5b636..7b8ff0cb9b4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -573,11 +573,11 @@ mkdocs = ">=0.17" [[package]] name = "mkdocs-material" -version = "7.3.6" +version = "8.1.9" description = "A Material Design theme for MkDocs" category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.6" [package.dependencies] jinja2 = ">=2.11.1" @@ -729,7 +729,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pygments" -version = "2.10.0" +version = "2.11.2" description = "Pygments is a syntax highlighting package written in Python." category = "dev" optional = false @@ -1056,7 +1056,7 @@ pydantic = ["pydantic", "email-validator"] [metadata] lock-version = "1.1" python-versions = "^3.6.2" -content-hash = "9e9ce00400070ccabc8efa6babe951d01f2b2c8b04a504b9fb06cbbf81dc1c7b" +content-hash = "76f5787a913202119d952999ce182e225ea7c4fc5e977778a9bc77ac16822bb7" [metadata.files] atomicwrites = [ @@ -1262,28 +1262,12 @@ markdown = [ {file = "Markdown-3.3.5.tar.gz", hash = "sha256:26e9546bfbcde5fcd072bd8f612c9c1b6e2677cb8aadbdf65206674f46dde069"}, ] 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"}, @@ -1292,27 +1276,14 @@ markupsafe = [ {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"}, @@ -1322,12 +1293,6 @@ markupsafe = [ {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"}, @@ -1353,8 +1318,8 @@ mkdocs-git-revision-date-plugin = [ {file = "mkdocs_git_revision_date_plugin-0.3.1-py3-none-any.whl", hash = "sha256:8ae50b45eb75d07b150a69726041860801615aae5f4adbd6b1cf4d51abaa03d5"}, ] mkdocs-material = [ - {file = "mkdocs-material-7.3.6.tar.gz", hash = "sha256:1b1dbd8ef2508b358d93af55a5c5db3f141c95667fad802301ec621c40c7c217"}, - {file = "mkdocs_material-7.3.6-py2.py3-none-any.whl", hash = "sha256:1b6b3e9e09f922c2d7f1160fe15c8f43d4adc0d6fb81aa6ff0cbc7ef5b78ec75"}, + {file = "mkdocs-material-8.1.9.tar.gz", hash = "sha256:a15873a5e116bf4615af4fcedc85a0537492464365286cba50310d96fb066958"}, + {file = "mkdocs_material-8.1.9-py2.py3-none-any.whl", hash = "sha256:6feb433f29227b862418bd1009edeec2e52870770c476bf02840fc094b8823f2"}, ] mkdocs-material-extensions = [ {file = "mkdocs-material-extensions-1.0.3.tar.gz", hash = "sha256:bfd24dfdef7b41c312ede42648f9eb83476ea168ec163b613f9abd12bbfddba2"}, @@ -1399,7 +1364,6 @@ pbr = [ {file = "pbr-5.8.0.tar.gz", hash = "sha256:672d8ebee84921862110f23fcec2acea191ef58543d34dfe9ef3d9f13c31cddf"}, ] pdoc3 = [ - {file = "pdoc3-0.10.0-py3-none-any.whl", hash = "sha256:ba45d1ada1bd987427d2bf5cdec30b2631a3ff5fb01f6d0e77648a572ce6028b"}, {file = "pdoc3-0.10.0.tar.gz", hash = "sha256:5f22e7bcb969006738e1aa4219c75a32f34c2d62d46dc9d2fb2d3e0b0287e4b7"}, ] platformdirs = [ @@ -1460,8 +1424,8 @@ pyflakes = [ {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, ] pygments = [ - {file = "Pygments-2.10.0-py3-none-any.whl", hash = "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380"}, - {file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"}, + {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"}, + {file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"}, ] pymdown-extensions = [ {file = "pymdown-extensions-9.1.tar.gz", hash = "sha256:74247f2c80f1d9e3c7242abe1c16317da36c6f26c7ad4b8a7f457f0ec20f0365"}, diff --git a/pyproject.toml b/pyproject.toml index 422c88e627b..4a8b040ac3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,10 +50,10 @@ radon = "^5.1.0" xenon = "^0.8.0" flake8-eradicate = "^1.2.0" flake8-bugbear = "^22.1.11" -mkdocs-material = "^7.3.6" mkdocs-git-revision-date-plugin = "^0.3.1" mike = "^0.6.0" mypy = "^0.931" +mkdocs-material = "^8.1.9" [tool.poetry.extras] From 6869155236787a569fcbdff60b5cc03a3055cc04 Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Fri, 4 Feb 2022 08:44:21 -0800 Subject: [PATCH 18/22] chore(metrics): fix tests when warnings are disabled (#994) --- tests/functional/test_metrics.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/functional/test_metrics.py b/tests/functional/test_metrics.py index 98700f87c8b..9a8294749a7 100644 --- a/tests/functional/test_metrics.py +++ b/tests/functional/test_metrics.py @@ -552,6 +552,7 @@ def lambda_handler(evt, context): # THEN it should raise a warning instead of throwing an exception with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("default") lambda_handler({}, {}) assert len(w) == 1 assert str(w[-1].message) == "No metrics to publish, skipping" From 2cc1135f7dd7c85c61139306dcec6672fd0f5fc3 Mon Sep 17 00:00:00 2001 From: "Shane R. Spencer" <305301+whardier@users.noreply.github.com> Date: Tue, 8 Feb 2022 23:50:00 -0900 Subject: [PATCH 19/22] fix(batch): delete >10 messages in legacy sqs processor (#818) Co-authored-by: Michal Ploski --- aws_lambda_powertools/utilities/batch/sqs.py | 40 ++++++++++++++++---- tests/functional/test_utilities_batch.py | 40 +++++++++++++++++--- tests/unit/test_utilities_batch.py | 4 +- 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/aws_lambda_powertools/utilities/batch/sqs.py b/aws_lambda_powertools/utilities/batch/sqs.py index 411e400615d..ee6a960c129 100644 --- a/aws_lambda_powertools/utilities/batch/sqs.py +++ b/aws_lambda_powertools/utilities/batch/sqs.py @@ -4,8 +4,10 @@ Batch SQS utilities """ import logging +import math import sys -from typing import Callable, Dict, List, Optional, Tuple, cast +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Any, Callable, Dict, List, Optional, Tuple, cast import boto3 from botocore.config import Config @@ -73,6 +75,7 @@ def __init__( session = boto3_session or boto3.session.Session() self.client = session.client("sqs", config=config) self.suppress_exception = suppress_exception + self.max_message_batch = 10 super().__init__() @@ -120,23 +123,39 @@ def _prepare(self): self.success_messages.clear() self.fail_messages.clear() - def _clean(self): + 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 behaviour which deletes messages if Lambda responds successfully + # 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 + return None queue_url = self._get_queue_url() 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) - delete_message_response = None if entries_to_remove: - delete_message_response = self.client.delete_message_batch(QueueUrl=queue_url, Entries=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: @@ -147,6 +166,13 @@ def _clean(self): 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 diff --git a/tests/functional/test_utilities_batch.py b/tests/functional/test_utilities_batch.py index d32a044279b..2d9e6bab612 100644 --- a/tests/functional/test_utilities_batch.py +++ b/tests/functional/test_utilities_batch.py @@ -1,4 +1,5 @@ import json +import math from random import randint from typing import Callable, Dict, Optional from unittest.mock import patch @@ -166,20 +167,26 @@ def factory(item: Dict) -> str: return factory -def test_partial_sqs_processor_context_with_failure(sqs_event_factory, record_handler, partial_processor): +@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 + Test processor with one failing record and multiple processed records """ fail_record = sqs_event_factory("fail") - success_record = sqs_event_factory("success") + success_records = [sqs_event_factory("success") for i in range(0, success_messages_count)] - records = [fail_record, success_record] + records = [fail_record, *success_records] response = {"Successful": [{"Id": fail_record["messageId"]}], "Failed": []} with Stubber(partial_processor.client) as stubber: - stubber.add_response("delete_message_batch", response) - + 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() @@ -188,6 +195,27 @@ def test_partial_sqs_processor_context_with_failure(sqs_event_factory, record_ha 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 diff --git a/tests/unit/test_utilities_batch.py b/tests/unit/test_utilities_batch.py index c491f0829cb..57de0223404 100644 --- a/tests/unit/test_utilities_batch.py +++ b/tests/unit/test_utilities_batch.py @@ -128,12 +128,12 @@ def test_partial_sqs_clean(monkeypatch, mocker, partial_sqs_processor): 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 + 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 + QueueUrl=mocker.sentinel.queue_url, Entries=[mocker.sentinel.entries_to_clean] ) From 1f60f318f92a7276e2aa6d462c67b5f3141f0eae Mon Sep 17 00:00:00 2001 From: Tom McCarthy Date: Wed, 9 Feb 2022 16:43:56 +0100 Subject: [PATCH 20/22] docs(parameters): add testing your code section (#1017) * docs: Add testing section to docs for parameters utility * docs: Correct imported and monkeypatched paths --- docs/utilities/parameters.md | 73 ++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/docs/utilities/parameters.md b/docs/utilities/parameters.md index 395f24b5a76..d02a3feb73a 100644 --- a/docs/utilities/parameters.md +++ b/docs/utilities/parameters.md @@ -515,3 +515,76 @@ The **`config`** and **`boto3_session`** parameters enable you to pass in a cust value = ssm_provider.get("/my/parameter") ... ``` + +## Testing your code + +For unit testing your applications, you can mock the calls to the parameters utility to avoid calling AWS APIs. This +can be achieved in a number of ways - in this example, we use the [pytest monkeypatch fixture](https://docs.pytest.org/en/latest/how-to/monkeypatch.html) +to patch the `parameters.get_parameter` method: + +=== "tests.py" + ```python + from src import index + + def test_handler(monkeypatch): + + def mockreturn(name): + return "mock_value" + + monkeypatch.setattr(index.parameters, "get_parameter", mockreturn) + return_val = index.handler({}, {}) + assert return_val.get('message') == 'mock_value' + ``` + +=== "src/index.py" + ```python + from aws_lambda_powertools.utilities import parameters + + def handler(event, context): + # Retrieve a single parameter + value = parameters.get_parameter("my-parameter-name") + return {"message": value} + ``` + +If we need to use this pattern across multiple tests, we can avoid repetition by refactoring to use our own pytest fixture: + +=== "tests.py" + ```python + import pytest + + from src import index + + @pytest.fixture + def mock_parameter_response(monkeypatch): + def mockreturn(name): + return "mock_value" + + monkeypatch.setattr(index.parameters, "get_parameter", mockreturn) + + # Pass our fixture as an argument to all tests where we want to mock the get_parameter response + def test_handler(mock_parameter_response): + return_val = index.handler({}, {}) + assert return_val.get('message') == 'mock_value' + + ``` + +Alternatively, if we need more fully featured mocking (for example checking the arguments passed to `get_parameter`), we +can use [unittest.mock](https://docs.python.org/3/library/unittest.mock.html) from the python stdlib instead of pytest's `monkeypatch` fixture. In this example, we use the +[patch](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.patch) decorator to replace the `aws_lambda_powertools.utilities.parameters.get_parameter` function with a [MagicMock](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.MagicMock) +object named `get_parameter_mock`. + +=== "tests.py" + ```python + from unittest.mock import patch + from src import index + + # Replaces "aws_lambda_powertools.utilities.parameters.get_parameter" with a Mock object + @patch("aws_lambda_powertools.utilities.parameters.get_parameter") + def test_handler(get_parameter_mock): + get_parameter_mock.return_value = 'mock_value' + + return_val = index.handler({}, {}) + get_parameter_mock.assert_called_with("my-parameter-name") + assert return_val.get('message') == 'mock_value' + + ``` From 5a5bf19f529fd237c8801614cb161cbd031c5517 Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Wed, 9 Feb 2022 08:18:16 -0800 Subject: [PATCH 21/22] docs: fix syntax errors and line highlights (#1004) --- docs/core/event_handler/api_gateway.md | 26 ++++----- docs/core/event_handler/appsync.md | 75 +++++++++++++------------- docs/tutorial/index.md | 2 +- docs/utilities/feature_flags.md | 10 ++-- 4 files changed, 59 insertions(+), 54 deletions(-) diff --git a/docs/core/event_handler/api_gateway.md b/docs/core/event_handler/api_gateway.md index dccd9154afe..cc01b66600d 100644 --- a/docs/core/event_handler/api_gateway.md +++ b/docs/core/event_handler/api_gateway.md @@ -298,7 +298,7 @@ You can also nest paths as configured earlier in [our sample infrastructure](#re @app.get("//") @tracer.capture_method def get_message(message, name): - return {"message": f"{message}, {name}}"} + return {"message": f"{message}, {name}"} # You can continue to use other utilities just as before @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST) @@ -461,7 +461,7 @@ def get_hello_you(): payload = app.current_event.body name = app.current_event.get_query_string_value(name="name", default_value="") - return {"message": f"hello {name}}"} + return {"message": f"hello {name}"} def lambda_handler(event, context): return app.resolve(event, context) @@ -481,7 +481,7 @@ def get_hello_you(): headers_as_dict = app.current_event.headers name = app.current_event.get_header_value(name="X-Name", default_value="") - return {"message": f"hello {name}}"} + return {"message": f"hello {name}"} def lambda_handler(event, context): return app.resolve(event, context) @@ -768,7 +768,8 @@ You can use the `Response` class to have full control over the response, for exa === "app.py" - ```python hl_lines="10-14" + ```python hl_lines="11-16" + import json from aws_lambda_powertools.event_handler.api_gateway import APIGatewayRestResolver, Response app = APIGatewayRestResolver() @@ -778,10 +779,11 @@ You can use the `Response` class to have full control over the response, for exa payload = json.dumps({"message": "I'm a teapot"}) custom_headers = {"X-Custom": "X-Value"} - return Response(status_code=418, - content_type="application/json", - body=payload, - headers=custom_headers + return Response( + status_code=418, + content_type="application/json", + body=payload, + headers=custom_headers, ) def lambda_handler(event, context): @@ -974,7 +976,7 @@ def lambda_handler(event, context): You can instruct API Gateway handler to use a custom serializer to best suit your needs, for example take into account Enums when serializing. -```python hl_lines="19-20 24" title="Using a custom JSON serializer for responses" +```python hl_lines="21-22 26" title="Using a custom JSON serializer for responses" import json from enum import Enum from json import JSONEncoder @@ -1025,7 +1027,7 @@ Let's assume you have `app.py` as your Lambda function entrypoint and routes in We import **Router** instead of **APIGatewayRestResolver**; syntax wise is exactly the same. - ```python hl_lines="4 8 12 15 21" + ```python hl_lines="5 8 12 15 21" import itertools from typing import Dict @@ -1221,7 +1223,7 @@ This sample project contains a Users function with two distinct set of routes, ` === "src/users/main.py" - ```python hl_lines="9 15-16" + ```python hl_lines="8 14-15" from typing import Dict from aws_lambda_powertools import Logger, Tracer @@ -1356,7 +1358,7 @@ You can test your routes by passing a proxy event request where `path` and `http def test_lambda_handler(lambda_context): minimal_event = { "path": "/hello", - "httpMethod": "GET" + "httpMethod": "GET", "requestContext": { # correlation ID "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef" } diff --git a/docs/core/event_handler/appsync.md b/docs/core/event_handler/appsync.md index b825b71124f..19205289bfd 100644 --- a/docs/core/event_handler/appsync.md +++ b/docs/core/event_handler/appsync.md @@ -346,24 +346,24 @@ You can nest `app.resolver()` decorator multiple times when resolving fields wit === "nested_mappings.py" ```python hl_lines="4 8 10-12 18" - from aws_lambda_powertools import Logger, Tracer + from aws_lambda_powertools import Logger, Tracer - from aws_lambda_powertools.logging import correlation_paths - from aws_lambda_powertools.event_handler import AppSyncResolver + from aws_lambda_powertools.logging import correlation_paths + from aws_lambda_powertools.event_handler import AppSyncResolver - tracer = Tracer(service="sample_resolver") - logger = Logger(service="sample_resolver") - app = AppSyncResolver() + tracer = Tracer(service="sample_resolver") + logger = Logger(service="sample_resolver") + app = AppSyncResolver() - @app.resolver(field_name="listLocations") - @app.resolver(field_name="locations") - def get_locations(name: str, description: str = ""): - return name + description + @app.resolver(field_name="listLocations") + @app.resolver(field_name="locations") + def get_locations(name: str, description: str = ""): + return name + description - @logger.inject_lambda_context(correlation_id_path=correlation_paths.APPSYNC_RESOLVER) - @tracer.capture_lambda_handler - def lambda_handler(event, context): - return app.resolve(event, context) + @logger.inject_lambda_context(correlation_id_path=correlation_paths.APPSYNC_RESOLVER) + @tracer.capture_lambda_handler + def lambda_handler(event, context): + return app.resolve(event, context) ``` === "schema.graphql" @@ -396,7 +396,8 @@ You can nest `app.resolver()` decorator multiple times when resolving fields wit For Lambda Python3.8+ runtime, this utility supports async functions when you use in conjunction with `asyncio.run`. -```python hl_lines="4 8 10-12 20" title="Resolving GraphQL resolvers async" +```python hl_lines="5 9 11-13 21" title="Resolving GraphQL resolvers async" +import asyncio from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.logging import correlation_paths @@ -603,33 +604,34 @@ You can subclass `AppSyncResolverEvent` to bring your own set of methods to hand === "custom_model.py" - ```python hl_lines="11-14 19 26" - from aws_lambda_powertools import Logger, Tracer + ```python hl_lines="12-15 20 27" + from aws_lambda_powertools import Logger, Tracer - from aws_lambda_powertools.logging import correlation_paths - from aws_lambda_powertools.event_handler import AppSyncResolver + from aws_lambda_powertools.logging import correlation_paths + from aws_lambda_powertools.event_handler import AppSyncResolver + from aws_lambda_powertools.utilities.data_classes.appsync_resolver_event import AppSyncResolverEvent - tracer = Tracer(service="sample_resolver") - logger = Logger(service="sample_resolver") - app = AppSyncResolver() + tracer = Tracer(service="sample_resolver") + logger = Logger(service="sample_resolver") + app = AppSyncResolver() - class MyCustomModel(AppSyncResolverEvent): - @property - def country_viewer(self) -> str: - return self.request_headers.get("cloudfront-viewer-country") + class MyCustomModel(AppSyncResolverEvent): + @property + def country_viewer(self) -> str: + return self.request_headers.get("cloudfront-viewer-country") - @app.resolver(field_name="listLocations") - @app.resolver(field_name="locations") - def get_locations(name: str, description: str = ""): - if app.current_event.country_viewer == "US": - ... - return name + description + @app.resolver(field_name="listLocations") + @app.resolver(field_name="locations") + def get_locations(name: str, description: str = ""): + if app.current_event.country_viewer == "US": + ... + return name + description - @logger.inject_lambda_context(correlation_id_path=correlation_paths.APPSYNC_RESOLVER) - @tracer.capture_lambda_handler - def lambda_handler(event, context): - return app.resolve(event, context, data_model=MyCustomModel) + @logger.inject_lambda_context(correlation_id_path=correlation_paths.APPSYNC_RESOLVER) + @tracer.capture_lambda_handler + def lambda_handler(event, context): + return app.resolve(event, context, data_model=MyCustomModel) ``` === "schema.graphql" @@ -820,7 +822,6 @@ Here's an example of how you can test your synchronous resolvers: And an example for testing asynchronous resolvers. Note that this requires the `pytest-asyncio` package: - === "test_async_resolver.py" ```python diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md index 63c5ca38a13..5ea8ec7f2fa 100644 --- a/docs/tutorial/index.md +++ b/docs/tutorial/index.md @@ -822,7 +822,7 @@ Let's expand our application with custom metrics using AWS SDK to see how it wor def add_greeting_metric(service: str = "APP"): function_name = os.getenv("AWS_LAMBDA_FUNCTION_NAME", "undefined") service_dimension = {"Name": "service", "Value": service} - function_dimension = {"Name": "function_name": "Value": function_name} + function_dimension = {"Name": "function_name", "Value": function_name} is_cold_start = True global cold_start diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index d2590b93aa3..95efc5d051c 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -453,6 +453,8 @@ By default, we cache configuration retrieved from the Store for 5 seconds for pe You can override `max_age` parameter when instantiating the store. +=== "app.py" + ```python hl_lines="7" from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore @@ -677,11 +679,13 @@ Parameter | Default | Description **logger** | `logging.Logger` | Logger to use for debug. You can optionally supply an instance of Powertools Logger. -```python hl_lines="19-25" title="AppConfigStore sample" +```python hl_lines="21-27" title="AppConfigStore sample" from botocore.config import Config import jmespath +from aws_lambda_powertools.utilities.feature_flags import AppConfigStore + boto_config = Config(read_timeout=10, retries={"total_max_attempts": 2}) # Custom JMESPath functions @@ -715,9 +719,7 @@ You can unit test your feature flags locally and independently without setting u ???+ warning This excerpt relies on `pytest` and `pytest-mock` dependencies. -```python hl_lines="9-11" title="Unit testing feature flags" -from typing import Dict, List, Optional - +```python hl_lines="7-9" title="Unit testing feature flags" from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore, RuleAction From 40c8b71ca892c929813b626b895f3c0fb225052f Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Wed, 9 Feb 2022 17:24:09 +0100 Subject: [PATCH 22/22] chore: bump to 1.25.0 --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ff18157792..894851b3fb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file. This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format for changes and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 1.25.0 - 2022-02-09 + +### Bug Fixes + +* **apigateway:** remove indentation in debug_mode ([#987](https://github.com/awslabs/aws-lambda-powertools-python/issues/987)) +* **ci:** pr label regex for special chars in title +* **logger:** exclude source_logger in copy_config_to_registered_loggers ([#1001](https://github.com/awslabs/aws-lambda-powertools-python/issues/1001)) +* **logger:** test generates logfile + +### Documentation + +* **event-handler:** improve testing section for graphql ([#996](https://github.com/awslabs/aws-lambda-powertools-python/issues/996)) +* **layer:** update to 1.24.2 +* **theme:** upgrade mkdocs-material to 8.x ([#1002](https://github.com/awslabs/aws-lambda-powertools-python/issues/1002)) +* **tutorial:** fix broken internal links ([#1000](https://github.com/awslabs/aws-lambda-powertools-python/issues/1000)) +* **general:** fix syntax errors and line highlights ([#1004](https://github.com/awslabs/aws-lambda-powertools-python/pull/1004)) + +### Features + +* **event-handler:** new resolvers to fix current_event typing ([#978](https://github.com/awslabs/aws-lambda-powertools-python/issues/978)) +* **logger:** log_event support event data classes (e.g. S3Event) ([#984](https://github.com/awslabs/aws-lambda-powertools-python/issues/984)) +* **mypy:** complete mypy support for the entire codebase ([#943](https://github.com/awslabs/aws-lambda-powertools-python/issues/943)) + +### Maintenance + +* **deps-dev:** bump flake8-bugbear from 21.11.29 to 22.1.11 ([#955](https://github.com/awslabs/aws-lambda-powertools-python/issues/955)) +* **metrics:** fix tests when warnings are disabled ([#994](https://github.com/awslabs/aws-lambda-powertools-python/issues/994)) + ## 1.24.2 - 2022-01-21 ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 4a8b040ac3d..82fb230f835 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "aws_lambda_powertools" -version = "1.24.2" +version = "1.25.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"]