From dd61320909a83a789152442897f9397e03306ebd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Jul 2021 19:50:55 +0000 Subject: [PATCH 01/35] chore(deps-dev): bump flake8-black from 0.2.1 to 0.2.3 (#541) Bumps [flake8-black](https://github.com/peterjc/flake8-black) from 0.2.1 to 0.2.3.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=flake8-black&package-manager=pip&previous-version=0.2.1&new-version=0.2.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--- poetry.lock | 9 +++++---- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index d15b11daae8..41b88208acd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -228,7 +228,7 @@ pyflakes = ">=2.3.0,<2.4.0" [[package]] name = "flake8-black" -version = "0.2.1" +version = "0.2.3" description = "flake8 plugin to call black as a code style validator" category = "dev" optional = false @@ -237,6 +237,7 @@ python-versions = "*" [package.dependencies] black = "*" flake8 = ">=3.0.0" +toml = "*" [[package]] name = "flake8-bugbear" @@ -1084,7 +1085,7 @@ pydantic = ["pydantic", "email-validator"] [metadata] lock-version = "1.1" python-versions = "^3.6.1" -content-hash = "1e91beb4537c7042746d638f86154a664cbb840c1a43b2e902586a1dc7b0b9c2" +content-hash = "ab05f71d499aa5effb55ff6f8435317a8f5053f4d00dcba841c910dc80a57ee5" [metadata.files] appdirs = [ @@ -1212,8 +1213,8 @@ flake8 = [ {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, ] flake8-black = [ - {file = "flake8-black-0.2.1.tar.gz", hash = "sha256:f26651bc10db786c03f4093414f7c9ea982ed8a244cec323c984feeffdf4c118"}, - {file = "flake8_black-0.2.1-py3-none-any.whl", hash = "sha256:941514149cb8b489cb17a4bb1cf18d84375db3b34381bb018de83509437931a0"}, + {file = "flake8-black-0.2.3.tar.gz", hash = "sha256:c199844bc1b559d91195ebe8620216f21ed67f2cc1ff6884294c91a0d2492684"}, + {file = "flake8_black-0.2.3-py3-none-any.whl", hash = "sha256:cc080ba5b3773b69ba102b6617a00cc4ecbad8914109690cfda4d565ea435d96"}, ] flake8-bugbear = [ {file = "flake8-bugbear-21.4.3.tar.gz", hash = "sha256:2346c81f889955b39e4a368eb7d508de723d9de05716c287dc860a4073dc57e7"}, diff --git a/pyproject.toml b/pyproject.toml index 12d3c68376f..e7d5d987f04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ coverage = {extras = ["toml"], version = "^5.5"} pytest = "^6.2.2" black = "^20.8b1" flake8 = "^3.9.0" -flake8-black = "^0.2.1" +flake8-black = "^0.2.3" flake8-builtins = "^1.5.3" flake8-comprehensions = "^3.4.0" flake8-debugger = "^4.0.0" From cf001a68f6c14652c63206dee7579f2b345cebf0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Jul 2021 20:06:24 +0000 Subject: [PATCH 02/35] chore(deps-dev): bump mkdocs-material from 7.1.11 to 7.2.0 (#551) Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 7.1.11 to 7.2.0.
Release notes

Sourced from mkdocs-material's releases.

mkdocs-material-7.2.0

Changelog

Sourced from mkdocs-material's changelog.

7.2.0 _ July 21, 2021

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=mkdocs-material&package-manager=pip&previous-version=7.1.11&new-version=7.2.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 41b88208acd..e06f95e7a14 100644 --- a/poetry.lock +++ b/poetry.lock @@ -593,7 +593,7 @@ mkdocs = ">=0.17" [[package]] name = "mkdocs-material" -version = "7.1.11" +version = "7.2.0" description = "A Material Design theme for MkDocs" category = "dev" optional = false @@ -1085,7 +1085,7 @@ pydantic = ["pydantic", "email-validator"] [metadata] lock-version = "1.1" python-versions = "^3.6.1" -content-hash = "ab05f71d499aa5effb55ff6f8435317a8f5053f4d00dcba841c910dc80a57ee5" +content-hash = "1e7ad25e6668d6f1a1dec558cb3325e79bdc79ee62cc675028b5f9e31d4e4b5a" [metadata.files] appdirs = [ @@ -1362,8 +1362,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.1.11.tar.gz", hash = "sha256:cad3a693f1c28823370578e5b9c9aea418bddae0c7348ab734537391e9f2b1e5"}, - {file = "mkdocs_material-7.1.11-py2.py3-none-any.whl", hash = "sha256:0bcfb788020b72b0ebf5b2722ddf89534acaed8c3feb39c2d6dda239b49dec45"}, + {file = "mkdocs-material-7.2.0.tar.gz", hash = "sha256:9f43c5874e119b312a6f369ef363815c11f182b5cdeff4a3426615ebc4664ace"}, + {file = "mkdocs_material-7.2.0-py2.py3-none-any.whl", hash = "sha256:8b3750857e168a9ca20be34890791817090b016248a39be45069fab5343f1dc0"}, ] mkdocs-material-extensions = [ {file = "mkdocs-material-extensions-1.0.1.tar.gz", hash = "sha256:6947fb7f5e4291e3c61405bad3539d81e0b3cd62ae0d66ced018128af509c68f"}, diff --git a/pyproject.toml b/pyproject.toml index e7d5d987f04..b35cda76e1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ radon = "^4.5.0" xenon = "^0.7.3" flake8-eradicate = "^1.1.0" flake8-bugbear = "^21.3.2" -mkdocs-material = "^7.1.11" +mkdocs-material = "^7.2.0" mkdocs-git-revision-date-plugin = "^0.3.1" mike = "^0.6.0" mypy = "^0.910" From 62409575f4480bac8044ae0d59869c6b9daa5fe2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jul 2021 20:45:12 +0000 Subject: [PATCH 03/35] chore(deps-dev): bump mkdocs-material from 7.2.0 to 7.2.1 (#566) Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 7.2.0 to 7.2.1.
Release notes

Sourced from mkdocs-material's releases.

mkdocs-material-7.2.1

Changelog

Sourced from mkdocs-material's changelog.


template: overrides/main.html

Changelog

Material for MkDocs

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=mkdocs-material&package-manager=pip&previous-version=7.2.0&new-version=7.2.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index e06f95e7a14..375ad72f9b1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -593,7 +593,7 @@ mkdocs = ">=0.17" [[package]] name = "mkdocs-material" -version = "7.2.0" +version = "7.2.1" description = "A Material Design theme for MkDocs" category = "dev" optional = false @@ -1085,7 +1085,7 @@ pydantic = ["pydantic", "email-validator"] [metadata] lock-version = "1.1" python-versions = "^3.6.1" -content-hash = "1e7ad25e6668d6f1a1dec558cb3325e79bdc79ee62cc675028b5f9e31d4e4b5a" +content-hash = "3120b961edcc0b3c2e07e82a27edf732a978ff053b515ba9519f0282f9a8a5f3" [metadata.files] appdirs = [ @@ -1362,8 +1362,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.2.0.tar.gz", hash = "sha256:9f43c5874e119b312a6f369ef363815c11f182b5cdeff4a3426615ebc4664ace"}, - {file = "mkdocs_material-7.2.0-py2.py3-none-any.whl", hash = "sha256:8b3750857e168a9ca20be34890791817090b016248a39be45069fab5343f1dc0"}, + {file = "mkdocs-material-7.2.1.tar.gz", hash = "sha256:3cd33ea5aac6ed693edea9fd93862513f996cfe522b72d759598b50286c4fe26"}, + {file = "mkdocs_material-7.2.1-py2.py3-none-any.whl", hash = "sha256:16de6cd9f87da3bcd18d4585150ad5485723878450aa331b3e8859d002989083"}, ] mkdocs-material-extensions = [ {file = "mkdocs-material-extensions-1.0.1.tar.gz", hash = "sha256:6947fb7f5e4291e3c61405bad3539d81e0b3cd62ae0d66ced018128af509c68f"}, diff --git a/pyproject.toml b/pyproject.toml index b35cda76e1d..4173670753e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ radon = "^4.5.0" xenon = "^0.7.3" flake8-eradicate = "^1.1.0" flake8-bugbear = "^21.3.2" -mkdocs-material = "^7.2.0" +mkdocs-material = "^7.2.1" mkdocs-git-revision-date-plugin = "^0.3.1" mike = "^0.6.0" mypy = "^0.910" From 1135314b77f7a9723dd61e73703bcd46727d2ae1 Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Mon, 26 Jul 2021 22:02:32 -0700 Subject: [PATCH 04/35] feat(data-classes): decode json_body if based64 encoded (#560) --- .../utilities/data_classes/common.py | 2 +- tests/functional/test_data_classes.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/aws_lambda_powertools/utilities/data_classes/common.py b/aws_lambda_powertools/utilities/data_classes/common.py index 66c8f15324f..fbf0502125e 100644 --- a/aws_lambda_powertools/utilities/data_classes/common.py +++ b/aws_lambda_powertools/utilities/data_classes/common.py @@ -65,7 +65,7 @@ def body(self) -> Optional[str]: @property def json_body(self) -> Any: """Parses the submitted body as json""" - return json.loads(self["body"]) + return json.loads(self.decoded_body) @property def decoded_body(self) -> str: diff --git a/tests/functional/test_data_classes.py b/tests/functional/test_data_classes.py index cbbaf834379..f9bb1fdef73 100644 --- a/tests/functional/test_data_classes.py +++ b/tests/functional/test_data_classes.py @@ -1037,6 +1037,18 @@ def test_base_proxy_event_decode_body_encoded_true(): assert event.decoded_body == data +def test_base_proxy_event_json_body_with_base64_encoded_data(): + # GIVEN a base64 encoded json body + data = {"message": "Foo"} + data_str = json.dumps(data) + encoded_data = base64.b64encode(data_str.encode()).decode() + event = BaseProxyEvent({"body": encoded_data, "isBase64Encoded": True}) + + # WHEN calling json_body + # THEN then base64 decode and json load + assert event.json_body == data + + def test_kinesis_stream_event(): event = KinesisStreamEvent(load_event("kinesisStreamEvent.json")) From f3ee8d1fc1d0893c2ea33c4dcc091d22f53f3343 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Jul 2021 20:52:05 +0000 Subject: [PATCH 05/35] chore(deps-dev): bump isort from 5.9.2 to 5.9.3 (#574) Bumps [isort](https://github.com/pycqa/isort) from 5.9.2 to 5.9.3.
Release notes

Sourced from isort's releases.

5.9.3 July 28 2021

  • Improved text of skipped file message to mention gitignore feature.
  • Made all exceptions pickleable.
  • Fixed #1779: Pylama integration ignores pylama specific isort config overrides.
  • Fixed #1781: --from-first CLI flag shouldn't take any arguments.
  • Fixed #1792: Sorting literals sometimes ignored when placed on first few lines of file.
  • Fixed #1777: extend_skip is not honored wit a git submodule when skip_gitignore=true.
Changelog

Sourced from isort's changelog.

5.9.3 July 28 2021

  • Improved text of skipped file message to mention gitignore feature.
  • Made all exceptions pickleable.
  • Fixed #1779: Pylama integration ignores pylama specific isort config overrides.
  • Fixed #1781: --from-first CLI flag shouldn't take any arguments.
  • Fixed #1792: Sorting literals sometimes ignored when placed on first few lines of file.
  • Fixed #1777: extend_skip is not honored wit a git submodule when skip_gitignore=true.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=isort&package-manager=pip&previous-version=5.9.2&new-version=5.9.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 375ad72f9b1..69da74fd667 100644 --- a/poetry.lock +++ b/poetry.lock @@ -414,7 +414,7 @@ python-versions = "*" [[package]] name = "isort" -version = "5.9.2" +version = "5.9.3" description = "A Python utility / library to sort Python imports." category = "dev" optional = false @@ -1085,7 +1085,7 @@ pydantic = ["pydantic", "email-validator"] [metadata] lock-version = "1.1" python-versions = "^3.6.1" -content-hash = "3120b961edcc0b3c2e07e82a27edf732a978ff053b515ba9519f0282f9a8a5f3" +content-hash = "458932b6d26286e05b821686641db661cbafacff296de24ed896c1f3b1b1a68e" [metadata.files] appdirs = [ @@ -1275,8 +1275,8 @@ iniconfig = [ {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] isort = [ - {file = "isort-5.9.2-py3-none-any.whl", hash = "sha256:eed17b53c3e7912425579853d078a0832820f023191561fcee9d7cae424e0813"}, - {file = "isort-5.9.2.tar.gz", hash = "sha256:f65ce5bd4cbc6abdfbe29afc2f0245538ab358c14590912df638033f157d555e"}, + {file = "isort-5.9.3-py3-none-any.whl", hash = "sha256:e17d6e2b81095c9db0a03a8025a957f334d6ea30b26f9ec70805411e5c7c81f2"}, + {file = "isort-5.9.3.tar.gz", hash = "sha256:9c2ea1e62d871267b78307fe511c0838ba0da28698c5732d54e2790bf3ba9899"}, ] jinja2 = [ {file = "Jinja2-3.0.1-py3-none-any.whl", hash = "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4"}, diff --git a/pyproject.toml b/pyproject.toml index 4173670753e..b5e9a696f76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ flake8-debugger = "^4.0.0" flake8-fixme = "^1.1.1" flake8-isort = "^4.0.0" flake8-variables-names = "^0.0.4" -isort = "^5.9.2" +isort = "^5.9.3" pytest-cov = "^2.12.1" pytest-mock = "^3.5.1" pdoc3 = "^0.9.2" From 0df5c572f26e737dedfaddd69cf9183020360a07 Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Fri, 30 Jul 2021 01:15:41 -0700 Subject: [PATCH 06/35] fix(parser): apigw wss validation check_message_id; housekeeping (#553) --- aws_lambda_powertools/tracing/tracer.py | 4 +- .../feature_toggles/appconfig_fetcher.py | 16 ++++--- .../utilities/idempotency/idempotency.py | 5 +-- .../idempotency/persistence/dynamodb.py | 2 +- .../utilities/parameters/dynamodb.py | 7 +-- .../utilities/parser/models/apigw.py | 14 +++--- .../idempotency/test_idempotency.py | 1 - tests/functional/parser/test_apigw.py | 44 +++++++++++++++++++ 8 files changed, 66 insertions(+), 27 deletions(-) diff --git a/aws_lambda_powertools/tracing/tracer.py b/aws_lambda_powertools/tracing/tracer.py index 5709b1956c2..bd4244b3171 100644 --- a/aws_lambda_powertools/tracing/tracer.py +++ b/aws_lambda_powertools/tracing/tracer.py @@ -335,7 +335,7 @@ def decorate(event, context, **kwargs): # see #465 @overload def capture_method(self, method: "AnyCallableT") -> "AnyCallableT": - ... + ... # pragma: no cover @overload def capture_method( @@ -344,7 +344,7 @@ def capture_method( capture_response: Optional[bool] = None, capture_error: Optional[bool] = None, ) -> Callable[["AnyCallableT"], "AnyCallableT"]: - ... + ... # pragma: no cover def capture_method( self, diff --git a/aws_lambda_powertools/utilities/feature_toggles/appconfig_fetcher.py b/aws_lambda_powertools/utilities/feature_toggles/appconfig_fetcher.py index ae7c6c90e51..3501edfd0d3 100644 --- a/aws_lambda_powertools/utilities/feature_toggles/appconfig_fetcher.py +++ b/aws_lambda_powertools/utilities/feature_toggles/appconfig_fetcher.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, cast from botocore.config import Config @@ -56,11 +56,15 @@ def get_json_configuration(self) -> Dict[str, Any]: parsed JSON dictionary """ try: - return self._conf_store.get( - name=self.configuration_name, - transform=TRANSFORM_TYPE, - max_age=self._cache_seconds, - ) # parse result conf as JSON, keep in cache for self.max_age seconds + # parse result conf as JSON, keep in cache for self.max_age seconds + return cast( + dict, + self._conf_store.get( + name=self.configuration_name, + transform=TRANSFORM_TYPE, + max_age=self._cache_seconds, + ), + ) except (GetParameterError, TransformParameterError) as exc: error_str = f"unable to get AWS AppConfig configuration file, exception={str(exc)}" self._logger.error(error_str) diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py index c2bcc62fd69..fc1d4d47d55 100644 --- a/aws_lambda_powertools/utilities/idempotency/idempotency.py +++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py @@ -78,9 +78,7 @@ def idempotent( try: return idempotency_handler.handle() except IdempotencyInconsistentStateError: - if i < max_handler_retries: - continue - else: + if i == max_handler_retries: # Allow the exception to bubble up after max retries exceeded raise @@ -117,7 +115,6 @@ def __init__( self.context = context self.event = event self.lambda_handler = lambda_handler - self.max_handler_retries = 2 def handle(self) -> Any: """ diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py index dc00334277e..ae3a1be490f 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py @@ -154,7 +154,7 @@ def _update_record(self, data_record: DataRecord): "ExpressionAttributeNames": expression_attr_names, } - self.table.update_item(**kwargs) + self.table.update_item(**kwargs) # type: ignore def _delete_record(self, data_record: DataRecord) -> None: logger.debug(f"Deleting record for idempotency key: {data_record.idempotency_key}") diff --git a/aws_lambda_powertools/utilities/parameters/dynamodb.py b/aws_lambda_powertools/utilities/parameters/dynamodb.py index 5edae643ec0..39bd1a8d6b7 100644 --- a/aws_lambda_powertools/utilities/parameters/dynamodb.py +++ b/aws_lambda_powertools/utilities/parameters/dynamodb.py @@ -3,7 +3,7 @@ """ -from typing import Any, Dict, Optional +from typing import Dict, Optional import boto3 from boto3.dynamodb.conditions import Key @@ -141,11 +141,6 @@ class DynamoDBProvider(BaseProvider): c Parameter value c """ - table: Any = None - key_attr = None - sort_attr = None - value_attr = None - def __init__( self, table_name: str, diff --git a/aws_lambda_powertools/utilities/parser/models/apigw.py b/aws_lambda_powertools/utilities/parser/models/apigw.py index 4de8ee96cc5..ed975e88e81 100644 --- a/aws_lambda_powertools/utilities/parser/models/apigw.py +++ b/aws_lambda_powertools/utilities/parser/models/apigw.py @@ -68,6 +68,13 @@ class APIGatewayEventRequestContext(BaseModel): routeKey: Optional[str] operationName: Optional[str] + @root_validator + def check_message_id(cls, values): + message_id, event_type = values.get("messageId"), values.get("eventType") + if message_id is not None and event_type != "MESSAGE": + raise TypeError("messageId is available only when the `eventType` is `MESSAGE`") + return values + class APIGatewayProxyEventModel(BaseModel): version: Optional[str] @@ -83,10 +90,3 @@ class APIGatewayProxyEventModel(BaseModel): stageVariables: Optional[Dict[str, str]] isBase64Encoded: bool body: str - - @root_validator() - def check_message_id(cls, values): - message_id, event_type = values.get("messageId"), values.get("eventType") - if message_id is not None and event_type != "MESSAGE": - raise TypeError("messageId is available only when the `eventType` is `MESSAGE`") - return values diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index 0cf19ab9de0..0ecc84b7f9c 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -395,7 +395,6 @@ def test_idempotent_lambda_expired_during_request( lambda_apigw_event, timestamp_expired, lambda_response, - expected_params_update_item, hashed_idempotency_key, lambda_context, ): diff --git a/tests/functional/parser/test_apigw.py b/tests/functional/parser/test_apigw.py index fc679d5dc37..d657a0dbe4d 100644 --- a/tests/functional/parser/test_apigw.py +++ b/tests/functional/parser/test_apigw.py @@ -1,3 +1,6 @@ +import pytest +from pydantic import ValidationError + from aws_lambda_powertools.utilities.parser import envelopes, event_parser from aws_lambda_powertools.utilities.parser.models import APIGatewayProxyEventModel from aws_lambda_powertools.utilities.typing import LambdaContext @@ -100,3 +103,44 @@ def test_apigw_event(): assert request_context.operationName is None assert identity.apiKey is None assert identity.apiKeyId is None + + +def test_apigw_event_with_invalid_websocket_request(): + # GIVEN an event with an eventType != MESSAGE and has a messageId + event = { + "resource": "/", + "path": "/", + "httpMethod": "GET", + "headers": {}, + "multiValueHeaders": {}, + "isBase64Encoded": False, + "body": "Foo!", + "requestContext": { + "accountId": "1234", + "apiId": "myApi", + "httpMethod": "GET", + "identity": { + "sourceIp": "127.0.0.1", + }, + "path": "/", + "protocol": "Https", + "requestId": "1234", + "requestTime": "2018-09-07T16:20:46Z", + "requestTimeEpoch": 1536992496000, + "resourcePath": "/", + "stage": "test", + "eventType": "DISCONNECT", + "messageId": "messageId", + }, + } + + # WHEN calling event_parser with APIGatewayProxyEventModel + with pytest.raises(ValidationError) as err: + handle_apigw_event(event, LambdaContext()) + + # THEN raise TypeError for invalid event + errors = err.value.errors() + assert len(errors) == 1 + expected_msg = "messageId is available only when the `eventType` is `MESSAGE`" + assert errors[0]["msg"] == expected_msg + assert expected_msg in str(err.value) From dfe42b116bf933aa882159539e842e1f9851bb56 Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Fri, 30 Jul 2021 01:20:21 -0700 Subject: [PATCH 07/35] feat(params): expose high level max_age, raise_on_transform_error (#567) --- .../utilities/parameters/appconfig.py | 9 ++++-- .../utilities/parameters/secrets.py | 14 +++++++-- .../utilities/parameters/ssm.py | 29 +++++++++++++++++-- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/aws_lambda_powertools/utilities/parameters/appconfig.py b/aws_lambda_powertools/utilities/parameters/appconfig.py index 63a8415f1ec..4a400aa7789 100644 --- a/aws_lambda_powertools/utilities/parameters/appconfig.py +++ b/aws_lambda_powertools/utilities/parameters/appconfig.py @@ -12,7 +12,7 @@ from ...shared import constants from ...shared.functions import resolve_env_var_choice -from .base import DEFAULT_PROVIDERS, BaseProvider +from .base import DEFAULT_MAX_AGE_SECS, DEFAULT_PROVIDERS, BaseProvider CLIENT_ID = str(uuid4()) @@ -110,6 +110,7 @@ def get_app_config( application: Optional[str] = None, transform: Optional[str] = None, force_fetch: bool = False, + max_age: int = DEFAULT_MAX_AGE_SECS, **sdk_options ) -> Union[str, list, dict, bytes]: """ @@ -127,6 +128,8 @@ def get_app_config( Transforms the content from a JSON object ('json') or base64 binary string ('binary') force_fetch: bool, optional Force update even before a cached item has expired, defaults to False + max_age: int + Maximum age of the cached value sdk_options: dict, optional Dictionary of options that will be passed to the Parameter Store get_parameter API call @@ -165,4 +168,6 @@ def get_app_config( sdk_options["ClientId"] = CLIENT_ID - return DEFAULT_PROVIDERS["appconfig"].get(name, transform=transform, force_fetch=force_fetch, **sdk_options) + return DEFAULT_PROVIDERS["appconfig"].get( + name, max_age=max_age, transform=transform, force_fetch=force_fetch, **sdk_options + ) diff --git a/aws_lambda_powertools/utilities/parameters/secrets.py b/aws_lambda_powertools/utilities/parameters/secrets.py index 6b7ea21fdf6..5699876d90e 100644 --- a/aws_lambda_powertools/utilities/parameters/secrets.py +++ b/aws_lambda_powertools/utilities/parameters/secrets.py @@ -8,7 +8,7 @@ import boto3 from botocore.config import Config -from .base import DEFAULT_PROVIDERS, BaseProvider +from .base import DEFAULT_MAX_AGE_SECS, DEFAULT_PROVIDERS, BaseProvider class SecretsProvider(BaseProvider): @@ -94,7 +94,11 @@ def _get_multiple(self, path: str, **sdk_options) -> Dict[str, str]: def get_secret( - name: str, transform: Optional[str] = None, force_fetch: bool = False, **sdk_options + name: str, + transform: Optional[str] = None, + force_fetch: bool = False, + max_age: int = DEFAULT_MAX_AGE_SECS, + **sdk_options ) -> Union[str, dict, bytes]: """ Retrieve a parameter value from AWS Secrets Manager @@ -107,6 +111,8 @@ def get_secret( Transforms the content from a JSON object ('json') or base64 binary string ('binary') force_fetch: bool, optional Force update even before a cached item has expired, defaults to False + max_age: int + Maximum age of the cached value sdk_options: dict, optional Dictionary of options that will be passed to the get_secret_value call @@ -143,4 +149,6 @@ def get_secret( if "secrets" not in DEFAULT_PROVIDERS: DEFAULT_PROVIDERS["secrets"] = SecretsProvider() - return DEFAULT_PROVIDERS["secrets"].get(name, transform=transform, force_fetch=force_fetch, **sdk_options) + return DEFAULT_PROVIDERS["secrets"].get( + name, max_age=max_age, transform=transform, force_fetch=force_fetch, **sdk_options + ) diff --git a/aws_lambda_powertools/utilities/parameters/ssm.py b/aws_lambda_powertools/utilities/parameters/ssm.py index 4bbef8bfc15..2a16ad91f08 100644 --- a/aws_lambda_powertools/utilities/parameters/ssm.py +++ b/aws_lambda_powertools/utilities/parameters/ssm.py @@ -186,7 +186,12 @@ def _get_multiple(self, path: str, decrypt: bool = False, recursive: bool = Fals def get_parameter( - name: str, transform: Optional[str] = None, decrypt: bool = False, force_fetch: bool = False, **sdk_options + name: str, + transform: Optional[str] = None, + decrypt: bool = False, + force_fetch: bool = False, + max_age: int = DEFAULT_MAX_AGE_SECS, + **sdk_options ) -> Union[str, list, dict, bytes]: """ Retrieve a parameter value from AWS Systems Manager (SSM) Parameter Store @@ -201,6 +206,8 @@ def get_parameter( If the parameter values should be decrypted force_fetch: bool, optional Force update even before a cached item has expired, defaults to False + max_age: int + Maximum age of the cached value sdk_options: dict, optional Dictionary of options that will be passed to the Parameter Store get_parameter API call @@ -240,7 +247,9 @@ def get_parameter( # Add to `decrypt` sdk_options to we can have an explicit option for this sdk_options["decrypt"] = decrypt - return DEFAULT_PROVIDERS["ssm"].get(name, transform=transform, force_fetch=force_fetch, **sdk_options) + return DEFAULT_PROVIDERS["ssm"].get( + name, max_age=max_age, transform=transform, force_fetch=force_fetch, **sdk_options + ) def get_parameters( @@ -249,6 +258,8 @@ def get_parameters( recursive: bool = True, decrypt: bool = False, force_fetch: bool = False, + max_age: int = DEFAULT_MAX_AGE_SECS, + raise_on_transform_error: bool = False, **sdk_options ) -> Union[Dict[str, str], Dict[str, dict], Dict[str, bytes]]: """ @@ -266,6 +277,11 @@ def get_parameters( If the parameter values should be decrypted force_fetch: bool, optional Force update even before a cached item has expired, defaults to False + max_age: int + Maximum age of the cached value + raise_on_transform_error: bool, optional + Raises an exception if any transform fails, otherwise this will + return a None value for each transform that failed sdk_options: dict, optional Dictionary of options that will be passed to the Parameter Store get_parameters_by_path API call @@ -305,4 +321,11 @@ def get_parameters( sdk_options["recursive"] = recursive sdk_options["decrypt"] = decrypt - return DEFAULT_PROVIDERS["ssm"].get_multiple(path, transform=transform, force_fetch=force_fetch, **sdk_options) + return DEFAULT_PROVIDERS["ssm"].get_multiple( + path, + max_age=max_age, + transform=transform, + raise_on_transform_error=raise_on_transform_error, + force_fetch=force_fetch, + **sdk_options + ) From 8fcb23b12034ed9390fdc752ef566a1b5d80cdf8 Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Fri, 30 Jul 2021 08:44:36 -0700 Subject: [PATCH 08/35] feat(api-gateway): add support for custom serializer (#568) Co-authored-by: Heitor Lessa --- .../event_handler/api_gateway.py | 15 ++++--- .../event_handler/test_api_gateway.py | 41 +++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 44d3f2b07de..7bf364695da 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -6,6 +6,7 @@ import traceback import zlib from enum import Enum +from functools import partial from http import HTTPStatus from typing import Any, Callable, Dict, List, Optional, Set, Union @@ -263,6 +264,7 @@ def __init__( proxy_type: Enum = ProxyEventType.APIGatewayProxyEvent, cors: Optional[CORSConfig] = None, debug: Optional[bool] = None, + serializer: Optional[Callable[[Dict], str]] = None, ): """ Parameters @@ -284,6 +286,13 @@ def __init__( env=os.getenv(constants.EVENT_HANDLER_DEBUG_ENV, "false"), choice=debug ) + # 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 get(self, rule: str, cors: Optional[bool] = None, compress: bool = False, cache_control: Optional[str] = None): """Get route decorator with GET `method` @@ -592,8 +601,4 @@ def _to_response(self, result: Union[Dict, Response]) -> Response: ) def _json_dump(self, obj: Any) -> str: - """Does a concise json serialization or pretty print when in debug mode""" - if self._debug: - return json.dumps(obj, indent=4, cls=Encoder) - else: - return json.dumps(obj, separators=(",", ":"), cls=Encoder) + return self._serializer(obj) diff --git a/tests/functional/event_handler/test_api_gateway.py b/tests/functional/event_handler/test_api_gateway.py index f16086ba634..1272125da8b 100644 --- a/tests/functional/event_handler/test_api_gateway.py +++ b/tests/functional/event_handler/test_api_gateway.py @@ -3,6 +3,8 @@ import zlib from copy import deepcopy from decimal import Decimal +from enum import Enum +from json import JSONEncoder from pathlib import Path from typing import Dict @@ -728,3 +730,42 @@ def get_account(account_id: str): ret = app.resolve(event, None) assert ret["statusCode"] == 200 + + +def test_custom_serializer(): + # GIVEN a custom serializer to handle enums and sets + class CustomEncoder(JSONEncoder): + def default(self, data): + if isinstance(data, Enum): + return data.value + try: + iterable = iter(data) + except TypeError: + pass + else: + return sorted(iterable) + return JSONEncoder.default(self, data) + + def custom_serializer(data) -> str: + return json.dumps(data, cls=CustomEncoder) + + app = ApiGatewayResolver(serializer=custom_serializer) + + class Color(Enum): + RED = 1 + BLUE = 2 + + @app.get("/colors") + def get_color() -> Dict: + return { + "color": Color.RED, + "variations": {"light", "dark"}, + } + + # WHEN calling handler + response = app({"httpMethod": "GET", "path": "/colors"}, None) + + # THEN then use the custom serializer + body = response["body"] + expected = '{"color": 1, "variations": ["dark", "light"]}' + assert expected == body From 79294f77f1cd373fedd62faa14aba3cffd8f4bfe Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Tue, 3 Aug 2021 04:59:59 -0700 Subject: [PATCH 09/35] docs(readme): add code coverage badge (#577) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 25e7b2e343d..da2f9c5a964 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # AWS Lambda Powertools (Python) ![Build](https://github.com/awslabs/aws-lambda-powertools/workflows/Powertools%20Python/badge.svg?branch=master) +[![codecov.io](https://codecov.io/github/awslabs/aws-lambda-powertools-python/branch/develop/graphs/badge.svg)](https://app.codecov.io/gh/awslabs/aws-lambda-powertools-python) ![PythonSupport](https://img.shields.io/static/v1?label=python&message=3.6%20|%203.7|%203.8&color=blue?style=flat-square&logo=python) ![PyPI version](https://badge.fury.io/py/aws-lambda-powertools.svg) ![PyPi monthly downloads](https://img.shields.io/pypi/dm/aws-lambda-powertools) A suite of Python utilities for AWS Lambda functions to ease adopting best practices such as tracing, structured logging, custom metrics, and more. ([AWS Lambda Powertools Java](https://github.com/awslabs/aws-lambda-powertools-java) is also available). From 92d4a6d495cb78fe822dc05ff2c796aced3ea2ab Mon Sep 17 00:00:00 2001 From: Heitor Lessa Date: Wed, 4 Aug 2021 23:06:53 +0200 Subject: [PATCH 10/35] refactor(feature_flags): optimize UX and maintenance (#563) Co-authored-by: Michael Brewer --- .pre-commit-config.yaml | 2 +- .../shared/jmespath_functions.py | 22 - .../shared/jmespath_utils.py | 55 ++ .../utilities/feature_flags/__init__.py | 15 + .../utilities/feature_flags/appconfig.py | 92 ++++ .../utilities/feature_flags/base.py | 50 ++ .../utilities/feature_flags/exceptions.py | 13 + .../utilities/feature_flags/feature_flags.py | 252 +++++++++ .../utilities/feature_flags/schema.py | 226 ++++++++ .../utilities/feature_toggles/__init__.py | 16 - .../feature_toggles/appconfig_fetcher.py | 71 --- .../feature_toggles/configuration_store.py | 216 -------- .../utilities/feature_toggles/exceptions.py | 2 - .../utilities/feature_toggles/schema.py | 84 --- .../feature_toggles/schema_fetcher.py | 24 - .../utilities/idempotency/persistence/base.py | 2 +- .../utilities/validation/base.py | 36 +- .../utilities/validation/validator.py | 13 +- docs/utilities/feature_flags.md | 57 ++ mkdocs.yml | 1 + .../__init__.py | 0 .../feature_flags/test_feature_flags.py | 503 ++++++++++++++++++ .../feature_flags/test_schema_validation.py | 282 ++++++++++ .../feature_toggles/test_feature_toggles.py | 503 ------------------ .../feature_toggles/test_schema_validation.py | 330 ------------ tests/functional/idempotency/conftest.py | 2 +- 26 files changed, 1560 insertions(+), 1309 deletions(-) delete mode 100644 aws_lambda_powertools/shared/jmespath_functions.py create mode 100644 aws_lambda_powertools/shared/jmespath_utils.py create mode 100644 aws_lambda_powertools/utilities/feature_flags/__init__.py create mode 100644 aws_lambda_powertools/utilities/feature_flags/appconfig.py create mode 100644 aws_lambda_powertools/utilities/feature_flags/base.py create mode 100644 aws_lambda_powertools/utilities/feature_flags/exceptions.py create mode 100644 aws_lambda_powertools/utilities/feature_flags/feature_flags.py create mode 100644 aws_lambda_powertools/utilities/feature_flags/schema.py delete mode 100644 aws_lambda_powertools/utilities/feature_toggles/__init__.py delete mode 100644 aws_lambda_powertools/utilities/feature_toggles/appconfig_fetcher.py delete mode 100644 aws_lambda_powertools/utilities/feature_toggles/configuration_store.py delete mode 100644 aws_lambda_powertools/utilities/feature_toggles/exceptions.py delete mode 100644 aws_lambda_powertools/utilities/feature_toggles/schema.py delete mode 100644 aws_lambda_powertools/utilities/feature_toggles/schema_fetcher.py create mode 100644 docs/utilities/feature_flags.md rename tests/functional/{feature_toggles => feature_flags}/__init__.py (100%) create mode 100644 tests/functional/feature_flags/test_feature_flags.py create mode 100644 tests/functional/feature_flags/test_schema_validation.py delete mode 100644 tests/functional/feature_toggles/test_feature_toggles.py delete mode 100644 tests/functional/feature_toggles/test_schema_validation.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eabc7a65a10..f42337d5c5b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,7 +24,7 @@ repos: types: [python] - id: isort name: formatting::isort - entry: poetry run isort -rc + entry: poetry run isort language: system types: [python] - repo: local diff --git a/aws_lambda_powertools/shared/jmespath_functions.py b/aws_lambda_powertools/shared/jmespath_functions.py deleted file mode 100644 index b23ab477d6b..00000000000 --- a/aws_lambda_powertools/shared/jmespath_functions.py +++ /dev/null @@ -1,22 +0,0 @@ -import base64 -import gzip -import json - -import jmespath - - -class PowertoolsFunctions(jmespath.functions.Functions): - @jmespath.functions.signature({"types": ["string"]}) - def _func_powertools_json(self, value): - return json.loads(value) - - @jmespath.functions.signature({"types": ["string"]}) - def _func_powertools_base64(self, value): - return base64.b64decode(value).decode() - - @jmespath.functions.signature({"types": ["string"]}) - def _func_powertools_base64_gzip(self, value): - encoded = base64.b64decode(value) - uncompressed = gzip.decompress(encoded) - - return uncompressed.decode() diff --git a/aws_lambda_powertools/shared/jmespath_utils.py b/aws_lambda_powertools/shared/jmespath_utils.py new file mode 100644 index 00000000000..f2a865d4807 --- /dev/null +++ b/aws_lambda_powertools/shared/jmespath_utils.py @@ -0,0 +1,55 @@ +import base64 +import gzip +import json +from typing import Any, Dict, Optional, Union + +import jmespath +from jmespath.exceptions import LexerError + +from aws_lambda_powertools.utilities.validation import InvalidEnvelopeExpressionError +from aws_lambda_powertools.utilities.validation.base import logger + + +class PowertoolsFunctions(jmespath.functions.Functions): + @jmespath.functions.signature({"types": ["string"]}) + def _func_powertools_json(self, value): + return json.loads(value) + + @jmespath.functions.signature({"types": ["string"]}) + def _func_powertools_base64(self, value): + return base64.b64decode(value).decode() + + @jmespath.functions.signature({"types": ["string"]}) + def _func_powertools_base64_gzip(self, value): + encoded = base64.b64decode(value) + uncompressed = gzip.decompress(encoded) + + return uncompressed.decode() + + +def unwrap_event_from_envelope(data: Union[Dict, str], envelope: str, jmespath_options: Optional[Dict]) -> Any: + """Searches data using JMESPath expression + + Parameters + ---------- + data : Dict + Data set to be filtered + envelope : str + JMESPath expression to filter data against + jmespath_options : Dict + Alternative JMESPath options to be included when filtering expr + + Returns + ------- + Any + Data found using JMESPath expression given in envelope + """ + if not jmespath_options: + jmespath_options = {"custom_functions": PowertoolsFunctions()} + + try: + logger.debug(f"Envelope detected: {envelope}. JMESPath options: {jmespath_options}") + return jmespath.search(envelope, data, options=jmespath.Options(**jmespath_options)) + except (LexerError, TypeError, UnicodeError) as e: + message = f"Failed to unwrap event from envelope using expression. Error: {e} Exp: {envelope}, Data: {data}" # noqa: B306, E501 + raise InvalidEnvelopeExpressionError(message) diff --git a/aws_lambda_powertools/utilities/feature_flags/__init__.py b/aws_lambda_powertools/utilities/feature_flags/__init__.py new file mode 100644 index 00000000000..db7dfca5b57 --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_flags/__init__.py @@ -0,0 +1,15 @@ +"""Advanced feature flags utility""" +from .appconfig import AppConfigStore +from .base import StoreProvider +from .exceptions import ConfigurationStoreError +from .feature_flags import FeatureFlags +from .schema import RuleAction, SchemaValidator + +__all__ = [ + "ConfigurationStoreError", + "FeatureFlags", + "RuleAction", + "SchemaValidator", + "AppConfigStore", + "StoreProvider", +] diff --git a/aws_lambda_powertools/utilities/feature_flags/appconfig.py b/aws_lambda_powertools/utilities/feature_flags/appconfig.py new file mode 100644 index 00000000000..6c075eac1a1 --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_flags/appconfig.py @@ -0,0 +1,92 @@ +import logging +import traceback +from typing import Any, Dict, Optional, cast + +from botocore.config import Config + +from aws_lambda_powertools.utilities.parameters import AppConfigProvider, GetParameterError, TransformParameterError + +from ...shared import jmespath_utils +from .base import StoreProvider +from .exceptions import ConfigurationStoreError, StoreClientError + +logger = logging.getLogger(__name__) + +TRANSFORM_TYPE = "json" + + +class AppConfigStore(StoreProvider): + def __init__( + self, + environment: str, + application: str, + name: str, + cache_seconds: int, + sdk_config: Optional[Config] = None, + envelope: str = "", + jmespath_options: Optional[Dict] = None, + ): + """This class fetches JSON schemas from AWS AppConfig + + Parameters + ---------- + environment: str + Appconfig environment, e.g. 'dev/test' etc. + application: str + AppConfig application name, e.g. 'powertools' + name: str + AppConfig configuration name e.g. `my_conf` + cache_seconds: int + cache expiration time, how often to call AppConfig to fetch latest configuration + sdk_config: Optional[Config] + Botocore Config object to pass during client initialization + envelope : str + JMESPath expression to pluck feature flags data from config + jmespath_options : Dict + Alternative JMESPath options to be included when filtering expr + """ + super().__init__() + self.environment = environment + self.application = application + self.name = name + self.cache_seconds = cache_seconds + self.config = sdk_config + self.envelope = envelope + self.jmespath_options = jmespath_options + self._conf_store = AppConfigProvider(environment=environment, application=application, config=sdk_config) + + def get_configuration(self) -> Dict[str, Any]: + """Fetch feature schema configuration from AWS AppConfig + + Raises + ------ + ConfigurationStoreError + Any validation error or AppConfig error that can occur + + Returns + ------- + Dict[str, Any] + parsed JSON dictionary + """ + try: + # parse result conf as JSON, keep in cache for self.max_age seconds + config = cast( + dict, + self._conf_store.get( + name=self.name, + transform=TRANSFORM_TYPE, + max_age=self.cache_seconds, + ), + ) + + if self.envelope: + config = jmespath_utils.unwrap_event_from_envelope( + data=config, envelope=self.envelope, jmespath_options=self.jmespath_options + ) + + return config + except (GetParameterError, TransformParameterError) as exc: + err_msg = traceback.format_exc() + if "AccessDenied" in err_msg: + raise StoreClientError(err_msg) from exc + raise ConfigurationStoreError("Unable to get AWS AppConfig configuration file") from exc diff --git a/aws_lambda_powertools/utilities/feature_flags/base.py b/aws_lambda_powertools/utilities/feature_flags/base.py new file mode 100644 index 00000000000..1df90f19ac8 --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_flags/base.py @@ -0,0 +1,50 @@ +from abc import ABC, abstractmethod +from typing import Any, Dict + + +class StoreProvider(ABC): + @abstractmethod + def get_configuration(self) -> Dict[str, Any]: + """Get configuration from any store and return the parsed JSON dictionary + + Raises + ------ + ConfigurationStoreError + Any error that can occur during schema fetch or JSON parse + + Returns + ------- + Dict[str, Any] + parsed JSON dictionary + + **Example** + + ```python + { + "premium_features": { + "default": False, + "rules": { + "customer tier equals premium": { + "when_match": True, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium", + } + ], + } + }, + }, + "feature_two": { + "default": False + } + } + """ + return NotImplemented # pragma: no cover + + +class BaseValidator(ABC): + @abstractmethod + def validate(self): + return NotImplemented # pragma: no cover diff --git a/aws_lambda_powertools/utilities/feature_flags/exceptions.py b/aws_lambda_powertools/utilities/feature_flags/exceptions.py new file mode 100644 index 00000000000..eaea6c61cca --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_flags/exceptions.py @@ -0,0 +1,13 @@ +class ConfigurationStoreError(Exception): + """When a configuration store raises an exception on config retrieval or parsing""" + + +class SchemaValidationError(Exception): + """When feature flag schema fails validation""" + + +class StoreClientError(Exception): + """When a store raises an exception that should be propagated to the client to fix + + For example, Access Denied errors when the client doesn't permissions to fetch config + """ diff --git a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py new file mode 100644 index 00000000000..a862baf61c2 --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py @@ -0,0 +1,252 @@ +import logging +from typing import Any, Dict, List, Optional, Union, cast + +from . import schema +from .base import StoreProvider +from .exceptions import ConfigurationStoreError + +logger = logging.getLogger(__name__) + + +class FeatureFlags: + def __init__(self, store: StoreProvider): + """Evaluates whether feature flags should be enabled based on a given context. + + It uses the provided store to fetch feature flag rules before evaluating them. + + Examples + -------- + + ```python + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + + app_config = AppConfigStore( + environment="test", + application="powertools", + name="test_conf_name", + cache_seconds=300, + envelope="features" + ) + + feature_flags: FeatureFlags = FeatureFlags(store=app_config) + ``` + + Parameters + ---------- + store: StoreProvider + Store to use to fetch feature flag schema configuration. + """ + self._store = store + + @staticmethod + def _match_by_action(action: str, condition_value: Any, context_value: Any) -> bool: + if not context_value: + return False + mapping_by_action = { + schema.RuleAction.EQUALS.value: lambda a, b: a == b, + schema.RuleAction.STARTSWITH.value: lambda a, b: a.startswith(b), + schema.RuleAction.ENDSWITH.value: lambda a, b: a.endswith(b), + schema.RuleAction.CONTAINS.value: lambda a, b: a in b, + } + + try: + func = mapping_by_action.get(action, lambda a, b: False) + return func(context_value, condition_value) + except Exception as exc: + logger.debug(f"caught exception while matching action: action={action}, exception={str(exc)}") + return False + + def _evaluate_conditions( + self, rule_name: str, feature_name: str, rule: Dict[str, Any], context: Dict[str, Any] + ) -> bool: + """Evaluates whether context matches conditions, return False otherwise""" + rule_match_value = rule.get(schema.RULE_MATCH_VALUE) + conditions = cast(List[Dict], rule.get(schema.CONDITIONS_KEY)) + + for condition in conditions: + context_value = context.get(str(condition.get(schema.CONDITION_KEY))) + cond_action = condition.get(schema.CONDITION_ACTION, "") + cond_value = condition.get(schema.CONDITION_VALUE) + + if not self._match_by_action(action=cond_action, condition_value=cond_value, context_value=context_value): + logger.debug( + f"rule did not match action, rule_name={rule_name}, rule_value={rule_match_value}, " + f"name={feature_name}, context_value={str(context_value)} " + ) + return False # context doesn't match condition + + logger.debug(f"rule matched, rule_name={rule_name}, rule_value={rule_match_value}, name={feature_name}") + return True + return False + + def _evaluate_rules( + self, *, feature_name: str, context: Dict[str, Any], feat_default: bool, rules: Dict[str, Any] + ) -> bool: + """Evaluates whether context matches rules and conditions, otherwise return feature default""" + for rule_name, rule in rules.items(): + rule_match_value = rule.get(schema.RULE_MATCH_VALUE) + + # Context might contain PII data; do not log its value + logger.debug(f"Evaluating rule matching, rule={rule_name}, feature={feature_name}, default={feat_default}") + if self._evaluate_conditions(rule_name=rule_name, feature_name=feature_name, rule=rule, context=context): + return bool(rule_match_value) + + # no rule matched, return default value of feature + logger.debug(f"no rule matched, returning feature default, default={feat_default}, name={feature_name}") + return feat_default + return False + + def get_configuration(self) -> Union[Dict[str, Dict], Dict]: + """Get validated feature flag schema from configured store. + + Largely used to aid testing, since it's called by `evaluate` and `get_enabled_features` methods. + + Raises + ------ + ConfigurationStoreError + Any propagated error from store + SchemaValidationError + When schema doesn't conform with feature flag schema + + Returns + ------ + Dict[str, Dict] + parsed JSON dictionary + + **Example** + + ```python + { + "premium_features": { + "default": False, + "rules": { + "customer tier equals premium": { + "when_match": True, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium", + } + ], + } + }, + }, + "feature_two": { + "default": False + } + } + ``` + """ + # parse result conf as JSON, keep in cache for max age defined in store + logger.debug(f"Fetching schema from registered store, store={self._store}") + config = self._store.get_configuration() + validator = schema.SchemaValidator(schema=config) + validator.validate() + + return config + + def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, default: bool) -> bool: + """Evaluate whether a feature flag should be enabled according to stored schema and input context + + **Logic when evaluating a feature flag** + + 1. Feature exists and a rule matches, returns when_match value + 2. Feature exists but has either no rules or no match, return feature default value + 3. Feature doesn't exist in stored schema, encountered an error when fetching -> return default value provided + + Parameters + ---------- + name: str + feature name to evaluate + context: Optional[Dict[str, Any]] + Attributes that should be evaluated against the stored schema. + + for example: `{"tenant_id": "X", "username": "Y", "region": "Z"}` + default: bool + default value if feature flag doesn't exist in the schema, + or there has been an error when fetching the configuration from the store + + Returns + ------ + bool + whether feature should be enabled or not + + Raises + ------ + SchemaValidationError + When schema doesn't conform with feature flag schema + """ + if context is None: + context = {} + + try: + features = self.get_configuration() + except ConfigurationStoreError as err: + logger.debug(f"Failed to fetch feature flags from store, returning default provided, reason={err}") + return default + + feature = features.get(name) + if feature is None: + logger.debug(f"Feature not found; returning default provided, name={name}, default={default}") + return default + + rules = feature.get(schema.RULES_KEY) + feat_default = feature.get(schema.FEATURE_DEFAULT_VAL_KEY) + if not rules: + logger.debug(f"no rules found, returning feature default, name={name}, default={feat_default}") + return bool(feat_default) + + logger.debug(f"looking for rule match, name={name}, default={feat_default}") + return self._evaluate_rules(feature_name=name, context=context, feat_default=bool(feat_default), rules=rules) + + def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> List[str]: + """Get all enabled feature flags while also taking into account context + (when a feature has defined rules) + + Parameters + ---------- + context: Optional[Dict[str, Any]] + dict of attributes that you would like to match the rules + against, can be `{'tenant_id: 'X', 'username':' 'Y', 'region': 'Z'}` etc. + + Returns + ---------- + List[str] + list of all feature names that either matches context or have True as default + + **Example** + + ```python + ["premium_features", "my_feature_two", "always_true_feature"] + ``` + + Raises + ------ + SchemaValidationError + When schema doesn't conform with feature flag schema + """ + if context is None: + context = {} + + features_enabled: List[str] = [] + + try: + features: Dict[str, Any] = self.get_configuration() + except ConfigurationStoreError as err: + logger.debug(f"Failed to fetch feature flags from store, returning empty list, reason={err}") + return features_enabled + + for name, feature in features.items(): + rules = feature.get(schema.RULES_KEY, {}) + feature_default_value = feature.get(schema.FEATURE_DEFAULT_VAL_KEY) + if feature_default_value and not rules: + logger.debug(f"feature is enabled by default and has no defined rules, name={name}") + features_enabled.append(name) + elif self._evaluate_rules( + feature_name=name, context=context, feat_default=feature_default_value, rules=rules + ): + logger.debug(f"feature's calculated value is True, name={name}") + features_enabled.append(name) + + return features_enabled diff --git a/aws_lambda_powertools/utilities/feature_flags/schema.py b/aws_lambda_powertools/utilities/feature_flags/schema.py new file mode 100644 index 00000000000..3de7ac22363 --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_flags/schema.py @@ -0,0 +1,226 @@ +import logging +from enum import Enum +from typing import Any, Dict, List, Optional + +from .base import BaseValidator +from .exceptions import SchemaValidationError + +logger = logging.getLogger(__name__) + +RULES_KEY = "rules" +FEATURE_DEFAULT_VAL_KEY = "default" +CONDITIONS_KEY = "conditions" +RULE_MATCH_VALUE = "when_match" +CONDITION_KEY = "key" +CONDITION_VALUE = "value" +CONDITION_ACTION = "action" + + +class RuleAction(str, Enum): + EQUALS = "EQUALS" + STARTSWITH = "STARTSWITH" + ENDSWITH = "ENDSWITH" + CONTAINS = "CONTAINS" + + +class SchemaValidator(BaseValidator): + """Validates feature flag schema configuration + + Raises + ------ + SchemaValidationError + When schema doesn't conform with feature flag schema + + Schema + ------ + + **Feature object** + + A dictionary containing default value and rules for matching. + The value MUST be an object and MIGHT contain the following members: + + * **default**: `bool`. Defines default feature value. This MUST be present + * **rules**: `Dict[str, Dict]`. Rules object. This MIGHT be present + + ```json + { + "my_feature": { + "default": True, + "rules": {} + } + } + ``` + + **Rules object** + + A dictionary with each rule and their conditions that a feature might have. + The value MIGHT be present, and when defined it MUST contain the following members: + + * **when_match**: `bool`. Defines value to return when context matches conditions + * **conditions**: `List[Dict]`. Conditions object. This MUST be present + + ```json + { + "my_feature": { + "default": True, + "rules": { + "tenant id equals 345345435": { + "when_match": False, + "conditions": [] + } + } + } + } + ``` + + **Conditions object** + + A list of dictionaries containing conditions for a given rule. + The value MUST contain the following members: + + * **action**: `str`. Operation to perform to match a key and value. + The value MUST be either EQUALS, STARTSWITH, ENDSWITH, CONTAINS + * **key**: `str`. Key in given context to perform operation + * **value**: `Any`. Value in given context that should match action operation. + + ```json + { + "my_feature": { + "default": True, + "rules": { + "tenant id equals 345345435": { + "when_match": False, + "conditions": [ + { + "action": "EQUALS", + "key": "tenant_id", + "value": "345345435", + } + ] + } + } + } + } + ``` + """ + + def __init__(self, schema: Dict[str, Any]): + self.schema = schema + + def validate(self) -> None: + logger.debug("Validating schema") + if not isinstance(self.schema, dict): + raise SchemaValidationError(f"Features must be a dictionary, schema={str(self.schema)}") + + features = FeaturesValidator(schema=self.schema) + features.validate() + + +class FeaturesValidator(BaseValidator): + """Validates each feature and calls RulesValidator to validate its rules""" + + def __init__(self, schema: Dict): + self.schema = schema + + def validate(self): + for name, feature in self.schema.items(): + logger.debug(f"Attempting to validate feature '{name}'") + self.validate_feature(name, feature) + rules = RulesValidator(feature=feature) + rules.validate() + + @staticmethod + def validate_feature(name, feature): + if not feature or not isinstance(feature, dict): + raise SchemaValidationError(f"Feature must be a non-empty dictionary, feature={name}") + + default_value = feature.get(FEATURE_DEFAULT_VAL_KEY) + if default_value is None or not isinstance(default_value, bool): + raise SchemaValidationError(f"feature 'default' boolean key must be present, feature={name}") + + +class RulesValidator(BaseValidator): + """Validates each rule and calls ConditionsValidator to validate each rule's conditions""" + + def __init__(self, feature: Dict[str, Any]): + self.feature = feature + self.feature_name = next(iter(self.feature)) + self.rules: Optional[Dict] = self.feature.get(RULES_KEY) + + def validate(self): + if not self.rules: + logger.debug("Rules are empty, ignoring validation") + return + + if not isinstance(self.rules, dict): + raise SchemaValidationError(f"Feature rules must be a dictionary, feature={self.feature_name}") + + for rule_name, rule in self.rules.items(): + logger.debug(f"Attempting to validate rule '{rule_name}'") + self.validate_rule(rule=rule, rule_name=rule_name, feature_name=self.feature_name) + conditions = ConditionsValidator(rule=rule, rule_name=rule_name) + conditions.validate() + + @staticmethod + def validate_rule(rule, rule_name, feature_name): + if not rule or not isinstance(rule, dict): + raise SchemaValidationError(f"Feature rule must be a dictionary, feature={feature_name}") + + RulesValidator.validate_rule_name(rule_name=rule_name, feature_name=feature_name) + RulesValidator.validate_rule_default_value(rule=rule, rule_name=rule_name) + + @staticmethod + def validate_rule_name(rule_name: str, feature_name: str): + if not rule_name or not isinstance(rule_name, str): + raise SchemaValidationError(f"Rule name key must have a non-empty string, feature={feature_name}") + + @staticmethod + def validate_rule_default_value(rule: Dict, rule_name: str): + rule_default_value = rule.get(RULE_MATCH_VALUE) + if not isinstance(rule_default_value, bool): + raise SchemaValidationError(f"'rule_default_value' key must have be bool, rule={rule_name}") + + +class ConditionsValidator(BaseValidator): + def __init__(self, rule: Dict[str, Any], rule_name: str): + self.conditions: List[Dict[str, Any]] = rule.get(CONDITIONS_KEY, {}) + self.rule_name = rule_name + + def validate(self): + if not self.conditions or not isinstance(self.conditions, list): + raise SchemaValidationError(f"Invalid condition, rule={self.rule_name}") + + for condition in self.conditions: + self.validate_condition(rule_name=self.rule_name, condition=condition) + + @staticmethod + def validate_condition(rule_name: str, condition: Dict[str, str]) -> None: + if not condition or not isinstance(condition, dict): + raise SchemaValidationError(f"Feature rule condition must be a dictionary, rule={rule_name}") + + # Condition can contain PII data; do not log condition value + logger.debug(f"Attempting to validate condition for '{rule_name}'") + ConditionsValidator.validate_condition_action(condition=condition, rule_name=rule_name) + ConditionsValidator.validate_condition_key(condition=condition, rule_name=rule_name) + ConditionsValidator.validate_condition_value(condition=condition, rule_name=rule_name) + + @staticmethod + def validate_condition_action(condition: Dict[str, Any], rule_name: str): + action = condition.get(CONDITION_ACTION, "") + if action not in RuleAction.__members__: + allowed_values = [_action.value for _action in RuleAction] + raise SchemaValidationError( + f"'action' value must be either {allowed_values}, rule_name={rule_name}, action={action}" + ) + + @staticmethod + def validate_condition_key(condition: Dict[str, Any], rule_name: str): + key = condition.get(CONDITION_KEY, "") + if not key or not isinstance(key, str): + raise SchemaValidationError(f"'key' value must be a non empty string, rule={rule_name}") + + @staticmethod + def validate_condition_value(condition: Dict[str, Any], rule_name: str): + value = condition.get(CONDITION_VALUE, "") + if not value: + raise SchemaValidationError(f"'value' key must not be empty, rule={rule_name}") diff --git a/aws_lambda_powertools/utilities/feature_toggles/__init__.py b/aws_lambda_powertools/utilities/feature_toggles/__init__.py deleted file mode 100644 index 04237d63812..00000000000 --- a/aws_lambda_powertools/utilities/feature_toggles/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Advanced feature toggles utility -""" -from .appconfig_fetcher import AppConfigFetcher -from .configuration_store import ConfigurationStore -from .exceptions import ConfigurationError -from .schema import ACTION, SchemaValidator -from .schema_fetcher import SchemaFetcher - -__all__ = [ - "ConfigurationError", - "ConfigurationStore", - "ACTION", - "SchemaValidator", - "AppConfigFetcher", - "SchemaFetcher", -] diff --git a/aws_lambda_powertools/utilities/feature_toggles/appconfig_fetcher.py b/aws_lambda_powertools/utilities/feature_toggles/appconfig_fetcher.py deleted file mode 100644 index 3501edfd0d3..00000000000 --- a/aws_lambda_powertools/utilities/feature_toggles/appconfig_fetcher.py +++ /dev/null @@ -1,71 +0,0 @@ -import logging -from typing import Any, Dict, Optional, cast - -from botocore.config import Config - -from aws_lambda_powertools.utilities.parameters import AppConfigProvider, GetParameterError, TransformParameterError - -from .exceptions import ConfigurationError -from .schema_fetcher import SchemaFetcher - -logger = logging.getLogger(__name__) - - -TRANSFORM_TYPE = "json" - - -class AppConfigFetcher(SchemaFetcher): - def __init__( - self, - environment: str, - service: str, - configuration_name: str, - cache_seconds: int, - config: Optional[Config] = None, - ): - """This class fetches JSON schemas from AWS AppConfig - - Parameters - ---------- - environment: str - what appconfig environment to use 'dev/test' etc. - service: str - what service name to use from the supplied environment - configuration_name: str - what configuration to take from the environment & service combination - cache_seconds: int - cache expiration time, how often to call AppConfig to fetch latest configuration - config: Optional[Config] - boto3 client configuration - """ - super().__init__(configuration_name, cache_seconds) - self._logger = logger - self._conf_store = AppConfigProvider(environment=environment, application=service, config=config) - - def get_json_configuration(self) -> Dict[str, Any]: - """Get configuration string from AWs AppConfig and return the parsed JSON dictionary - - Raises - ------ - ConfigurationError - Any validation error or appconfig error that can occur - - Returns - ------- - Dict[str, Any] - parsed JSON dictionary - """ - try: - # parse result conf as JSON, keep in cache for self.max_age seconds - return cast( - dict, - self._conf_store.get( - name=self.configuration_name, - transform=TRANSFORM_TYPE, - max_age=self._cache_seconds, - ), - ) - except (GetParameterError, TransformParameterError) as exc: - error_str = f"unable to get AWS AppConfig configuration file, exception={str(exc)}" - self._logger.error(error_str) - raise ConfigurationError(error_str) diff --git a/aws_lambda_powertools/utilities/feature_toggles/configuration_store.py b/aws_lambda_powertools/utilities/feature_toggles/configuration_store.py deleted file mode 100644 index 72d00bb9c03..00000000000 --- a/aws_lambda_powertools/utilities/feature_toggles/configuration_store.py +++ /dev/null @@ -1,216 +0,0 @@ -import logging -from typing import Any, Dict, List, Optional, cast - -from . import schema -from .exceptions import ConfigurationError -from .schema_fetcher import SchemaFetcher - -logger = logging.getLogger(__name__) - - -class ConfigurationStore: - def __init__(self, schema_fetcher: SchemaFetcher): - """constructor - - Parameters - ---------- - schema_fetcher: SchemaFetcher - A schema JSON fetcher, can be AWS AppConfig, Hashicorp Consul etc. - """ - self._logger = logger - self._schema_fetcher = schema_fetcher - self._schema_validator = schema.SchemaValidator(self._logger) - - def _match_by_action(self, action: str, condition_value: Any, context_value: Any) -> bool: - if not context_value: - return False - mapping_by_action = { - schema.ACTION.EQUALS.value: lambda a, b: a == b, - schema.ACTION.STARTSWITH.value: lambda a, b: a.startswith(b), - schema.ACTION.ENDSWITH.value: lambda a, b: a.endswith(b), - schema.ACTION.CONTAINS.value: lambda a, b: a in b, - } - - try: - func = mapping_by_action.get(action, lambda a, b: False) - return func(context_value, condition_value) - except Exception as exc: - self._logger.error(f"caught exception while matching action, action={action}, exception={str(exc)}") - return False - - def _is_rule_matched(self, feature_name: str, rule: Dict[str, Any], rules_context: Dict[str, Any]) -> bool: - rule_name = rule.get(schema.RULE_NAME_KEY, "") - rule_default_value = rule.get(schema.RULE_DEFAULT_VALUE) - conditions = cast(List[Dict], rule.get(schema.CONDITIONS_KEY)) - - for condition in conditions: - context_value = rules_context.get(str(condition.get(schema.CONDITION_KEY))) - if not self._match_by_action( - condition.get(schema.CONDITION_ACTION, ""), - condition.get(schema.CONDITION_VALUE), - context_value, - ): - logger.debug( - f"rule did not match action, rule_name={rule_name}, rule_default_value={rule_default_value}, " - f"feature_name={feature_name}, context_value={str(context_value)} " - ) - # context doesn't match condition - return False - # if we got here, all conditions match - logger.debug( - f"rule matched, rule_name={rule_name}, rule_default_value={rule_default_value}, " - f"feature_name={feature_name}" - ) - return True - return False - - def _handle_rules( - self, - *, - feature_name: str, - rules_context: Dict[str, Any], - feature_default_value: bool, - rules: List[Dict[str, Any]], - ) -> bool: - for rule in rules: - rule_default_value = rule.get(schema.RULE_DEFAULT_VALUE) - if self._is_rule_matched(feature_name, rule, rules_context): - return bool(rule_default_value) - # no rule matched, return default value of feature - logger.debug( - f"no rule matched, returning default value of feature, feature_default_value={feature_default_value}, " - f"feature_name={feature_name}" - ) - return feature_default_value - return False - - def get_configuration(self) -> Dict[str, Any]: - """Get configuration string from AWs AppConfig and returned the parsed JSON dictionary - - Raises - ------ - ConfigurationError - Any validation error or appconfig error that can occur - - Returns - ------ - Dict[str, Any] - parsed JSON dictionary - """ - # parse result conf as JSON, keep in cache for self.max_age seconds - config = self._schema_fetcher.get_json_configuration() - # validate schema - self._schema_validator.validate_json_schema(config) - return config - - def get_feature_toggle( - self, *, feature_name: str, rules_context: Optional[Dict[str, Any]] = None, value_if_missing: bool - ) -> bool: - """Get a feature toggle boolean value. Value is calculated according to a set of rules and conditions. - - See below for explanation. - - Parameters - ---------- - feature_name: str - feature name that you wish to fetch - rules_context: Optional[Dict[str, Any]] - dict of attributes that you would like to match the rules - against, can be {'tenant_id: 'X', 'username':' 'Y', 'region': 'Z'} etc. - value_if_missing: bool - this will be the returned value in case the feature toggle doesn't exist in - the schema or there has been an error while fetching the - configuration from appconfig - - Returns - ------ - bool - calculated feature toggle value. several possibilities: - 1. if the feature doesn't appear in the schema or there has been an error fetching the - configuration -> error/warning log would appear and value_if_missing is returned - 2. feature exists and has no rules or no rules have matched -> return feature_default_value of - the defined feature - 3. feature exists and a rule matches -> rule_default_value of rule is returned - """ - if rules_context is None: - rules_context = {} - - try: - toggles_dict: Dict[str, Any] = self.get_configuration() - except ConfigurationError: - logger.error("unable to get feature toggles JSON, returning provided value_if_missing value") - return value_if_missing - - feature: Dict[str, Dict] = toggles_dict.get(schema.FEATURES_KEY, {}).get(feature_name, None) - if feature is None: - logger.warning( - f"feature does not appear in configuration, using provided value_if_missing, " - f"feature_name={feature_name}, value_if_missing={value_if_missing}" - ) - return value_if_missing - - rules_list = feature.get(schema.RULES_KEY) - feature_default_value = feature.get(schema.FEATURE_DEFAULT_VAL_KEY) - if not rules_list: - # not rules but has a value - logger.debug( - f"no rules found, returning feature default value, feature_name={feature_name}, " - f"default_value={feature_default_value}" - ) - return bool(feature_default_value) - # look for first rule match - logger.debug( - f"looking for rule match, feature_name={feature_name}, feature_default_value={feature_default_value}" - ) - return self._handle_rules( - feature_name=feature_name, - rules_context=rules_context, - feature_default_value=bool(feature_default_value), - rules=cast(List, rules_list), - ) - - def get_all_enabled_feature_toggles(self, *, rules_context: Optional[Dict[str, Any]] = None) -> List[str]: - """Get all enabled feature toggles while also taking into account rule_context - (when a feature has defined rules) - - Parameters - ---------- - rules_context: Optional[Dict[str, Any]] - dict of attributes that you would like to match the rules - against, can be `{'tenant_id: 'X', 'username':' 'Y', 'region': 'Z'}` etc. - - Returns - ---------- - List[str] - a list of all features name that are enabled by also taking into account - rule_context (when a feature has defined rules) - """ - if rules_context is None: - rules_context = {} - - try: - toggles_dict: Dict[str, Any] = self.get_configuration() - except ConfigurationError: - logger.error("unable to get feature toggles JSON") - return [] - - ret_list = [] - features: Dict[str, Any] = toggles_dict.get(schema.FEATURES_KEY, {}) - for feature_name, feature_dict_def in features.items(): - rules_list = feature_dict_def.get(schema.RULES_KEY, []) - feature_default_value = feature_dict_def.get(schema.FEATURE_DEFAULT_VAL_KEY) - if feature_default_value and not rules_list: - self._logger.debug( - f"feature is enabled by default and has no defined rules, feature_name={feature_name}" - ) - ret_list.append(feature_name) - elif self._handle_rules( - feature_name=feature_name, - rules_context=rules_context, - feature_default_value=feature_default_value, - rules=rules_list, - ): - self._logger.debug(f"feature's calculated value is True, feature_name={feature_name}") - ret_list.append(feature_name) - - return ret_list diff --git a/aws_lambda_powertools/utilities/feature_toggles/exceptions.py b/aws_lambda_powertools/utilities/feature_toggles/exceptions.py deleted file mode 100644 index d87f9a39dec..00000000000 --- a/aws_lambda_powertools/utilities/feature_toggles/exceptions.py +++ /dev/null @@ -1,2 +0,0 @@ -class ConfigurationError(Exception): - """When a a configuration store raises an exception on config retrieval or parsing""" diff --git a/aws_lambda_powertools/utilities/feature_toggles/schema.py b/aws_lambda_powertools/utilities/feature_toggles/schema.py deleted file mode 100644 index 9d995ab59e4..00000000000 --- a/aws_lambda_powertools/utilities/feature_toggles/schema.py +++ /dev/null @@ -1,84 +0,0 @@ -from enum import Enum -from logging import Logger -from typing import Any, Dict - -from .exceptions import ConfigurationError - -FEATURES_KEY = "features" -RULES_KEY = "rules" -FEATURE_DEFAULT_VAL_KEY = "feature_default_value" -CONDITIONS_KEY = "conditions" -RULE_NAME_KEY = "rule_name" -RULE_DEFAULT_VALUE = "value_when_applies" -CONDITION_KEY = "key" -CONDITION_VALUE = "value" -CONDITION_ACTION = "action" - - -class ACTION(str, Enum): - EQUALS = "EQUALS" - STARTSWITH = "STARTSWITH" - ENDSWITH = "ENDSWITH" - CONTAINS = "CONTAINS" - - -class SchemaValidator: - def __init__(self, logger: Logger): - self._logger = logger - - def _raise_conf_exc(self, error_str: str) -> None: - self._logger.error(error_str) - raise ConfigurationError(error_str) - - def _validate_condition(self, rule_name: str, condition: Dict[str, str]) -> None: - if not condition or not isinstance(condition, dict): - self._raise_conf_exc(f"invalid condition type, not a dictionary, rule_name={rule_name}") - action = condition.get(CONDITION_ACTION, "") - if action not in [ACTION.EQUALS.value, ACTION.STARTSWITH.value, ACTION.ENDSWITH.value, ACTION.CONTAINS.value]: - self._raise_conf_exc(f"invalid action value, rule_name={rule_name}, action={action}") - key = condition.get(CONDITION_KEY, "") - if not key or not isinstance(key, str): - self._raise_conf_exc(f"invalid key value, key has to be a non empty string, rule_name={rule_name}") - value = condition.get(CONDITION_VALUE, "") - if not value: - self._raise_conf_exc(f"missing condition value, rule_name={rule_name}") - - def _validate_rule(self, feature_name: str, rule: Dict[str, Any]) -> None: - if not rule or not isinstance(rule, dict): - self._raise_conf_exc(f"feature rule is not a dictionary, feature_name={feature_name}") - rule_name = rule.get(RULE_NAME_KEY) - if not rule_name or rule_name is None or not isinstance(rule_name, str): - return self._raise_conf_exc(f"invalid rule_name, feature_name={feature_name}") - rule_default_value = rule.get(RULE_DEFAULT_VALUE) - if rule_default_value is None or not isinstance(rule_default_value, bool): - self._raise_conf_exc(f"invalid rule_default_value, rule_name={rule_name}") - conditions = rule.get(CONDITIONS_KEY, {}) - if not conditions or not isinstance(conditions, list): - self._raise_conf_exc(f"invalid condition, rule_name={rule_name}") - # validate conditions - for condition in conditions: - self._validate_condition(rule_name, condition) - - def _validate_feature(self, feature_name: str, feature_dict_def: Dict[str, Any]) -> None: - if not feature_dict_def or not isinstance(feature_dict_def, dict): - self._raise_conf_exc(f"invalid AWS AppConfig JSON schema detected, feature {feature_name} is invalid") - feature_default_value = feature_dict_def.get(FEATURE_DEFAULT_VAL_KEY) - if feature_default_value is None or not isinstance(feature_default_value, bool): - self._raise_conf_exc(f"missing feature_default_value for feature, feature_name={feature_name}") - # validate rules - rules = feature_dict_def.get(RULES_KEY, []) - if not rules: - return - if not isinstance(rules, list): - self._raise_conf_exc(f"feature rules is not a list, feature_name={feature_name}") - for rule in rules: - self._validate_rule(feature_name, rule) - - def validate_json_schema(self, schema: Dict[str, Any]) -> None: - if not isinstance(schema, dict): - self._raise_conf_exc("invalid AWS AppConfig JSON schema detected, root schema is not a dictionary") - features_dict = schema.get(FEATURES_KEY) - if not isinstance(features_dict, dict): - return self._raise_conf_exc("invalid AWS AppConfig JSON schema detected, missing features dictionary") - for feature_name, feature_dict_def in features_dict.items(): - self._validate_feature(feature_name, feature_dict_def) diff --git a/aws_lambda_powertools/utilities/feature_toggles/schema_fetcher.py b/aws_lambda_powertools/utilities/feature_toggles/schema_fetcher.py deleted file mode 100644 index 89fffe1221d..00000000000 --- a/aws_lambda_powertools/utilities/feature_toggles/schema_fetcher.py +++ /dev/null @@ -1,24 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, Dict - - -class SchemaFetcher(ABC): - def __init__(self, configuration_name: str, cache_seconds: int): - self.configuration_name = configuration_name - self._cache_seconds = cache_seconds - - @abstractmethod - def get_json_configuration(self) -> Dict[str, Any]: - """Get configuration string from any configuration storing service and return the parsed JSON dictionary - - Raises - ------ - ConfigurationError - Any error that can occur during schema fetch or JSON parse - - Returns - ------- - Dict[str, Any] - parsed JSON dictionary - """ - return NotImplemented # pragma: no cover diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index eb43a8b30c5..0388adfbf55 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -14,7 +14,7 @@ import jmespath from aws_lambda_powertools.shared.cache_dict import LRUDict -from aws_lambda_powertools.shared.jmespath_functions import PowertoolsFunctions +from aws_lambda_powertools.shared.jmespath_utils import PowertoolsFunctions from aws_lambda_powertools.shared.json_encoder import Encoder from aws_lambda_powertools.utilities.idempotency.config import IdempotencyConfig from aws_lambda_powertools.utilities.idempotency.exceptions import ( diff --git a/aws_lambda_powertools/utilities/validation/base.py b/aws_lambda_powertools/utilities/validation/base.py index b818f11a40e..13deb4d24e2 100644 --- a/aws_lambda_powertools/utilities/validation/base.py +++ b/aws_lambda_powertools/utilities/validation/base.py @@ -1,13 +1,9 @@ import logging -from typing import Any, Dict, Optional, Union +from typing import Dict, Optional, Union import fastjsonschema # type: ignore -import jmespath -from jmespath.exceptions import LexerError # type: ignore -from aws_lambda_powertools.shared.jmespath_functions import PowertoolsFunctions - -from .exceptions import InvalidEnvelopeExpressionError, InvalidSchemaFormatError, SchemaValidationError +from .exceptions import InvalidSchemaFormatError, SchemaValidationError logger = logging.getLogger(__name__) @@ -39,31 +35,3 @@ def validate_data_against_schema(data: Union[Dict, str], schema: Dict, formats: except fastjsonschema.JsonSchemaException as e: message = f"Failed schema validation. Error: {e.message}, Path: {e.path}, Data: {e.value}" # noqa: B306, E501 raise SchemaValidationError(message) - - -def unwrap_event_from_envelope(data: Union[Dict, str], envelope: str, jmespath_options: Optional[Dict]) -> Any: - """Searches data using JMESPath expression - - Parameters - ---------- - data : Dict - Data set to be filtered - envelope : str - JMESPath expression to filter data against - jmespath_options : Dict - Alternative JMESPath options to be included when filtering expr - - Returns - ------- - Any - Data found using JMESPath expression given in envelope - """ - if not jmespath_options: - jmespath_options = {"custom_functions": PowertoolsFunctions()} - - try: - logger.debug(f"Envelope detected: {envelope}. JMESPath options: {jmespath_options}") - return jmespath.search(envelope, data, options=jmespath.Options(**jmespath_options)) - except (LexerError, TypeError, UnicodeError) as e: - message = f"Failed to unwrap event from envelope using expression. Error: {e} Exp: {envelope}, Data: {data}" # noqa: B306, E501 - raise InvalidEnvelopeExpressionError(message) diff --git a/aws_lambda_powertools/utilities/validation/validator.py b/aws_lambda_powertools/utilities/validation/validator.py index 0497a49a714..02a685a1565 100644 --- a/aws_lambda_powertools/utilities/validation/validator.py +++ b/aws_lambda_powertools/utilities/validation/validator.py @@ -2,7 +2,8 @@ from typing import Any, Callable, Dict, Optional, Union from ...middleware_factory import lambda_handler_decorator -from .base import unwrap_event_from_envelope, validate_data_against_schema +from ...shared import jmespath_utils +from .base import validate_data_against_schema logger = logging.getLogger(__name__) @@ -16,7 +17,7 @@ def validator( inbound_formats: Optional[Dict] = None, outbound_schema: Optional[Dict] = None, outbound_formats: Optional[Dict] = None, - envelope: Optional[str] = None, + envelope: str = "", jmespath_options: Optional[Dict] = None, ) -> Any: """Lambda handler decorator to validate incoming/outbound data using a JSON Schema @@ -116,7 +117,9 @@ def handler(event, context): When JMESPath expression to unwrap event is invalid """ if envelope: - event = unwrap_event_from_envelope(data=event, envelope=envelope, jmespath_options=jmespath_options) + event = jmespath_utils.unwrap_event_from_envelope( + data=event, envelope=envelope, jmespath_options=jmespath_options + ) if inbound_schema: logger.debug("Validating inbound event") @@ -216,6 +219,8 @@ def handler(event, context): When JMESPath expression to unwrap event is invalid """ if envelope: - event = unwrap_event_from_envelope(data=event, envelope=envelope, jmespath_options=jmespath_options) + event = jmespath_utils.unwrap_event_from_envelope( + data=event, envelope=envelope, jmespath_options=jmespath_options + ) validate_data_against_schema(data=event, schema=schema, formats=formats) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md new file mode 100644 index 00000000000..b450d45806c --- /dev/null +++ b/docs/utilities/feature_flags.md @@ -0,0 +1,57 @@ +--- +title: Feature flags +description: Utility +--- + +The feature flags utility provides a simple rule engine to define when one or multiple features should be enabled depending on the input. + +!!! tip "For simpler use cases where a feature is simply on or off for all users, use [Parameters](parameters.md) utility instead." + +## Terminology + +Feature flags are used to modify a system behaviour without having to change their code. These flags can be static or dynamic. + +**Static feature flags** are commonly used for long-lived behaviours that will rarely change, for example `TRACER_ENABLED=True`. These are better suited for [Parameters utility](parameters.md). + +**Dynamic feature flags** are typically used for experiments where you'd want to enable a feature for a limited set of customers, for example A/B testing and Canary releases. These are better suited for this utility, as you can create multiple conditions on whether a feature flag should be `True` or `False`. + +That being said, be mindful that feature flags can increase your application complexity over time if you're not careful; use them sparingly. + +!!! tip "Read [this article](https://martinfowler.com/articles/feature-toggles.html){target="_blank"} for more details on different types of feature flags and trade-offs" + +## Key features + +> TODO: Revisit once getting started and advanced sections are complete + +* Define simple feature flags to dynamically decide when to enable a feature +* Fetch one or all feature flags enabled for a given application context +* Bring your own configuration store + +## Getting started +### IAM Permissions + +By default, this utility provides AWS AppConfig as a configuration store. As such, you IAM Role needs permission - `appconfig:GetConfiguration` - to fetch feature flags from AppConfig. + +### Creating feature flags + +> NOTE: Explain schema, provide sample boto3 script and CFN to create one + +#### Rules + + + +### Fetching a single feature flag + +### Fetching all feature flags + +### Advanced + +#### Adjusting cache TTL + +### Partially enabling features + +### Bring your own store provider + +## Testing your code + +> NOTE: Share example on how customers can unit test their feature flags diff --git a/mkdocs.yml b/mkdocs.yml index 7ee0fd56236..94dc9980cf1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -25,6 +25,7 @@ nav: - utilities/data_classes.md - utilities/parser.md - utilities/idempotency.md + - utilities/feature_flags.md theme: name: material diff --git a/tests/functional/feature_toggles/__init__.py b/tests/functional/feature_flags/__init__.py similarity index 100% rename from tests/functional/feature_toggles/__init__.py rename to tests/functional/feature_flags/__init__.py diff --git a/tests/functional/feature_flags/test_feature_flags.py b/tests/functional/feature_flags/test_feature_flags.py new file mode 100644 index 00000000000..d2150268062 --- /dev/null +++ b/tests/functional/feature_flags/test_feature_flags.py @@ -0,0 +1,503 @@ +from typing import Dict, List, Optional + +import pytest +from botocore.config import Config + +from aws_lambda_powertools.utilities.feature_flags import ConfigurationStoreError, schema +from aws_lambda_powertools.utilities.feature_flags.appconfig import AppConfigStore +from aws_lambda_powertools.utilities.feature_flags.exceptions import StoreClientError +from aws_lambda_powertools.utilities.feature_flags.feature_flags import FeatureFlags +from aws_lambda_powertools.utilities.feature_flags.schema import RuleAction +from aws_lambda_powertools.utilities.parameters import GetParameterError + + +@pytest.fixture(scope="module") +def config(): + return Config(region_name="us-east-1") + + +def init_feature_flags( + mocker, mock_schema: Dict, config: Config, envelope: str = "", jmespath_options: Optional[Dict] = None +) -> FeatureFlags: + mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.parameters.AppConfigProvider.get") + mocked_get_conf.return_value = mock_schema + + app_conf_fetcher = AppConfigStore( + environment="test_env", + application="test_app", + name="test_conf_name", + cache_seconds=600, + sdk_config=config, + envelope=envelope, + jmespath_options=jmespath_options, + ) + feature_flags: FeatureFlags = FeatureFlags(store=app_conf_fetcher) + return feature_flags + + +def init_fetcher_side_effect(mocker, config: Config, side_effect) -> AppConfigStore: + mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.parameters.AppConfigProvider.get") + mocked_get_conf.side_effect = side_effect + return AppConfigStore( + environment="env", + application="application", + name="conf", + cache_seconds=1, + sdk_config=config, + ) + + +# this test checks that we get correct value of feature that exists in the schema. +# we also don't send an empty context dict in this case +def test_flags_rule_does_not_match(mocker, config): + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": expected_value, + "rules": { + "tenant id equals 345345435": { + "when_match": False, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "345345435", + } + ], + } + }, + } + } + + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={}, default=False) + assert toggle == expected_value + + +# this test checks that if you try to get a feature that doesn't exist in the schema, +# you get the default value of False that was sent to the evaluate API +def test_flags_no_conditions_feature_does_not_exist(mocker, config): + expected_value = False + mocked_app_config_schema = {"my_fake_feature": {"default": True}} + + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={}, default=expected_value) + assert toggle == expected_value + + +# check that feature match works when they are no rules and we send context. +# default value is False but the feature has a True default_value. +def test_flags_no_rules(mocker, config): + expected_value = True + mocked_app_config_schema = {"my_feature": {"default": expected_value}} + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False) + assert toggle == expected_value + + +# check a case where the feature exists but the rule doesn't match so we revert to the default value of the feature +def test_flags_conditions_no_match(mocker, config): + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": expected_value, + "rules": { + "tenant id equals 345345435": { + "when_match": False, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "345345435", + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False) + assert toggle == expected_value + + +# check that a rule can match when it has multiple conditions, see rule name for further explanation +def test_flags_conditions_rule_match_equal_multiple_conditions(mocker, config): + expected_value = False + tenant_id_val = "6" + username_val = "a" + mocked_app_config_schema = { + "my_feature": { + "default": True, + "rules": { + "tenant id equals 6 and username is a": { + "when_match": expected_value, + "conditions": [ + { + "action": RuleAction.EQUALS.value, # this rule will match, it has multiple conditions + "key": "tenant_id", + "value": tenant_id_val, + }, + { + "action": RuleAction.EQUALS.value, + "key": "username", + "value": username_val, + }, + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate( + name="my_feature", + context={ + "tenant_id": tenant_id_val, + "username": username_val, + }, + default=True, + ) + assert toggle == expected_value + + +# check a case when rule doesn't match and it has multiple conditions, +# different tenant id causes the rule to not match. +# default value of the feature in this case is True +def test_flags_conditions_no_rule_match_equal_multiple_conditions(mocker, config): + expected_val = True + mocked_app_config_schema = { + "my_feature": { + "default": expected_val, + "rules": { + # rule will not match + "tenant id equals 645654 and username is a": { + "when_match": False, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "645654", + }, + { + "action": RuleAction.EQUALS.value, + "key": "username", + "value": "a", + }, + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False) + assert toggle == expected_val + + +# check rule match for multiple of action types +def test_flags_conditions_rule_match_multiple_actions_multiple_rules_multiple_conditions(mocker, config): + expected_value_first_check = True + expected_value_second_check = False + expected_value_third_check = False + expected_value_fourth_case = False + mocked_app_config_schema = { + "my_feature": { + "default": expected_value_third_check, + "rules": { + "tenant id equals 6 and username startswith a": { + "when_match": expected_value_first_check, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "6", + }, + { + "action": RuleAction.STARTSWITH.value, + "key": "username", + "value": "a", + }, + ], + }, + "tenant id equals 4446 and username startswith a and endswith z": { + "when_match": expected_value_second_check, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "4446", + }, + { + "action": RuleAction.STARTSWITH.value, + "key": "username", + "value": "a", + }, + { + "action": RuleAction.ENDSWITH.value, + "key": "username", + "value": "z", + }, + ], + }, + }, + } + } + + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + # match first rule + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "abcd"}, default=False) + assert toggle == expected_value_first_check + # match second rule + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "4446", "username": "az"}, default=False) + assert toggle == expected_value_second_check + # match no rule + toggle = feature_flags.evaluate( + name="my_feature", context={"tenant_id": "11114446", "username": "ab"}, default=False + ) + assert toggle == expected_value_third_check + # feature doesn't exist + toggle = feature_flags.evaluate( + name="my_fake_feature", + context={"tenant_id": "11114446", "username": "ab"}, + default=expected_value_fourth_case, + ) + assert toggle == expected_value_fourth_case + + +# check a case where the feature exists but the rule doesn't match so we revert to the default value of the feature +def test_flags_match_rule_with_contains_action(mocker, config): + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": False, + "rules": { + "tenant id is contained in [6, 2]": { + "when_match": expected_value, + "conditions": [ + { + "action": RuleAction.CONTAINS.value, + "key": "tenant_id", + "value": ["6", "2"], + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False) + assert toggle == expected_value + + +def test_flags_no_match_rule_with_contains_action(mocker, config): + expected_value = False + mocked_app_config_schema = { + "my_feature": { + "default": expected_value, + "rules": { + "tenant id is contained in [8, 2]": { + "when_match": True, + "conditions": [ + { + "action": RuleAction.CONTAINS.value, + "key": "tenant_id", + "value": ["8", "2"], + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False) + assert toggle == expected_value + + +def test_multiple_features_enabled(mocker, config): + expected_value = ["my_feature", "my_feature2"] + mocked_app_config_schema = { + "my_feature": { + "default": False, + "rules": { + "tenant id is contained in [6, 2]": { + "when_match": True, + "conditions": [ + { + "action": RuleAction.CONTAINS.value, + "key": "tenant_id", + "value": ["6", "2"], + } + ], + } + }, + }, + "my_feature2": { + "default": True, + }, + "my_feature3": { + "default": False, + }, + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + enabled_list: List[str] = feature_flags.get_enabled_features(context={"tenant_id": "6", "username": "a"}) + assert enabled_list == expected_value + + +def test_multiple_features_only_some_enabled(mocker, config): + expected_value = ["my_feature", "my_feature2", "my_feature4"] + mocked_app_config_schema = { + "my_feature": { # rule will match here, feature is enabled due to rule match + "default": False, + "rules": { + "tenant id is contained in [6, 2]": { + "when_match": True, + "conditions": [ + { + "action": RuleAction.CONTAINS.value, + "key": "tenant_id", + "value": ["6", "2"], + } + ], + } + }, + }, + "my_feature2": { + "default": True, + }, + "my_feature3": { + "default": False, + }, + # rule will not match here, feature is enabled by default + "my_feature4": { + "default": True, + "rules": { + "tenant id equals 7": { + "when_match": False, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "7", + } + ], + } + }, + }, + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + enabled_list: List[str] = feature_flags.get_enabled_features(context={"tenant_id": "6", "username": "a"}) + assert enabled_list == expected_value + + +def test_get_feature_toggle_handles_error(mocker, config): + # GIVEN a schema fetch that raises a ConfigurationStoreError + schema_fetcher = init_fetcher_side_effect(mocker, config, GetParameterError()) + feature_flags = FeatureFlags(schema_fetcher) + + # WHEN calling evaluate + toggle = feature_flags.evaluate(name="Foo", default=False) + + # THEN handle the error and return the default + assert toggle is False + + +def test_get_all_enabled_feature_flags_handles_error(mocker, config): + # GIVEN a schema fetch that raises a ConfigurationStoreError + schema_fetcher = init_fetcher_side_effect(mocker, config, GetParameterError()) + feature_flags = FeatureFlags(schema_fetcher) + + # WHEN calling get_enabled_features + flags = feature_flags.get_enabled_features(context=None) + + # THEN handle the error and return an empty list + assert flags == [] + + +def test_app_config_get_parameter_err(mocker, config): + # GIVEN an appconfig with a missing config + app_conf_fetcher = init_fetcher_side_effect(mocker, config, GetParameterError()) + + # WHEN calling get_configuration + with pytest.raises(ConfigurationStoreError) as err: + app_conf_fetcher.get_configuration() + + # THEN raise ConfigurationStoreError error + assert "AWS AppConfig configuration" in str(err.value) + + +def test_match_by_action_no_matching_action(mocker, config): + # GIVEN an unsupported action + feature_flags = init_feature_flags(mocker, {}, config) + # WHEN calling _match_by_action + result = feature_flags._match_by_action("Foo", None, "foo") + # THEN default to False + assert result is False + + +def test_match_by_action_attribute_error(mocker, config): + # GIVEN a startswith action and 2 integer + feature_flags = init_feature_flags(mocker, {}, config) + # WHEN calling _match_by_action + result = feature_flags._match_by_action(RuleAction.STARTSWITH.value, 1, 100) + # THEN swallow the AttributeError and return False + assert result is False + + +def test_is_rule_matched_no_matches(mocker, config): + # GIVEN an empty list of conditions + rule = {schema.CONDITIONS_KEY: []} + rules_context = {} + feature_flags = init_feature_flags(mocker, {}, config) + + # WHEN calling _evaluate_conditions + result = feature_flags._evaluate_conditions( + rule_name="dummy", feature_name="dummy", rule=rule, context=rules_context + ) + + # THEN return False + assert result is False + + +def test_features_jmespath_envelope(mocker, config): + expected_value = True + mocked_app_config_schema = {"features": {"my_feature": {"default": expected_value}}} + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config, envelope="features") + toggle = feature_flags.evaluate(name="my_feature", context={}, default=False) + assert toggle == expected_value + + +# test_match_rule_with_contains_action +def test_match_condition_with_dict_value(mocker, config): + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": False, + "rules": { + "tenant id is 6 and username is lessa": { + "when_match": expected_value, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant", + "value": {"tenant_id": "6", "username": "lessa"}, + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + ctx = {"tenant": {"tenant_id": "6", "username": "lessa"}} + toggle = feature_flags.evaluate(name="my_feature", context=ctx, default=False) + assert toggle == expected_value + + +def test_get_feature_toggle_propagates_access_denied_error(mocker, config): + # GIVEN a schema fetch that raises a StoreClientError + # due to client invalid permissions to fetch from the store + err = "An error occurred (AccessDeniedException) when calling the GetConfiguration operation" + schema_fetcher = init_fetcher_side_effect(mocker, config, GetParameterError(err)) + feature_flags = FeatureFlags(schema_fetcher) + + # WHEN calling evaluate + # THEN raise StoreClientError error + with pytest.raises(StoreClientError, match="AccessDeniedException") as err: + feature_flags.evaluate(name="Foo", default=False) diff --git a/tests/functional/feature_flags/test_schema_validation.py b/tests/functional/feature_flags/test_schema_validation.py new file mode 100644 index 00000000000..2c33d3c61cc --- /dev/null +++ b/tests/functional/feature_flags/test_schema_validation.py @@ -0,0 +1,282 @@ +import logging + +import pytest # noqa: F401 + +from aws_lambda_powertools.utilities.feature_flags.exceptions import SchemaValidationError +from aws_lambda_powertools.utilities.feature_flags.schema import ( + CONDITION_ACTION, + CONDITION_KEY, + CONDITION_VALUE, + CONDITIONS_KEY, + FEATURE_DEFAULT_VAL_KEY, + RULE_MATCH_VALUE, + RULES_KEY, + ConditionsValidator, + RuleAction, + RulesValidator, + SchemaValidator, +) + +logger = logging.getLogger(__name__) + +EMPTY_SCHEMA = {"": ""} + + +def test_invalid_features_dict(): + validator = SchemaValidator(schema=[]) + with pytest.raises(SchemaValidationError): + validator.validate() + + +def test_empty_features_not_fail(): + validator = SchemaValidator(schema={}) + validator.validate() + + +@pytest.mark.parametrize( + "schema", + [ + pytest.param({"my_feature": []}, id="feat_as_list"), + pytest.param({"my_feature": {}}, id="feat_empty_dict"), + pytest.param({"my_feature": {FEATURE_DEFAULT_VAL_KEY: "False"}}, id="feat_default_non_bool"), + pytest.param({"my_feature": {FEATURE_DEFAULT_VAL_KEY: False, RULES_KEY: "4"}}, id="feat_rules_non_dict"), + pytest.param("%<>[]{}|^", id="unsafe-rfc3986"), + ], +) +def test_invalid_feature(schema): + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + +def test_valid_feature_dict(): + # empty rules list + schema = {"my_feature": {FEATURE_DEFAULT_VAL_KEY: False, RULES_KEY: []}} + validator = SchemaValidator(schema) + validator.validate() + + # no rules list at all + schema = {"my_feature": {FEATURE_DEFAULT_VAL_KEY: False}} + validator = SchemaValidator(schema) + validator.validate() + + +def test_invalid_rule(): + # rules list is not a list of dict + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: [ + "a", + "b", + ], + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + # rules RULE_MATCH_VALUE is not bool + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 345345435": { + RULE_MATCH_VALUE: "False", + } + }, + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + # missing conditions list + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 345345435": { + RULE_MATCH_VALUE: False, + } + }, + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + # condition list is empty + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 345345435": {RULE_MATCH_VALUE: False, CONDITIONS_KEY: []}, + }, + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + # condition is invalid type, not list + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 345345435": {RULE_MATCH_VALUE: False, CONDITIONS_KEY: {}}, + }, + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + +def test_invalid_condition(): + # invalid condition action + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 345345435": { + RULE_MATCH_VALUE: False, + CONDITIONS_KEY: {CONDITION_ACTION: "stuff", CONDITION_KEY: "a", CONDITION_VALUE: "a"}, + } + }, + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + # missing condition key and value + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 345345435": { + RULE_MATCH_VALUE: False, + CONDITIONS_KEY: {CONDITION_ACTION: RuleAction.EQUALS.value}, + } + }, + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + # invalid condition key type, not string + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 345345435": { + RULE_MATCH_VALUE: False, + CONDITIONS_KEY: { + CONDITION_ACTION: RuleAction.EQUALS.value, + CONDITION_KEY: 5, + CONDITION_VALUE: "a", + }, + } + }, + } + } + validator = SchemaValidator(schema) + with pytest.raises(SchemaValidationError): + validator.validate() + + +def test_valid_condition_all_actions(): + schema = { + "my_feature": { + FEATURE_DEFAULT_VAL_KEY: False, + RULES_KEY: { + "tenant id equals 645654 and username is a": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.EQUALS.value, + CONDITION_KEY: "tenant_id", + CONDITION_VALUE: "645654", + }, + { + CONDITION_ACTION: RuleAction.STARTSWITH.value, + CONDITION_KEY: "username", + CONDITION_VALUE: "a", + }, + { + CONDITION_ACTION: RuleAction.ENDSWITH.value, + CONDITION_KEY: "username", + CONDITION_VALUE: "a", + }, + { + CONDITION_ACTION: RuleAction.CONTAINS.value, + CONDITION_KEY: "username", + CONDITION_VALUE: ["a", "b"], + }, + ], + } + }, + } + } + validator = SchemaValidator(schema) + validator.validate() + + +def test_validate_condition_invalid_condition_type(): + # GIVEN an invalid condition type of empty dict + condition = {} + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises(SchemaValidationError, match="Feature rule condition must be a dictionary"): + ConditionsValidator.validate_condition(condition=condition, rule_name="dummy") + + +def test_validate_condition_invalid_condition_action(): + # GIVEN an invalid condition action of foo + condition = {"action": "INVALID", "key": "tenant_id", "value": "12345"} + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises(SchemaValidationError, match="'action' value must be either"): + ConditionsValidator.validate_condition_action(condition=condition, rule_name="dummy") + + +def test_validate_condition_invalid_condition_key(): + # GIVEN a configuration with a missing "key" + condition = {"action": RuleAction.EQUALS.value, "value": "12345"} + + # WHEN calling validate_condition + # THEN raise SchemaValidationError + with pytest.raises(SchemaValidationError, match="'key' value must be a non empty string"): + ConditionsValidator.validate_condition_key(condition=condition, rule_name="dummy") + + +def test_validate_condition_missing_condition_value(): + # GIVEN a configuration with a missing condition value + condition = { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + } + + # WHEN calling validate_condition + with pytest.raises(SchemaValidationError, match="'value' key must not be empty"): + ConditionsValidator.validate_condition_value(condition=condition, rule_name="dummy") + + +def test_validate_rule_invalid_rule_type(): + # GIVEN an invalid rule type of empty list + # WHEN calling validate_rule + # THEN raise SchemaValidationError + with pytest.raises(SchemaValidationError, match="Feature rule must be a dictionary"): + RulesValidator.validate_rule(rule=[], rule_name="dummy", feature_name="dummy") + + +def test_validate_rule_invalid_rule_name(): + # GIVEN a rule name is empty + # WHEN calling validate_rule_name + # THEN raise SchemaValidationError + with pytest.raises(SchemaValidationError, match="Rule name key must have a non-empty string"): + RulesValidator.validate_rule_name(rule_name="", feature_name="dummy") diff --git a/tests/functional/feature_toggles/test_feature_toggles.py b/tests/functional/feature_toggles/test_feature_toggles.py deleted file mode 100644 index bb4b8f24dfc..00000000000 --- a/tests/functional/feature_toggles/test_feature_toggles.py +++ /dev/null @@ -1,503 +0,0 @@ -from typing import Dict, List - -import pytest -from botocore.config import Config - -from aws_lambda_powertools.utilities.feature_toggles import ConfigurationError, schema -from aws_lambda_powertools.utilities.feature_toggles.appconfig_fetcher import AppConfigFetcher -from aws_lambda_powertools.utilities.feature_toggles.configuration_store import ConfigurationStore -from aws_lambda_powertools.utilities.feature_toggles.schema import ACTION -from aws_lambda_powertools.utilities.parameters import GetParameterError - - -@pytest.fixture(scope="module") -def config(): - return Config(region_name="us-east-1") - - -def init_configuration_store(mocker, mock_schema: Dict, config: Config) -> ConfigurationStore: - mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.parameters.AppConfigProvider.get") - mocked_get_conf.return_value = mock_schema - - app_conf_fetcher = AppConfigFetcher( - environment="test_env", - service="test_app", - configuration_name="test_conf_name", - cache_seconds=600, - config=config, - ) - conf_store: ConfigurationStore = ConfigurationStore(schema_fetcher=app_conf_fetcher) - return conf_store - - -def init_fetcher_side_effect(mocker, config: Config, side_effect) -> AppConfigFetcher: - mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.parameters.AppConfigProvider.get") - mocked_get_conf.side_effect = side_effect - return AppConfigFetcher( - environment="env", - service="service", - configuration_name="conf", - cache_seconds=1, - config=config, - ) - - -# this test checks that we get correct value of feature that exists in the schema. -# we also don't send an empty rules_context dict in this case -def test_toggles_rule_does_not_match(mocker, config): - expected_value = True - mocked_app_config_schema = { - "features": { - "my_feature": { - "feature_default_value": expected_value, - "rules": [ - { - "rule_name": "tenant id equals 345345435", - "value_when_applies": False, - "conditions": [ - { - "action": ACTION.EQUALS.value, - "key": "tenant_id", - "value": "345345435", - } - ], - }, - ], - } - }, - } - - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - toggle = conf_store.get_feature_toggle(feature_name="my_feature", rules_context={}, value_if_missing=False) - assert toggle == expected_value - - -# this test checks that if you try to get a feature that doesn't exist in the schema, -# you get the default value of False that was sent to the get_feature_toggle API -def test_toggles_no_conditions_feature_does_not_exist(mocker, config): - expected_value = False - mocked_app_config_schema = {"features": {"my_fake_feature": {"feature_default_value": True}}} - - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - toggle = conf_store.get_feature_toggle(feature_name="my_feature", rules_context={}, value_if_missing=expected_value) - assert toggle == expected_value - - -# check that feature match works when they are no rules and we send rules_context. -# default value is False but the feature has a True default_value. -def test_toggles_no_rules(mocker, config): - expected_value = True - mocked_app_config_schema = {"features": {"my_feature": {"feature_default_value": expected_value}}} - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - toggle = conf_store.get_feature_toggle( - feature_name="my_feature", rules_context={"tenant_id": "6", "username": "a"}, value_if_missing=False - ) - assert toggle == expected_value - - -# check a case where the feature exists but the rule doesn't match so we revert to the default value of the feature -def test_toggles_conditions_no_match(mocker, config): - expected_value = True - mocked_app_config_schema = { - "features": { - "my_feature": { - "feature_default_value": expected_value, - "rules": [ - { - "rule_name": "tenant id equals 345345435", - "value_when_applies": False, - "conditions": [ - { - "action": ACTION.EQUALS.value, - "key": "tenant_id", - "value": "345345435", - } - ], - }, - ], - } - }, - } - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - toggle = conf_store.get_feature_toggle( - feature_name="my_feature", - rules_context={"tenant_id": "6", "username": "a"}, # rule will not match - value_if_missing=False, - ) - assert toggle == expected_value - - -# check that a rule can match when it has multiple conditions, see rule name for further explanation -def test_toggles_conditions_rule_match_equal_multiple_conditions(mocker, config): - expected_value = False - tenant_id_val = "6" - username_val = "a" - mocked_app_config_schema = { - "features": { - "my_feature": { - "feature_default_value": True, - "rules": [ - { - "rule_name": "tenant id equals 6 and username is a", - "value_when_applies": expected_value, - "conditions": [ - { - "action": ACTION.EQUALS.value, # this rule will match, it has multiple conditions - "key": "tenant_id", - "value": tenant_id_val, - }, - { - "action": ACTION.EQUALS.value, - "key": "username", - "value": username_val, - }, - ], - }, - ], - } - }, - } - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - toggle = conf_store.get_feature_toggle( - feature_name="my_feature", - rules_context={ - "tenant_id": tenant_id_val, - "username": username_val, - }, - value_if_missing=True, - ) - assert toggle == expected_value - - -# check a case when rule doesn't match and it has multiple conditions, -# different tenant id causes the rule to not match. -# default value of the feature in this case is True -def test_toggles_conditions_no_rule_match_equal_multiple_conditions(mocker, config): - expected_val = True - mocked_app_config_schema = { - "features": { - "my_feature": { - "feature_default_value": expected_val, - "rules": [ - { - "rule_name": "tenant id equals 645654 and username is a", # rule will not match - "value_when_applies": False, - "conditions": [ - { - "action": ACTION.EQUALS.value, - "key": "tenant_id", - "value": "645654", - }, - { - "action": ACTION.EQUALS.value, - "key": "username", - "value": "a", - }, - ], - }, - ], - } - }, - } - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - toggle = conf_store.get_feature_toggle( - feature_name="my_feature", rules_context={"tenant_id": "6", "username": "a"}, value_if_missing=False - ) - assert toggle == expected_val - - -# check rule match for multiple of action types -def test_toggles_conditions_rule_match_multiple_actions_multiple_rules_multiple_conditions(mocker, config): - expected_value_first_check = True - expected_value_second_check = False - expected_value_third_check = False - expected_value_fourth_case = False - mocked_app_config_schema = { - "features": { - "my_feature": { - "feature_default_value": expected_value_third_check, - "rules": [ - { - "rule_name": "tenant id equals 6 and username startswith a", - "value_when_applies": expected_value_first_check, - "conditions": [ - { - "action": ACTION.EQUALS.value, - "key": "tenant_id", - "value": "6", - }, - { - "action": ACTION.STARTSWITH.value, - "key": "username", - "value": "a", - }, - ], - }, - { - "rule_name": "tenant id equals 4446 and username startswith a and endswith z", - "value_when_applies": expected_value_second_check, - "conditions": [ - { - "action": ACTION.EQUALS.value, - "key": "tenant_id", - "value": "4446", - }, - { - "action": ACTION.STARTSWITH.value, - "key": "username", - "value": "a", - }, - { - "action": ACTION.ENDSWITH.value, - "key": "username", - "value": "z", - }, - ], - }, - ], - } - }, - } - - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - # match first rule - toggle = conf_store.get_feature_toggle( - feature_name="my_feature", - rules_context={"tenant_id": "6", "username": "abcd"}, - value_if_missing=False, - ) - assert toggle == expected_value_first_check - # match second rule - toggle = conf_store.get_feature_toggle( - feature_name="my_feature", - rules_context={"tenant_id": "4446", "username": "az"}, - value_if_missing=False, - ) - assert toggle == expected_value_second_check - # match no rule - toggle = conf_store.get_feature_toggle( - feature_name="my_feature", - rules_context={"tenant_id": "11114446", "username": "ab"}, - value_if_missing=False, - ) - assert toggle == expected_value_third_check - # feature doesn't exist - toggle = conf_store.get_feature_toggle( - feature_name="my_fake_feature", - rules_context={"tenant_id": "11114446", "username": "ab"}, - value_if_missing=expected_value_fourth_case, - ) - assert toggle == expected_value_fourth_case - - -# check a case where the feature exists but the rule doesn't match so we revert to the default value of the feature -def test_toggles_match_rule_with_contains_action(mocker, config): - expected_value = True - mocked_app_config_schema = { - "features": { - "my_feature": { - "feature_default_value": False, - "rules": [ - { - "rule_name": "tenant id is contained in [6,2] ", - "value_when_applies": expected_value, - "conditions": [ - { - "action": ACTION.CONTAINS.value, - "key": "tenant_id", - "value": ["6", "2"], - } - ], - }, - ], - } - }, - } - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - toggle = conf_store.get_feature_toggle( - feature_name="my_feature", - rules_context={"tenant_id": "6", "username": "a"}, # rule will match - value_if_missing=False, - ) - assert toggle == expected_value - - -def test_toggles_no_match_rule_with_contains_action(mocker, config): - expected_value = False - mocked_app_config_schema = { - "features": { - "my_feature": { - "feature_default_value": expected_value, - "rules": [ - { - "rule_name": "tenant id is contained in [6,2] ", - "value_when_applies": True, - "conditions": [ - { - "action": ACTION.CONTAINS.value, - "key": "tenant_id", - "value": ["8", "2"], - } - ], - }, - ], - } - }, - } - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - toggle = conf_store.get_feature_toggle( - feature_name="my_feature", - rules_context={"tenant_id": "6", "username": "a"}, # rule will not match - value_if_missing=False, - ) - assert toggle == expected_value - - -def test_multiple_features_enabled(mocker, config): - expected_value = ["my_feature", "my_feature2"] - mocked_app_config_schema = { - "features": { - "my_feature": { - "feature_default_value": False, - "rules": [ - { - "rule_name": "tenant id is contained in [6,2] ", - "value_when_applies": True, - "conditions": [ - { - "action": ACTION.CONTAINS.value, - "key": "tenant_id", - "value": ["6", "2"], - } - ], - }, - ], - }, - "my_feature2": { - "feature_default_value": True, - }, - }, - } - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - enabled_list: List[str] = conf_store.get_all_enabled_feature_toggles( - rules_context={"tenant_id": "6", "username": "a"} - ) - assert enabled_list == expected_value - - -def test_multiple_features_only_some_enabled(mocker, config): - expected_value = ["my_feature", "my_feature2", "my_feature4"] - mocked_app_config_schema = { - "features": { - "my_feature": { # rule will match here, feature is enabled due to rule match - "feature_default_value": False, - "rules": [ - { - "rule_name": "tenant id is contained in [6,2] ", - "value_when_applies": True, - "conditions": [ - { - "action": ACTION.CONTAINS.value, - "key": "tenant_id", - "value": ["6", "2"], - } - ], - }, - ], - }, - "my_feature2": { - "feature_default_value": True, - }, - "my_feature3": { - "feature_default_value": False, - }, - "my_feature4": { # rule will not match here, feature is enabled by default - "feature_default_value": True, - "rules": [ - { - "rule_name": "tenant id equals 7", - "value_when_applies": False, - "conditions": [ - { - "action": ACTION.EQUALS.value, - "key": "tenant_id", - "value": "7", - } - ], - }, - ], - }, - }, - } - conf_store = init_configuration_store(mocker, mocked_app_config_schema, config) - enabled_list: List[str] = conf_store.get_all_enabled_feature_toggles( - rules_context={"tenant_id": "6", "username": "a"} - ) - assert enabled_list == expected_value - - -def test_get_feature_toggle_handles_error(mocker, config): - # GIVEN a schema fetch that raises a ConfigurationError - schema_fetcher = init_fetcher_side_effect(mocker, config, GetParameterError()) - conf_store = ConfigurationStore(schema_fetcher) - - # WHEN calling get_feature_toggle - toggle = conf_store.get_feature_toggle(feature_name="Foo", value_if_missing=False) - - # THEN handle the error and return the value_if_missing - assert toggle is False - - -def test_get_all_enabled_feature_toggles_handles_error(mocker, config): - # GIVEN a schema fetch that raises a ConfigurationError - schema_fetcher = init_fetcher_side_effect(mocker, config, GetParameterError()) - conf_store = ConfigurationStore(schema_fetcher) - - # WHEN calling get_all_enabled_feature_toggles - toggles = conf_store.get_all_enabled_feature_toggles(rules_context=None) - - # THEN handle the error and return an empty list - assert toggles == [] - - -def test_app_config_get_parameter_err(mocker, config): - # GIVEN an appconfig with a missing config - app_conf_fetcher = init_fetcher_side_effect(mocker, config, GetParameterError()) - - # WHEN calling get_json_configuration - with pytest.raises(ConfigurationError) as err: - app_conf_fetcher.get_json_configuration() - - # THEN raise ConfigurationError error - assert "AWS AppConfig configuration" in str(err.value) - - -def test_match_by_action_no_matching_action(mocker, config): - # GIVEN an unsupported action - conf_store = init_configuration_store(mocker, {}, config) - # WHEN calling _match_by_action - result = conf_store._match_by_action("Foo", None, "foo") - # THEN default to False - assert result is False - - -def test_match_by_action_attribute_error(mocker, config): - # GIVEN a startswith action and 2 integer - conf_store = init_configuration_store(mocker, {}, config) - # WHEN calling _match_by_action - result = conf_store._match_by_action(ACTION.STARTSWITH.value, 1, 100) - # THEN swallow the AttributeError and return False - assert result is False - - -def test_is_rule_matched_no_matches(mocker, config): - # GIVEN an empty list of conditions - rule = {schema.CONDITIONS_KEY: []} - rules_context = {} - conf_store = init_configuration_store(mocker, {}, config) - - # WHEN calling _is_rule_matched - result = conf_store._is_rule_matched("feature_name", rule, rules_context) - - # THEN return False - assert result is False diff --git a/tests/functional/feature_toggles/test_schema_validation.py b/tests/functional/feature_toggles/test_schema_validation.py deleted file mode 100644 index 184f448322a..00000000000 --- a/tests/functional/feature_toggles/test_schema_validation.py +++ /dev/null @@ -1,330 +0,0 @@ -import logging - -import pytest # noqa: F401 - -from aws_lambda_powertools.utilities.feature_toggles.exceptions import ConfigurationError -from aws_lambda_powertools.utilities.feature_toggles.schema import ( - ACTION, - CONDITION_ACTION, - CONDITION_KEY, - CONDITION_VALUE, - CONDITIONS_KEY, - FEATURE_DEFAULT_VAL_KEY, - FEATURES_KEY, - RULE_DEFAULT_VALUE, - RULE_NAME_KEY, - RULES_KEY, - SchemaValidator, -) - -logger = logging.getLogger(__name__) - - -def test_invalid_features_dict(): - schema = {} - # empty dict - validator = SchemaValidator(logger) - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - schema = [] - # invalid type - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # invalid features key - schema = {FEATURES_KEY: []} - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - -def test_empty_features_not_fail(): - schema = {FEATURES_KEY: {}} - validator = SchemaValidator(logger) - validator.validate_json_schema(schema) - - -def test_invalid_feature_dict(): - # invalid feature type, not dict - schema = {FEATURES_KEY: {"my_feature": []}} - validator = SchemaValidator(logger) - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # empty feature dict - schema = {FEATURES_KEY: {"my_feature": {}}} - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # invalid FEATURE_DEFAULT_VAL_KEY type, not boolean - schema = {FEATURES_KEY: {"my_feature": {FEATURE_DEFAULT_VAL_KEY: "False"}}} - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # invalid FEATURE_DEFAULT_VAL_KEY type, not boolean #2 - schema = {FEATURES_KEY: {"my_feature": {FEATURE_DEFAULT_VAL_KEY: 5}}} - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # invalid rules type, not list - schema = {FEATURES_KEY: {"my_feature": {FEATURE_DEFAULT_VAL_KEY: False, RULES_KEY: "4"}}} - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - -def test_valid_feature_dict(): - # no rules list at all - schema = {FEATURES_KEY: {"my_feature": {FEATURE_DEFAULT_VAL_KEY: False}}} - validator = SchemaValidator(logger) - validator.validate_json_schema(schema) - - # empty rules list - schema = {FEATURES_KEY: {"my_feature": {FEATURE_DEFAULT_VAL_KEY: False, RULES_KEY: []}}} - validator.validate_json_schema(schema) - - -def test_invalid_rule(): - # rules list is not a list of dict - schema = { - FEATURES_KEY: { - "my_feature": { - FEATURE_DEFAULT_VAL_KEY: False, - RULES_KEY: [ - "a", - "b", - ], - } - } - } - validator = SchemaValidator(logger) - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # rules RULE_DEFAULT_VALUE is not bool - schema = { - FEATURES_KEY: { - "my_feature": { - FEATURE_DEFAULT_VAL_KEY: False, - RULES_KEY: [ - { - RULE_NAME_KEY: "tenant id equals 345345435", - RULE_DEFAULT_VALUE: "False", - }, - ], - } - } - } - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # missing conditions list - schema = { - FEATURES_KEY: { - "my_feature": { - FEATURE_DEFAULT_VAL_KEY: False, - RULES_KEY: [ - { - RULE_NAME_KEY: "tenant id equals 345345435", - RULE_DEFAULT_VALUE: False, - }, - ], - } - } - } - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # condition list is empty - schema = { - FEATURES_KEY: { - "my_feature": { - FEATURE_DEFAULT_VAL_KEY: False, - RULES_KEY: [ - {RULE_NAME_KEY: "tenant id equals 345345435", RULE_DEFAULT_VALUE: False, CONDITIONS_KEY: []}, - ], - } - } - } - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # condition is invalid type, not list - schema = { - FEATURES_KEY: { - "my_feature": { - FEATURE_DEFAULT_VAL_KEY: False, - RULES_KEY: [ - {RULE_NAME_KEY: "tenant id equals 345345435", RULE_DEFAULT_VALUE: False, CONDITIONS_KEY: {}}, - ], - } - } - } - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - -def test_invalid_condition(): - # invalid condition action - schema = { - FEATURES_KEY: { - "my_feature": { - FEATURE_DEFAULT_VAL_KEY: False, - RULES_KEY: [ - { - RULE_NAME_KEY: "tenant id equals 345345435", - RULE_DEFAULT_VALUE: False, - CONDITIONS_KEY: {CONDITION_ACTION: "stuff", CONDITION_KEY: "a", CONDITION_VALUE: "a"}, - }, - ], - } - } - } - validator = SchemaValidator(logger) - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # missing condition key and value - schema = { - FEATURES_KEY: { - "my_feature": { - FEATURE_DEFAULT_VAL_KEY: False, - RULES_KEY: [ - { - RULE_NAME_KEY: "tenant id equals 345345435", - RULE_DEFAULT_VALUE: False, - CONDITIONS_KEY: {CONDITION_ACTION: ACTION.EQUALS.value}, - }, - ], - } - } - } - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - # invalid condition key type, not string - schema = { - FEATURES_KEY: { - "my_feature": { - FEATURE_DEFAULT_VAL_KEY: False, - RULES_KEY: [ - { - RULE_NAME_KEY: "tenant id equals 345345435", - RULE_DEFAULT_VALUE: False, - CONDITIONS_KEY: { - CONDITION_ACTION: ACTION.EQUALS.value, - CONDITION_KEY: 5, - CONDITION_VALUE: "a", - }, - }, - ], - } - } - } - with pytest.raises(ConfigurationError): - validator.validate_json_schema(schema) - - -def test_valid_condition_all_actions(): - validator = SchemaValidator(logger) - schema = { - FEATURES_KEY: { - "my_feature": { - FEATURE_DEFAULT_VAL_KEY: False, - RULES_KEY: [ - { - RULE_NAME_KEY: "tenant id equals 645654 and username is a", - RULE_DEFAULT_VALUE: True, - CONDITIONS_KEY: [ - { - CONDITION_ACTION: ACTION.EQUALS.value, - CONDITION_KEY: "tenant_id", - CONDITION_VALUE: "645654", - }, - { - CONDITION_ACTION: ACTION.STARTSWITH.value, - CONDITION_KEY: "username", - CONDITION_VALUE: "a", - }, - { - CONDITION_ACTION: ACTION.ENDSWITH.value, - CONDITION_KEY: "username", - CONDITION_VALUE: "a", - }, - { - CONDITION_ACTION: ACTION.CONTAINS.value, - CONDITION_KEY: "username", - CONDITION_VALUE: ["a", "b"], - }, - ], - }, - ], - } - }, - } - validator.validate_json_schema(schema) - - -def test_validate_condition_invalid_condition_type(): - # GIVEN an invalid condition type of empty dict - validator = SchemaValidator(logger) - condition = {} - - # WHEN calling _validate_condition - with pytest.raises(ConfigurationError) as err: - validator._validate_condition("foo", condition) - - # THEN raise ConfigurationError - assert "invalid condition type" in str(err) - - -def test_validate_condition_invalid_condition_action(): - # GIVEN an invalid condition action of foo - validator = SchemaValidator(logger) - condition = {"action": "foo"} - - # WHEN calling _validate_condition - with pytest.raises(ConfigurationError) as err: - validator._validate_condition("foo", condition) - - # THEN raise ConfigurationError - assert "invalid action value" in str(err) - - -def test_validate_condition_invalid_condition_key(): - # GIVEN a configuration with a missing "key" - validator = SchemaValidator(logger) - condition = {"action": ACTION.EQUALS.value} - - # WHEN calling _validate_condition - with pytest.raises(ConfigurationError) as err: - validator._validate_condition("foo", condition) - - # THEN raise ConfigurationError - assert "invalid key value" in str(err) - - -def test_validate_condition_missing_condition_value(): - # GIVEN a configuration with a missing condition value - validator = SchemaValidator(logger) - condition = {"action": ACTION.EQUALS.value, "key": "Foo"} - - # WHEN calling _validate_condition - with pytest.raises(ConfigurationError) as err: - validator._validate_condition("foo", condition) - - # THEN raise ConfigurationError - assert "missing condition value" in str(err) - - -def test_validate_rule_invalid_rule_name(): - # GIVEN a rule_name not in the rule dict - validator = SchemaValidator(logger) - rule_name = "invalid_rule_name" - rule = {"missing": ""} - - # WHEN calling _validate_rule - with pytest.raises(ConfigurationError) as err: - validator._validate_rule(rule_name, rule) - - # THEN raise ConfigurationError - assert "invalid rule_name" in str(err) diff --git a/tests/functional/idempotency/conftest.py b/tests/functional/idempotency/conftest.py index e100957dee7..9f61d50d656 100644 --- a/tests/functional/idempotency/conftest.py +++ b/tests/functional/idempotency/conftest.py @@ -11,11 +11,11 @@ from botocore.config import Config from jmespath import functions +from aws_lambda_powertools.shared.jmespath_utils import unwrap_event_from_envelope from aws_lambda_powertools.shared.json_encoder import Encoder from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer from aws_lambda_powertools.utilities.idempotency.idempotency import IdempotencyConfig from aws_lambda_powertools.utilities.validation import envelopes -from aws_lambda_powertools.utilities.validation.base import unwrap_event_from_envelope from tests.functional.utils import load_event TABLE_NAME = "TEST_TABLE" From 4eb4aaaf54384f45eb647e173e2e3050d5823f3b Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Wed, 4 Aug 2021 21:35:36 -0700 Subject: [PATCH 11/35] docs(feature-toggles): correct docs and typing (#588) --- .../utilities/feature_flags/appconfig.py | 6 +-- .../utilities/feature_flags/base.py | 39 ++++++++-------- .../utilities/feature_flags/feature_flags.py | 46 +++++++++---------- .../utilities/feature_flags/schema.py | 6 +-- 4 files changed, 49 insertions(+), 48 deletions(-) diff --git a/aws_lambda_powertools/utilities/feature_flags/appconfig.py b/aws_lambda_powertools/utilities/feature_flags/appconfig.py index 6c075eac1a1..df506940ee1 100644 --- a/aws_lambda_powertools/utilities/feature_flags/appconfig.py +++ b/aws_lambda_powertools/utilities/feature_flags/appconfig.py @@ -23,7 +23,7 @@ def __init__( name: str, cache_seconds: int, sdk_config: Optional[Config] = None, - envelope: str = "", + envelope: Optional[str] = "", jmespath_options: Optional[Dict] = None, ): """This class fetches JSON schemas from AWS AppConfig @@ -40,9 +40,9 @@ def __init__( cache expiration time, how often to call AppConfig to fetch latest configuration sdk_config: Optional[Config] Botocore Config object to pass during client initialization - envelope : str + envelope : Optional[str] JMESPath expression to pluck feature flags data from config - jmespath_options : Dict + jmespath_options : Optional[Dict] Alternative JMESPath options to be included when filtering expr """ super().__init__() diff --git a/aws_lambda_powertools/utilities/feature_flags/base.py b/aws_lambda_powertools/utilities/feature_flags/base.py index 1df90f19ac8..edb94c4f45d 100644 --- a/aws_lambda_powertools/utilities/feature_flags/base.py +++ b/aws_lambda_powertools/utilities/feature_flags/base.py @@ -19,27 +19,28 @@ def get_configuration(self) -> Dict[str, Any]: **Example** - ```python - { - "premium_features": { - "default": False, - "rules": { - "customer tier equals premium": { - "when_match": True, - "conditions": [ - { - "action": "EQUALS", - "key": "tier", - "value": "premium", - } - ], - } - }, + ```python + { + "premium_features": { + "default": False, + "rules": { + "customer tier equals premium": { + "when_match": True, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium", + } + ], + } }, - "feature_two": { - "default": False - } + }, + "feature_two": { + "default": False } + } + ``` """ return NotImplemented # pragma: no cover diff --git a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py index a862baf61c2..3d913de98d4 100644 --- a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py +++ b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py @@ -115,28 +115,28 @@ def get_configuration(self) -> Union[Dict[str, Dict], Dict]: **Example** - ```python - { - "premium_features": { - "default": False, - "rules": { - "customer tier equals premium": { - "when_match": True, - "conditions": [ - { - "action": "EQUALS", - "key": "tier", - "value": "premium", - } - ], - } - }, + ```python + { + "premium_features": { + "default": False, + "rules": { + "customer tier equals premium": { + "when_match": True, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium", + } + ], + } }, - "feature_two": { - "default": False - } + }, + "feature_two": { + "default": False } - ``` + } + ``` """ # parse result conf as JSON, keep in cache for max age defined in store logger.debug(f"Fetching schema from registered store, store={self._store}") @@ -217,9 +217,9 @@ def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> L **Example** - ```python - ["premium_features", "my_feature_two", "always_true_feature"] - ``` + ```python + ["premium_features", "my_feature_two", "always_true_feature"] + ``` Raises ------ diff --git a/aws_lambda_powertools/utilities/feature_flags/schema.py b/aws_lambda_powertools/utilities/feature_flags/schema.py index 3de7ac22363..6f2d2bf713c 100644 --- a/aws_lambda_powertools/utilities/feature_flags/schema.py +++ b/aws_lambda_powertools/utilities/feature_flags/schema.py @@ -42,7 +42,7 @@ class SchemaValidator(BaseValidator): * **default**: `bool`. Defines default feature value. This MUST be present * **rules**: `Dict[str, Dict]`. Rules object. This MIGHT be present - ```json + ```python { "my_feature": { "default": True, @@ -59,7 +59,7 @@ class SchemaValidator(BaseValidator): * **when_match**: `bool`. Defines value to return when context matches conditions * **conditions**: `List[Dict]`. Conditions object. This MUST be present - ```json + ```python { "my_feature": { "default": True, @@ -83,7 +83,7 @@ class SchemaValidator(BaseValidator): * **key**: `str`. Key in given context to perform operation * **value**: `Any`. Value in given context that should match action operation. - ```json + ```python { "my_feature": { "default": True, From 486f9b93ee03404c25ecc7b4bc45da8119bfad2a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Aug 2021 11:00:32 +0200 Subject: [PATCH 12/35] chore(deps-dev): bump pdoc3 from 0.9.2 to 0.10.0 (#584) Bumps [pdoc3](https://github.com/pdoc3/pdoc) from 0.9.2 to 0.10.0. - [Release notes](https://github.com/pdoc3/pdoc/releases) - [Changelog](https://github.com/pdoc3/pdoc/blob/master/CHANGELOG) - [Commits](https://github.com/pdoc3/pdoc/compare/0.9.2...0.10.0) --- updated-dependencies: - dependency-name: pdoc3 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 69da74fd667..809785701d6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -694,11 +694,11 @@ python-versions = ">=2.6" [[package]] name = "pdoc3" -version = "0.9.2" +version = "0.10.0" description = "Auto-generate API documentation for Python projects." category = "dev" optional = false -python-versions = ">= 3.5" +python-versions = ">= 3.6" [package.dependencies] mako = "*" @@ -1085,7 +1085,7 @@ pydantic = ["pydantic", "email-validator"] [metadata] lock-version = "1.1" python-versions = "^3.6.1" -content-hash = "458932b6d26286e05b821686641db661cbafacff296de24ed896c1f3b1b1a68e" +content-hash = "dd886febae74a063977428d8ac4e232a7db2c074ebc2a318e1a8e8b59151892e" [metadata.files] appdirs = [ @@ -1415,7 +1415,7 @@ pbr = [ {file = "pbr-5.6.0.tar.gz", hash = "sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd"}, ] pdoc3 = [ - {file = "pdoc3-0.9.2.tar.gz", hash = "sha256:9df5d931f25f353c69c46819a3bd03ef96dd286f2a70bb1b93a23a781f91faa1"}, + {file = "pdoc3-0.10.0.tar.gz", hash = "sha256:5f22e7bcb969006738e1aa4219c75a32f34c2d62d46dc9d2fb2d3e0b0287e4b7"}, ] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, diff --git a/pyproject.toml b/pyproject.toml index b5e9a696f76..67b27c80e10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ flake8-variables-names = "^0.0.4" isort = "^5.9.3" pytest-cov = "^2.12.1" pytest-mock = "^3.5.1" -pdoc3 = "^0.9.2" +pdoc3 = "^0.10.0" pytest-asyncio = "^0.15.1" bandit = "^1.7.0" radon = "^4.5.0" From f0a852a8e22394e11c369368c4804c630a1f8d80 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Thu, 5 Aug 2021 13:40:33 +0200 Subject: [PATCH 13/35] chore: enable autolabel based on PR title --- .github/release-drafter.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 44ad5a61779..c5298fdecff 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -39,3 +39,18 @@ template: | ## This release was made possible by the following contributors: $CONTRIBUTORS + +autolabeler: + - label: 'documentation' + title: + - '/docs.+/i' + - label: 'bug' + title: + - '/fix/i' + - label: 'feature' + title: + - '/feat.+/i' + - '/refactor.+/i' + - label: 'internal' + title: + - '/chore.+/i' From d9fe499bdd358c60968faa1d7e8e7d8f7d0fe53d Mon Sep 17 00:00:00 2001 From: Heitor Lessa Date: Thu, 5 Aug 2021 13:52:29 +0200 Subject: [PATCH 14/35] refactor(feature-flags): add debug for all features evaluation" (#590) * chore: add missing debugging log for all feats * revert: autolabeler for release-drafter PR title --- .github/release-drafter.yml | 15 --------------- .../utilities/feature_flags/feature_flags.py | 1 + 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index c5298fdecff..44ad5a61779 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -39,18 +39,3 @@ template: | ## This release was made possible by the following contributors: $CONTRIBUTORS - -autolabeler: - - label: 'documentation' - title: - - '/docs.+/i' - - label: 'bug' - title: - - '/fix/i' - - label: 'feature' - title: - - '/feat.+/i' - - '/refactor.+/i' - - label: 'internal' - title: - - '/chore.+/i' diff --git a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py index 3d913de98d4..4cf5de6f3ed 100644 --- a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py +++ b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py @@ -237,6 +237,7 @@ def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> L logger.debug(f"Failed to fetch feature flags from store, returning empty list, reason={err}") return features_enabled + logger.debug("Evaluating all features") for name, feature in features.items(): rules = feature.get(schema.RULES_KEY, {}) feature_default_value = feature.get(schema.FEATURE_DEFAULT_VAL_KEY) From b8423ce202919e4938ec517cd0803d1cd8bd5ee9 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Thu, 5 Aug 2021 13:53:55 +0200 Subject: [PATCH 15/35] chore: only build docs on docs path --- .github/workflows/python_docs.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/python_docs.yml b/.github/workflows/python_docs.yml index dceee36b2f9..219b9381a8a 100644 --- a/.github/workflows/python_docs.yml +++ b/.github/workflows/python_docs.yml @@ -4,6 +4,10 @@ on: push: branches: - develop + paths: + - 'docs/**' + - 'CHANGELOG.md' + - 'mkdocs.yml' jobs: docs: From 726b9e6443f0d66036b179d95a56b86a15167b2e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Aug 2021 12:41:04 +0000 Subject: [PATCH 16/35] chore(deps-dev): bump mkdocs-material from 7.2.1 to 7.2.2 (#582) Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 7.2.1 to 7.2.2.
Release notes

Sourced from mkdocs-material's releases.

mkdocs-material-7.2.2

  • Updated Korean translations
  • Fixed #2879: Search highlighting does not properly escape HTML
Changelog

Sourced from mkdocs-material's changelog.

7.2.2 _ July 31, 2021

  • Updated Korean translations
  • Fixed #2879: Search highlighting does not properly escape HTML
Commits
  • 619d47f Prepare 7.2.2 release
  • e4b9de7 Updated README
  • ccbf5a6 Updated README
  • a15c7d2 Merge branch 'master' of github.com:squidfunk/mkdocs-material
  • 6744eb6 Fixed missing escaping of HTML in search highlighting
  • e29dfd0 Updated changelog (#2878)
  • fc9cfaa Merge branch 'master' of github.com:squidfunk/mkdocs-material
  • 9b7ddfe Boost search page for more natural ranking
  • 1e38590 Updated Korean translations (#2875)
  • 6d75663 Updated Insiders changelog
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=mkdocs-material&package-manager=pip&previous-version=7.2.1&new-version=7.2.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 809785701d6..53a13424193 100644 --- a/poetry.lock +++ b/poetry.lock @@ -593,7 +593,7 @@ mkdocs = ">=0.17" [[package]] name = "mkdocs-material" -version = "7.2.1" +version = "7.2.2" description = "A Material Design theme for MkDocs" category = "dev" optional = false @@ -1085,7 +1085,7 @@ pydantic = ["pydantic", "email-validator"] [metadata] lock-version = "1.1" python-versions = "^3.6.1" -content-hash = "dd886febae74a063977428d8ac4e232a7db2c074ebc2a318e1a8e8b59151892e" +content-hash = "00bdaf21d7da6645b2244c9254aeed854a00d4d6545f7a5bb475ec89d940575a" [metadata.files] appdirs = [ @@ -1362,8 +1362,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.2.1.tar.gz", hash = "sha256:3cd33ea5aac6ed693edea9fd93862513f996cfe522b72d759598b50286c4fe26"}, - {file = "mkdocs_material-7.2.1-py2.py3-none-any.whl", hash = "sha256:16de6cd9f87da3bcd18d4585150ad5485723878450aa331b3e8859d002989083"}, + {file = "mkdocs-material-7.2.2.tar.gz", hash = "sha256:4f501e139e2f8546653e7d8777c9b97ca639d03d8c86345a60609864cc5bbb03"}, + {file = "mkdocs_material-7.2.2-py2.py3-none-any.whl", hash = "sha256:76de22213f0e0319b9bddf1bfa86530e93efb4a604e9ddf8f8419f0438572523"}, ] mkdocs-material-extensions = [ {file = "mkdocs-material-extensions-1.0.1.tar.gz", hash = "sha256:6947fb7f5e4291e3c61405bad3539d81e0b3cd62ae0d66ced018128af509c68f"}, diff --git a/pyproject.toml b/pyproject.toml index 67b27c80e10..e8a26b03f8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ radon = "^4.5.0" xenon = "^0.7.3" flake8-eradicate = "^1.1.0" flake8-bugbear = "^21.3.2" -mkdocs-material = "^7.2.1" +mkdocs-material = "^7.2.2" mkdocs-git-revision-date-plugin = "^0.3.1" mike = "^0.6.0" mypy = "^0.910" From a3681f194b9c8b6af1a22e56b9a2e326d8f787c3 Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Fri, 6 Aug 2021 12:59:57 -0700 Subject: [PATCH 17/35] fix(deps): bump poetry to latest (#592) --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index e098615b86c..6b9d6ef0963 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ target: @$(MAKE) pr dev: - pip install --upgrade pip pre-commit poetry==1.1.4 + pip install --upgrade pip pre-commit poetry poetry install --extras "pydantic" pre-commit install From cef266a31faaea27d2ce5867bd5f0279c77f9395 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Aug 2021 22:03:31 +0200 Subject: [PATCH 18/35] chore(deps): bump codecov/codecov-action from 2.0.1 to 2.0.2 (#558) --- .github/workflows/python_build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python_build.yml b/.github/workflows/python_build.yml index 48f8783a49e..7b4a1bdd77f 100644 --- a/.github/workflows/python_build.yml +++ b/.github/workflows/python_build.yml @@ -37,7 +37,7 @@ jobs: - name: Complexity baseline run: make complexity-baseline - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2.0.1 + uses: codecov/codecov-action@v2.0.2 with: file: ./coverage.xml # flags: unittests From f4deeab5a00fd93dacf9ca75e066c1c15b0ab711 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Aug 2021 20:04:07 +0000 Subject: [PATCH 19/35] chore(deps): bump boto3 from 1.18.1 to 1.18.15 (#591) Bumps [boto3](https://github.com/boto/boto3) from 1.18.1 to 1.18.15.
Changelog

Sourced from boto3's changelog.

Commits
  • 0b26529 Merge branch 'release-1.18.15'
  • afad98b Bumping version to 1.18.15
  • c9c7944 Add changelog entries from botocore
  • 4c0e64d Merge branch 'release-1.18.14'
  • c93a5e0 Merge branch 'release-1.18.14' into develop
  • a3f99a3 Bumping version to 1.18.14
  • e658034 Add changelog entries from botocore
  • 7bcd669 Merge branch 'release-1.18.13'
  • ef3702f Merge branch 'release-1.18.13' into develop
  • c25afdf Bumping version to 1.18.13
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=boto3&package-manager=pip&previous-version=1.18.1&new-version=1.18.15)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--- poetry.lock | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index 53a13424193..146357c42be 100644 --- a/poetry.lock +++ b/poetry.lock @@ -81,20 +81,23 @@ d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] [[package]] name = "boto3" -version = "1.18.1" +version = "1.18.15" description = "The AWS SDK for Python" category = "main" optional = false python-versions = ">= 3.6" [package.dependencies] -botocore = ">=1.21.1,<1.22.0" +botocore = ">=1.21.15,<1.22.0" jmespath = ">=0.7.1,<1.0.0" s3transfer = ">=0.5.0,<0.6.0" +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + [[package]] name = "botocore" -version = "1.21.1" +version = "1.21.15" description = "Low-level, data-driven core of boto 3." category = "main" optional = false @@ -1112,12 +1115,12 @@ black = [ {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, ] boto3 = [ - {file = "boto3-1.18.1-py3-none-any.whl", hash = "sha256:a6399df957bfc7944fbd97e9fb0755cba29b1cb135b91d7e43fd298b268ab804"}, - {file = "boto3-1.18.1.tar.gz", hash = "sha256:ddfe4a78f04cd2d3a7a37d5cdfa07b4889b24296508786969bc968bee6b8b003"}, + {file = "boto3-1.18.15-py3-none-any.whl", hash = "sha256:dc44be94fa03245fd0cfff8a3fcc17d79283cfda9a39ae2e5cdedcd75749e089"}, + {file = "boto3-1.18.15.tar.gz", hash = "sha256:48241d2ca6074dd35411e1e72a4ca8ae5043e8e4aba0a9975a94af66382995da"}, ] botocore = [ - {file = "botocore-1.21.1-py3-none-any.whl", hash = "sha256:b845220eb580d10f7714798a96e380eb8f94dca89905a41d8a3c35119c757b01"}, - {file = "botocore-1.21.1.tar.gz", hash = "sha256:200887ce5f3b47d7499b7ded75dc65c4649abdaaddd06cebc118a3a954d6fd73"}, + {file = "botocore-1.21.15-py3-none-any.whl", hash = "sha256:5f9686f42fcc6df0eb3ca5804113135f06ae92a6010347665ca7670f1397bff1"}, + {file = "botocore-1.21.15.tar.gz", hash = "sha256:90b50e321278223c794032ae1ded7dfebdc73c54cc3cbbf72648e4cfdf060529"}, ] certifi = [ {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, From d07a3e14c91f96f497cb12342cf8452f94d0bb04 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Aug 2021 20:35:32 +0000 Subject: [PATCH 20/35] chore(deps-dev): bump mkdocs-material from 7.2.2 to 7.2.3 (#596) Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 7.2.2 to 7.2.3.
Release notes

Sourced from mkdocs-material's releases.

mkdocs-material-7.2.3

  • Slight facelift of data tables, now closer to Material Design
  • Fixed instant loading not respecting clicks on search results
  • Fixed #2881: Invalid anchor offsets when using instant loading
Changelog

Sourced from mkdocs-material's changelog.

7.2.3 _ August 9, 2021

  • Slight facelift of data tables, now closer to Material Design
  • Fixed instant loading not respecting clicks on search results
  • Fixed #2881: Invalid anchor offsets when using instant loading
Commits
  • 3b23896 Prepare 7.2.3 release
  • f55318b Updated dependencies
  • 213beba Documentation
  • e518348 Fixed new table styles in dark mode
  • f0615fe Slight facelift of data tables
  • 8a8d574 Updated video link
  • b978bba Updated documentation
  • adafd84 Fixed invalid anchor offsets when using instant loading
  • 51d4920 Fixed #2913: Disabled Critic extension to omit zero-width whitespace characters
  • 2dc67c9 Updated README
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=mkdocs-material&package-manager=pip&previous-version=7.2.2&new-version=7.2.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 146357c42be..49b4acdbf45 100644 --- a/poetry.lock +++ b/poetry.lock @@ -596,7 +596,7 @@ mkdocs = ">=0.17" [[package]] name = "mkdocs-material" -version = "7.2.2" +version = "7.2.3" description = "A Material Design theme for MkDocs" category = "dev" optional = false @@ -1088,7 +1088,7 @@ pydantic = ["pydantic", "email-validator"] [metadata] lock-version = "1.1" python-versions = "^3.6.1" -content-hash = "00bdaf21d7da6645b2244c9254aeed854a00d4d6545f7a5bb475ec89d940575a" +content-hash = "f1f9f5b0dfe99881c9ec59adc3b58e8802d23b503d45ebc9908762e36af57c11" [metadata.files] appdirs = [ @@ -1365,8 +1365,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.2.2.tar.gz", hash = "sha256:4f501e139e2f8546653e7d8777c9b97ca639d03d8c86345a60609864cc5bbb03"}, - {file = "mkdocs_material-7.2.2-py2.py3-none-any.whl", hash = "sha256:76de22213f0e0319b9bddf1bfa86530e93efb4a604e9ddf8f8419f0438572523"}, + {file = "mkdocs-material-7.2.3.tar.gz", hash = "sha256:688f0162e7356aa5d019cf687851f5b24c7002886bd939b8159595cc16966edf"}, + {file = "mkdocs_material-7.2.3-py2.py3-none-any.whl", hash = "sha256:039ce80555ba457faa10af993c57e9b6469918c6a5427851dcdd65591cc47fac"}, ] mkdocs-material-extensions = [ {file = "mkdocs-material-extensions-1.0.1.tar.gz", hash = "sha256:6947fb7f5e4291e3c61405bad3539d81e0b3cd62ae0d66ced018128af509c68f"}, diff --git a/pyproject.toml b/pyproject.toml index e8a26b03f8f..aad776d0a28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ radon = "^4.5.0" xenon = "^0.7.3" flake8-eradicate = "^1.1.0" flake8-bugbear = "^21.3.2" -mkdocs-material = "^7.2.2" +mkdocs-material = "^7.2.3" mkdocs-git-revision-date-plugin = "^0.3.1" mike = "^0.6.0" mypy = "^0.910" From 6952d839643fdabdfbff406308dccafe3a7eb59a Mon Sep 17 00:00:00 2001 From: Ran Isenberg <60175085+risenberg-cyberark@users.noreply.github.com> Date: Tue, 10 Aug 2021 10:41:50 +0300 Subject: [PATCH 21/35] feat(feature flags): Add not_in action and rename contains to in (#589) Co-authored-by: Heitor Lessa --- .../utilities/feature_flags/feature_flags.py | 3 +- .../utilities/feature_flags/schema.py | 5 +- .../feature_flags/test_feature_flags.py | 62 ++++++++++++++++--- .../feature_flags/test_schema_validation.py | 7 ++- 4 files changed, 66 insertions(+), 11 deletions(-) diff --git a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py index 4cf5de6f3ed..e7bde21c25b 100644 --- a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py +++ b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py @@ -46,7 +46,8 @@ def _match_by_action(action: str, condition_value: Any, context_value: Any) -> b schema.RuleAction.EQUALS.value: lambda a, b: a == b, schema.RuleAction.STARTSWITH.value: lambda a, b: a.startswith(b), schema.RuleAction.ENDSWITH.value: lambda a, b: a.endswith(b), - schema.RuleAction.CONTAINS.value: lambda a, b: a in b, + schema.RuleAction.IN.value: lambda a, b: a in b, + schema.RuleAction.NOT_IN.value: lambda a, b: a not in b, } try: diff --git a/aws_lambda_powertools/utilities/feature_flags/schema.py b/aws_lambda_powertools/utilities/feature_flags/schema.py index 6f2d2bf713c..efce82018db 100644 --- a/aws_lambda_powertools/utilities/feature_flags/schema.py +++ b/aws_lambda_powertools/utilities/feature_flags/schema.py @@ -20,7 +20,8 @@ class RuleAction(str, Enum): EQUALS = "EQUALS" STARTSWITH = "STARTSWITH" ENDSWITH = "ENDSWITH" - CONTAINS = "CONTAINS" + IN = "IN" + NOT_IN = "NOT_IN" class SchemaValidator(BaseValidator): @@ -79,7 +80,7 @@ class SchemaValidator(BaseValidator): The value MUST contain the following members: * **action**: `str`. Operation to perform to match a key and value. - The value MUST be either EQUALS, STARTSWITH, ENDSWITH, CONTAINS + The value MUST be either EQUALS, STARTSWITH, ENDSWITH, IN, NOT_IN * **key**: `str`. Key in given context to perform operation * **value**: `Any`. Value in given context that should match action operation. diff --git a/tests/functional/feature_flags/test_feature_flags.py b/tests/functional/feature_flags/test_feature_flags.py index d2150268062..c0f463c78d0 100644 --- a/tests/functional/feature_flags/test_feature_flags.py +++ b/tests/functional/feature_flags/test_feature_flags.py @@ -263,7 +263,7 @@ def test_flags_conditions_rule_match_multiple_actions_multiple_rules_multiple_co # check a case where the feature exists but the rule doesn't match so we revert to the default value of the feature -def test_flags_match_rule_with_contains_action(mocker, config): +def test_flags_match_rule_with_in_action(mocker, config): expected_value = True mocked_app_config_schema = { "my_feature": { @@ -273,7 +273,7 @@ def test_flags_match_rule_with_contains_action(mocker, config): "when_match": expected_value, "conditions": [ { - "action": RuleAction.CONTAINS.value, + "action": RuleAction.IN.value, "key": "tenant_id", "value": ["6", "2"], } @@ -287,7 +287,7 @@ def test_flags_match_rule_with_contains_action(mocker, config): assert toggle == expected_value -def test_flags_no_match_rule_with_contains_action(mocker, config): +def test_flags_no_match_rule_with_in_action(mocker, config): expected_value = False mocked_app_config_schema = { "my_feature": { @@ -297,7 +297,7 @@ def test_flags_no_match_rule_with_contains_action(mocker, config): "when_match": True, "conditions": [ { - "action": RuleAction.CONTAINS.value, + "action": RuleAction.IN.value, "key": "tenant_id", "value": ["8", "2"], } @@ -311,6 +311,54 @@ def test_flags_no_match_rule_with_contains_action(mocker, config): assert toggle == expected_value +def test_flags_match_rule_with_not_in_action(mocker, config): + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": False, + "rules": { + "tenant id is contained in [8, 2]": { + "when_match": expected_value, + "conditions": [ + { + "action": RuleAction.NOT_IN.value, + "key": "tenant_id", + "value": ["10", "4"], + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False) + assert toggle == expected_value + + +def test_flags_no_match_rule_with_not_in_action(mocker, config): + expected_value = False + mocked_app_config_schema = { + "my_feature": { + "default": expected_value, + "rules": { + "tenant id is contained in [8, 2]": { + "when_match": True, + "conditions": [ + { + "action": RuleAction.NOT_IN.value, + "key": "tenant_id", + "value": ["6", "4"], + } + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate(name="my_feature", context={"tenant_id": "6", "username": "a"}, default=False) + assert toggle == expected_value + + def test_multiple_features_enabled(mocker, config): expected_value = ["my_feature", "my_feature2"] mocked_app_config_schema = { @@ -321,7 +369,7 @@ def test_multiple_features_enabled(mocker, config): "when_match": True, "conditions": [ { - "action": RuleAction.CONTAINS.value, + "action": RuleAction.IN.value, "key": "tenant_id", "value": ["6", "2"], } @@ -351,7 +399,7 @@ def test_multiple_features_only_some_enabled(mocker, config): "when_match": True, "conditions": [ { - "action": RuleAction.CONTAINS.value, + "action": RuleAction.IN.value, "key": "tenant_id", "value": ["6", "2"], } @@ -464,7 +512,7 @@ def test_features_jmespath_envelope(mocker, config): assert toggle == expected_value -# test_match_rule_with_contains_action +# test_match_rule_with_equals_action def test_match_condition_with_dict_value(mocker, config): expected_value = True mocked_app_config_schema = { diff --git a/tests/functional/feature_flags/test_schema_validation.py b/tests/functional/feature_flags/test_schema_validation.py index 2c33d3c61cc..ce85494afce 100644 --- a/tests/functional/feature_flags/test_schema_validation.py +++ b/tests/functional/feature_flags/test_schema_validation.py @@ -211,10 +211,15 @@ def test_valid_condition_all_actions(): CONDITION_VALUE: "a", }, { - CONDITION_ACTION: RuleAction.CONTAINS.value, + CONDITION_ACTION: RuleAction.IN.value, CONDITION_KEY: "username", CONDITION_VALUE: ["a", "b"], }, + { + CONDITION_ACTION: RuleAction.NOT_IN.value, + CONDITION_KEY: "username", + CONDITION_VALUE: ["c"], + }, ], } }, From 7b3b032581477cfe9b235697fa7715c02291f784 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Aug 2021 07:42:26 +0000 Subject: [PATCH 22/35] chore(deps): bump boto3 from 1.18.15 to 1.18.17 (#597) Bumps [boto3](https://github.com/boto/boto3) from 1.18.15 to 1.18.17.
Changelog

Sourced from boto3's changelog.

1.18.17

  • api-change:wafv2: [botocore] This release adds APIs to support versioning feature of AWS WAF Managed rule groups
  • api-change:rekognition: [botocore] This release adds support for four new types of segments (opening credits, content segments, slates, and studio logos), improved accuracy for credits and shot detection and new filters to control black frame detection.
  • api-change:ssm: [botocore] Documentation updates for AWS Systems Manager.

1.18.16

  • api-change:synthetics: [botocore] Documentation updates for Visual Monitoring feature and other doc ticket fixes.
  • api-change:chime-sdk-identity: [botocore] The Amazon Chime SDK Identity APIs allow software developers to create and manage unique instances of their messaging applications.
  • api-change:chime-sdk-messaging: [botocore] The Amazon Chime SDK Messaging APIs allow software developers to send and receive messages in custom messaging applications.
  • api-change:connect: [botocore] This release adds support for agent status and hours of operation. For details, see the Release Notes in the Amazon Connect Administrator Guide.
  • api-change:lightsail: [botocore] This release adds support to track when a bucket access key was last used.
  • api-change:athena: [botocore] Documentation updates for Athena.
Commits
  • ea7bed7 Merge branch 'release-1.18.17'
  • 616426f Bumping version to 1.18.17
  • 8d8e689 Add changelog entries from botocore
  • 2634e76 Merge branch 'release-1.18.16'
  • ddedebd Merge branch 'release-1.18.16' into develop
  • d401cb0 Bumping version to 1.18.16
  • dbb9322 Add changelog entries from botocore
  • 33d41ed Merge branch 'release-1.18.15' into develop
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=boto3&package-manager=pip&previous-version=1.18.15&new-version=1.18.17)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--- poetry.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index 49b4acdbf45..ac68acc59ed 100644 --- a/poetry.lock +++ b/poetry.lock @@ -81,14 +81,14 @@ d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] [[package]] name = "boto3" -version = "1.18.15" +version = "1.18.17" description = "The AWS SDK for Python" category = "main" optional = false python-versions = ">= 3.6" [package.dependencies] -botocore = ">=1.21.15,<1.22.0" +botocore = ">=1.21.17,<1.22.0" jmespath = ">=0.7.1,<1.0.0" s3transfer = ">=0.5.0,<0.6.0" @@ -97,7 +97,7 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.21.15" +version = "1.21.17" description = "Low-level, data-driven core of boto 3." category = "main" optional = false @@ -1115,12 +1115,12 @@ black = [ {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, ] boto3 = [ - {file = "boto3-1.18.15-py3-none-any.whl", hash = "sha256:dc44be94fa03245fd0cfff8a3fcc17d79283cfda9a39ae2e5cdedcd75749e089"}, - {file = "boto3-1.18.15.tar.gz", hash = "sha256:48241d2ca6074dd35411e1e72a4ca8ae5043e8e4aba0a9975a94af66382995da"}, + {file = "boto3-1.18.17-py3-none-any.whl", hash = "sha256:69a5ebbd5fda6742d20fd536cd9b2927f2eaa8dde84ad529fe816231afcf9c68"}, + {file = "boto3-1.18.17.tar.gz", hash = "sha256:5e5f60ece9b73d48f668bef56ddcde716f013b48a62fdf9c5eac9512a5981136"}, ] botocore = [ - {file = "botocore-1.21.15-py3-none-any.whl", hash = "sha256:5f9686f42fcc6df0eb3ca5804113135f06ae92a6010347665ca7670f1397bff1"}, - {file = "botocore-1.21.15.tar.gz", hash = "sha256:90b50e321278223c794032ae1ded7dfebdc73c54cc3cbbf72648e4cfdf060529"}, + {file = "botocore-1.21.17-py3-none-any.whl", hash = "sha256:5b665142bdb2c30fc86b15bc48dd8b74c9cac69dc3e20b6d8f79cb60ff368797"}, + {file = "botocore-1.21.17.tar.gz", hash = "sha256:a0d64369857d86b3a6d01b0c5933671c2394584311ce3af702271ba221b09afa"}, ] certifi = [ {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, From b9a1d5450593e456b7f185bb529821f9eaf3a280 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 10 Aug 2021 20:35:06 +0200 Subject: [PATCH 23/35] docs(feature-flags): create concrete documentation (#594) Co-authored-by: heitorlessa --- README.md | 1 + .../utilities/feature_flags/appconfig.py | 8 +- .../utilities/feature_flags/feature_flags.py | 2 +- docs/index.md | 1 + docs/media/feat_flags_evaluation_workflow.png | Bin 0 -> 70609 bytes docs/utilities/feature_flags.md | 632 +++++++++++++++++- .../feature_flags/test_feature_flags.py | 4 +- 7 files changed, 621 insertions(+), 27 deletions(-) create mode 100644 docs/media/feat_flags_evaluation_workflow.png diff --git a/README.md b/README.md index da2f9c5a964..89889bd3a92 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ A suite of Python utilities for AWS Lambda functions to ease adopting best pract * **[Event source data classes](https://awslabs.github.io/aws-lambda-powertools-python/latest/utilities/data_classes/)** - Data classes describing the schema of common Lambda event triggers * **[Parser](https://awslabs.github.io/aws-lambda-powertools-python/latest/utilities/parser/)** - Data parsing and deep validation using Pydantic * **[Idempotency](https://awslabs.github.io/aws-lambda-powertools-python/latest/utilities/idempotency/)** - Convert your Lambda functions into idempotent operations which are safe to retry +* **[Feature Flags](./utilities/feature_flags.md)** - A simple rule engine to evaluate when one or multiple features should be enabled depending on the input ### Installation diff --git a/aws_lambda_powertools/utilities/feature_flags/appconfig.py b/aws_lambda_powertools/utilities/feature_flags/appconfig.py index df506940ee1..30c70b6c590 100644 --- a/aws_lambda_powertools/utilities/feature_flags/appconfig.py +++ b/aws_lambda_powertools/utilities/feature_flags/appconfig.py @@ -21,7 +21,7 @@ def __init__( environment: str, application: str, name: str, - cache_seconds: int, + max_age: int = 5, sdk_config: Optional[Config] = None, envelope: Optional[str] = "", jmespath_options: Optional[Dict] = None, @@ -36,8 +36,8 @@ def __init__( AppConfig application name, e.g. 'powertools' name: str AppConfig configuration name e.g. `my_conf` - cache_seconds: int - cache expiration time, how often to call AppConfig to fetch latest configuration + max_age: int + cache expiration time in seconds, or how often to call AppConfig to fetch latest configuration sdk_config: Optional[Config] Botocore Config object to pass during client initialization envelope : Optional[str] @@ -49,7 +49,7 @@ def __init__( self.environment = environment self.application = application self.name = name - self.cache_seconds = cache_seconds + self.cache_seconds = max_age self.config = sdk_config self.envelope = envelope self.jmespath_options = jmespath_options diff --git a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py index e7bde21c25b..50d65175e51 100644 --- a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py +++ b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py @@ -24,7 +24,7 @@ def __init__(self, store: StoreProvider): environment="test", application="powertools", name="test_conf_name", - cache_seconds=300, + max_age=300, envelope="features" ) diff --git a/docs/index.md b/docs/index.md index 104ed1d85d6..9220f2e1ca7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -212,6 +212,7 @@ aws serverlessrepo list-application-versions \ [Event source data classes](./utilities/data_classes.md) | Data classes describing the schema of common Lambda event triggers [Parser](./utilities/parser.md) | Data parsing and deep validation using Pydantic [Idempotency](./utilities/idempotency.md) | Idempotent Lambda handler +[Feature Flags](./utilities/feature_flags.md) | A simple rule engine to evaluate when one or multiple features should be enabled depending on the input ## Environment variables diff --git a/docs/media/feat_flags_evaluation_workflow.png b/docs/media/feat_flags_evaluation_workflow.png new file mode 100644 index 0000000000000000000000000000000000000000..deca3dfc297c0c748a0c61f36db7fcaccef07bfb GIT binary patch literal 70609 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 TODO: Revisit once getting started and advanced sections are complete +## Key features * Define simple feature flags to dynamically decide when to enable a feature * Fetch one or all feature flags enabled for a given application context -* Bring your own configuration store +* Support for static feature flags to simply turn on/off a feature without rules ## Getting started + ### IAM Permissions -By default, this utility provides AWS AppConfig as a configuration store. As such, you IAM Role needs permission - `appconfig:GetConfiguration` - to fetch feature flags from AppConfig. +Your Lambda function must have `appconfig:GetConfiguration` IAM permission in order to fetch configuration from AWS AppConfig. + +### Required resources + +By default, this utility provides [AWS AppConfig](https://docs.aws.amazon.com/appconfig/latest/userguide/what-is-appconfig.html) as a configuration store. + +The following sample infrastructure will be used throughout this documentation: + +=== "template.yaml" + + ```yaml hl_lines="5 11 18 25 31-50 54" + AWSTemplateFormatVersion: "2010-09-09" + Description: Lambda Powertools Feature flags sample template + Resources: + FeatureStoreApp: + Type: AWS::AppConfig::Application + Properties: + Description: "AppConfig Appliction for feature toggles" + Name: my-app + + FeatureStoreDevEnv: + Type: AWS::AppConfig::Environment + Properties: + ApplicationId: !Ref FeatureStoreApp + Description: "Development Environment for the App Config Store" + Name: "development" + + FeatureStoreConfigProfile: + Type: AWS::AppConfig::ConfigurationProfile + Properties: + ApplicationId: !Ref FeatureStoreApp + Name: "MyTestProfile" + LocationUri: "hosted" + + HostedConfigVersion: + Type: AWS::AppConfig::HostedConfigurationVersion + Properties: + ApplicationId: !Ref FeatureStoreApp + ConfigurationProfileId: !Ref FeatureStoreConfigProfile + Description: 'A sample hosted configuration version' + Content: | + { + "premium_features": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + }, + "ten_percent_off_campaign": { + "default": false + } + } + ContentType: 'application/json' + + ConfigDeployment: + Type: AWS::AppConfig::Deployment + Properties: + ApplicationId: !Ref FeatureStoreApp + ConfigurationProfileId: !Ref FeatureStoreConfigProfile + ConfigurationVersion: !Ref HostedConfigVersion + DeploymentStrategyId: "AppConfig.AllAtOnce" + EnvironmentId: !Ref FeatureStoreDevEnv + ``` + +=== "CDK" + + ```typescript hl_lines="2-7 13-32 34-35 40 47 54" + import * as cdk from '@aws-cdk/core'; + import { + CfnApplication, + CfnConfigurationProfile, CfnDeployment, + CfnEnvironment, + CfnHostedConfigurationVersion + } from "@aws-cdk/aws-appconfig"; + + export class CdkStack extends cdk.Stack { + constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + const featureConfig = { + "premium_features": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + }, + "ten_percent_off_campaign": { + "default": true + } + } + + const app = new CfnApplication(this, "app", {name: "productapp"}); + const env = new CfnEnvironment(this, "env", { + applicationId: app.ref, + name: "dev-env" + }); + + const configProfile = new CfnConfigurationProfile(this, "profile", { + applicationId: app.ref, + locationUri: "hosted", + name: "configProfile" + }); + + + const hostedConfigVersion = new CfnHostedConfigurationVersion(this, "version", { + applicationId: app.ref, + configurationProfileId: configProfile.ref, + content: JSON.stringify(featureConfig), + contentType: "application/json" + }); + + new CfnDeployment(this, "deploy", { + applicationId: app.ref, + configurationProfileId: configProfile.ref, + configurationVersion: hostedConfigVersion.ref, + deploymentStrategyId: "AppConfig.AllAtOnce", + environmentId: env.ref + }); + } + } + ``` + +### Evaluating a single feature flag + +To get started, you'd need to initialize `AppConfigStore` and `FeatureFlags`. Then call `FeatureFlags` `evaluate` method to fetch, validate, and evaluate your feature. + +The `evaluate` method supports two optional parameters: + +* **context**: Value to be evaluated against each rule defined for the given feature +* **default**: Sentinel value to use in case we experience any issues with our store, or feature doesn't exist + +=== "app.py" + + ```python hl_lines="3 9 13 17-19" + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + + app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="features" + ) + + feature_flags = FeatureFlags(store=app_config) + + def lambda_handler(event, context): + # Get customer's tier from incoming request + ctx = { "tier": event.get("tier", "standard") } + + # Evaluate whether customer's tier has access to premium features + # based on `has_premium_features` rules + has_premium_features: bool = feature_flags.evaluate(name="has_premium_features", + context=ctx, default=False) + if has_premium_features: + # enable premium features + ... + ``` + +=== "event.json" + + ```json hl_lines="3" + { + "username": "lessa", + "tier": "premium", + "basked_id": "random_id" + } + ``` +=== "features.json" + + ```json hl_lines="2 6 9-11" + { + "premium_features": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + }, + "ten_percent_off_campaign": { + "default": false + } + } + ``` + +#### Static flags + +We have a static flag named `ten_percent_off_campaign`. Meaning, there are no conditional rules, it's either ON or OFF for all customers. + +In this case, we could omit the `context` parameter and simply evaluate whether we should apply the 10% discount. + +=== "app.py" + + ```python hl_lines="12-13" + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + + app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="features" + ) + + feature_flags = FeatureFlags(store=app_config) + + def lambda_handler(event, context): + apply_discount: bool = feature_flags.evaluate(name="ten_percent_off_campaign", + default=False) + + if apply_discount: + # apply 10% discount to product + ... + ``` + +=== "features.json" + + ```json hl_lines="2-3" + { + "ten_percent_off_campaign": { + "default": false + } + } + ``` + +### Getting all enabled features + +As you might have noticed, each `evaluate` call means an API call to the Store and the more features you have the more costly this becomes. + +You can use `get_enabled_features` method for scenarios where you need a list of all enabled features according to the input context. + +=== "app.py" + + ```python hl_lines="17-20 23" + from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + + app = ApiGatewayResolver() + + app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="features" + ) + + feature_flags = FeatureFlags(store=app_config) + + + @app.get("/products") + def list_products(): + ctx = { + **app.current_event.headers, + **app.current_event.json_body + } + + # all_features is evaluated to ["geo_customer_campaign", "ten_percent_off_campaign"] + all_features: list[str] = feature_flags.get_enabled_features(context=ctx) + + if "geo_customer_campaign" in all_features: + # apply discounts based on geo + ... + + if "ten_percent_off_campaign" in all_features: + # apply additional 10% for all customers + ... -### Creating feature flags + def lambda_handler(event, context): + return app.resolve(event, context) + ``` -> NOTE: Explain schema, provide sample boto3 script and CFN to create one +=== "event.json" + + ```json hl_lines="2 8" + { + "body": '{"username": "lessa", "tier": "premium", "basked_id": "random_id"}', + "resource": "/products", + "path": "/products", + "httpMethod": "GET", + "isBase64Encoded": false, + "headers": { + "CloudFront-Viewer-Country": "NL", + } + } + ``` + +=== "features.json" + + ```json hl_lines="17-18 20 27-29" + { + "premium_features": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + }, + "ten_percent_off_campaign": { + "default": true + }, + "geo_customer_campaign": { + "default": false, + "rules": { + "customer in temporary discount geo": { + "when_match": true, + "conditions": [ + { + "action": "IN", + "key": "CloudFront-Viewer-Country", + "value": ["NL", "IE", "UK", "PL", "PT"}, + } + ] + } + } + } + } + ``` + + +## Advanced + +### Schema + +This utility expects a certain schema to be stored as JSON within AWS AppConfig. + +#### Features + +A feature can simply have its name and a `default` value. This is either on or off, also known as a [static flag](#static-flags). + +=== "minimal_schema.json" + ```json hl_lines="2-3" + { + "global_feature": { + "default": true + } + } + ``` + +If you need more control and want to provide context such as user group, permissions, location, etc., you need to add rules to your feature flag configuration. #### Rules +When adding `rules` to a feature, they must contain: + +1. A rule name as a key +2. `when_match` boolean value that should be used when conditions match +3. A list of `conditions` for evaluation + +=== "feature_with_rules.json" + + ```json hl_lines="4-11" + { + "premium_feature": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + } + } + ``` + +You can have multiple rules with different names. The rule engine will return the first result `when_match` of the matching rule configuration, or `default` value when none of the rules apply. + +#### Conditions + +The `conditions` block is a list of conditions that contain `action`, `key`, and `value` keys: + +=== "conditions.json" + ```json hl_lines="8-11" + { + ... + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + ``` + +The `action` configuration can have 5 different values: `EQUALS`, `STARTSWITH`, `ENDSWITH`, `IN`, `NOT_IN`. + +The `key` and `value` will be compared to the input from the context parameter. + +**For multiple conditions**, we will evaluate the list of conditions as a logical `AND`, so all conditions needs to match to return `when_match` value. + +#### Rule engine flowchart + +Now that you've seen all properties of a feature flag schema, this flowchart describes how the rule engines makes a decision on when to return `True` or `False`. + +![Rule engine ](../media/feat_flags_evaluation_workflow.png) + +### Adjusting in-memory cache + +By default, we cache configuration retrieved from the Store for 5 seconds for performance and reliability reasons. + +You can override `max_age` parameter when instantiating the store. + +```python hl_lines="7" +from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore + +app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="features", + max_age=300 +) +``` + +### Envelope + +There are scenarios where you might want to include feature flags as part of an existing application configuration. +For this to work, you need to use a JMESPath expression via the `envelope` parameter to extract that key as the feature flags configuration. -### Fetching a single feature flag +=== "app.py" -### Fetching all feature flags + ```python hl_lines="7" + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore -### Advanced + app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="configuration", + envelope = "feature_flags" + ) + ``` -#### Adjusting cache TTL +=== "configuration.json" -### Partially enabling features + ```json hl_lines="6" + { + "logging": { + "level": "INFO", + "sampling_rate": 0.1 + }, + "feature_flags": { + "premium_feature": { + "default": false, + "rules": { + "customer tier equals premium": { + "when_match": true, + "conditions": [ + { + "action": "EQUALS", + "key": "tier", + "value": "premium" + } + ] + } + } + }, + "feature2": { + "default": false + } + } + } + ``` + +### Built-in store provider + +!!! info "For GA, you'll be able to bring your own store." + +#### AppConfig + +AppConfig store provider fetches any JSON document from AWS AppConfig. + +These are the available options for further customization. + +Parameter | Default | Description +------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------- +**environment** | `""` | AWS AppConfig Environment, e.g. `test` +**application** | `""` | AWS AppConfig Application +**name** | `""` | AWS AppConfig Configuration name +**envelope** | `None` | JMESPath expression to use to extract feature flags configuration from AWS AppConfig configuration +**max_age** | `5` | Number of seconds to cache feature flags configuration fetched from AWS AppConfig +**sdk_config** | `None` | [Botocore Config object](https://botocore.amazonaws.com/v1/documentation/api/latest/reference/config.html){target="_blank"} +**jmespath_options** | `None` | For advanced use cases when you want to bring your own [JMESPath functions](https://github.com/jmespath/jmespath.py#custom-functions){target="_blank"} + +=== "appconfig_store_example.py" + +```python hl_lines="19-25" +from botocore.config import Config + +import jmespath + +boto_config = Config(read_timeout=10, retries={"total_max_attempts": 2}) + +# Custom JMESPath functions +class CustomFunctions(jmespath.functions.Functions): + + @jmespath.functions.signature({'types': ['string']}) + def _func_special_decoder(self, s): + return my_custom_decoder_logic(s) + + +custom_jmespath_options = {"custom_functions": CustomFunctions()} + + +app_config = AppConfigStore( + environment="dev", + application="product-catalogue", + name="configuration", + max_age=120, + envelope = "features", + sdk_config=boto_config, + jmespath_options=custom_jmespath_options +) +``` -### Bring your own store provider ## Testing your code -> NOTE: Share example on how customers can unit test their feature flags +You can unit test your feature flags locally and independently without setting up AWS AppConfig. + +`AppConfigStore` only fetches a JSON document with a specific schema. This allows you to mock the response and use it to verify the rule evaluation. + +!!! warning "This excerpt relies on `pytest` and `pytest-mock` dependencies" + +=== "test_feature_flags_independently.py" + + ```python hl_lines="9-11" + from typing import Dict, List, Optional + + from aws_lambda_powertools.utilities.feature_flags import FeatureFlags, AppConfigStore, RuleAction + + + def init_feature_flags(mocker, mock_schema, envelope="") -> FeatureFlags: + """Mock AppConfig Store get_configuration method to use mock schema instead""" + + method_to_mock = "aws_lambda_powertools.utilities.feature_flags.AppConfigStore.get_configuration" + mocked_get_conf = mocker.patch(method_to_mock) + mocked_get_conf.return_value = mock_schema + + app_conf_store = AppConfigStore( + environment="test_env", + application="test_app", + name="test_conf_name", + envelope=envelope, + ) + + return FeatureFlags(store=app_conf_store) + + + def test_flags_condition_match(mocker): + # GIVEN + expected_value = True + mocked_app_config_schema = { + "my_feature": { + "default": expected_value, + "rules": { + "tenant id equals 12345": { + "when_match": True, + "conditions": [ + { + "action": RuleAction.EQUALS.value, + "key": "tenant_id", + "value": "12345", + } + ], + } + }, + } + } + + # WHEN + ctx = {"tenant_id": "12345", "username": "a"} + feature_flags = init_feature_flags(mocker=mocker, mock_schema=mocked_app_config_schema) + flag = feature_flags.evaluate(name="my_feature", context=ctx, default=False) + + # THEN + assert flag == expected_value + ``` diff --git a/tests/functional/feature_flags/test_feature_flags.py b/tests/functional/feature_flags/test_feature_flags.py index c0f463c78d0..9d647a81d2f 100644 --- a/tests/functional/feature_flags/test_feature_flags.py +++ b/tests/functional/feature_flags/test_feature_flags.py @@ -26,7 +26,7 @@ def init_feature_flags( environment="test_env", application="test_app", name="test_conf_name", - cache_seconds=600, + max_age=600, sdk_config=config, envelope=envelope, jmespath_options=jmespath_options, @@ -42,7 +42,7 @@ def init_fetcher_side_effect(mocker, config: Config, side_effect) -> AppConfigSt environment="env", application="application", name="conf", - cache_seconds=1, + max_age=1, sdk_config=config, ) From 06be6266d247b5789f27106b96e27c117e8fb711 Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Tue, 10 Aug 2021 11:58:37 -0700 Subject: [PATCH 24/35] feat(tracer): disable tracer when for non-Lambda envs (#598) --- aws_lambda_powertools/shared/constants.py | 3 +-- aws_lambda_powertools/tracing/tracer.py | 7 +++---- tests/functional/test_tracing.py | 21 +++------------------ tests/unit/test_tracing.py | 10 +++++++--- 4 files changed, 14 insertions(+), 27 deletions(-) diff --git a/aws_lambda_powertools/shared/constants.py b/aws_lambda_powertools/shared/constants.py index 8388eded654..6061462a051 100644 --- a/aws_lambda_powertools/shared/constants.py +++ b/aws_lambda_powertools/shared/constants.py @@ -12,10 +12,9 @@ EVENT_HANDLER_DEBUG_ENV: str = "POWERTOOLS_EVENT_HANDLER_DEBUG" -SAM_LOCAL_ENV: str = "AWS_SAM_LOCAL" -CHALICE_LOCAL_ENV: str = "AWS_CHALICE_CLI_MODE" SERVICE_NAME_ENV: str = "POWERTOOLS_SERVICE_NAME" XRAY_TRACE_ID_ENV: str = "_X_AMZN_TRACE_ID" +LAMBDA_TASK_ROOT_ENV: str = "LAMBDA_TASK_ROOT" XRAY_SDK_MODULE: str = "aws_xray_sdk" XRAY_SDK_CORE_MODULE: str = "aws_xray_sdk.core" diff --git a/aws_lambda_powertools/tracing/tracer.py b/aws_lambda_powertools/tracing/tracer.py index bd4244b3171..e57d24044dc 100644 --- a/aws_lambda_powertools/tracing/tracer.py +++ b/aws_lambda_powertools/tracing/tracer.py @@ -719,16 +719,15 @@ def _is_tracer_disabled() -> Union[bool, str]: Union[bool, str] """ logger.debug("Verifying whether Tracing has been disabled") - is_lambda_sam_cli = os.getenv(constants.SAM_LOCAL_ENV) - is_chalice_cli = os.getenv(constants.CHALICE_LOCAL_ENV) + is_lambda_env = os.getenv(constants.LAMBDA_TASK_ROOT_ENV) is_disabled = resolve_truthy_env_var_choice(env=os.getenv(constants.TRACER_DISABLED_ENV, "false")) if is_disabled: logger.debug("Tracing has been disabled via env var POWERTOOLS_TRACE_DISABLED") return is_disabled - if is_lambda_sam_cli or is_chalice_cli: - logger.debug("Running under SAM CLI env or not in Lambda env; disabling Tracing") + if not is_lambda_env: + logger.debug("Running outside Lambda env; disabling Tracing") return True return False diff --git a/tests/functional/test_tracing.py b/tests/functional/test_tracing.py index 617bf816f86..b330ab6316f 100644 --- a/tests/functional/test_tracing.py +++ b/tests/functional/test_tracing.py @@ -60,26 +60,11 @@ def greeting(name, message): greeting(name="Foo", message="Bar") -def test_tracer_lambda_emulator(monkeypatch, dummy_response): - # GIVEN tracer runs locally - monkeypatch.setenv("AWS_SAM_LOCAL", "true") +def test_tracer_lambda_outside_lambda_env(monkeypatch, dummy_response): + # GIVEN tracer runs locally (ie: `LAMBDA_TASK_ROOT` is not set) tracer = Tracer() - # WHEN a lambda function is run through SAM CLI - @tracer.capture_lambda_handler - def handler(event, context): - return dummy_response - - # THEN tracer should run in disabled mode, and not raise an Exception - handler({}, {}) - - -def test_tracer_chalice_cli_mode(monkeypatch, dummy_response): - # GIVEN tracer runs locally - monkeypatch.setenv("AWS_CHALICE_CLI_MODE", "true") - tracer = Tracer() - - # WHEN a lambda function is run through the Chalice CLI. + # WHEN a lambda function is run through outside of a lambda @tracer.capture_lambda_handler def handler(event, context): return dummy_response diff --git a/tests/unit/test_tracing.py b/tests/unit/test_tracing.py index fdfdf5c6d6e..2b147ec4405 100644 --- a/tests/unit/test_tracing.py +++ b/tests/unit/test_tracing.py @@ -125,9 +125,11 @@ def greeting(name, message): ) -def test_tracer_custom_metadata(mocker, dummy_response, provider_stub): +def test_tracer_custom_metadata(monkeypatch, mocker, dummy_response, provider_stub): # GIVEN Tracer is initialized with booking as the service name + monkeypatch.setenv("LAMBDA_TASK_ROOT", "/opt/") put_metadata_mock = mocker.MagicMock() + provider = provider_stub(put_metadata_mock=put_metadata_mock) tracer = Tracer(provider=provider, service="booking") @@ -143,8 +145,9 @@ def test_tracer_custom_metadata(mocker, dummy_response, provider_stub): ) -def test_tracer_custom_annotation(mocker, dummy_response, provider_stub): +def test_tracer_custom_annotation(monkeypatch, mocker, dummy_response, provider_stub): # GIVEN Tracer is initialized + monkeypatch.setenv("LAMBDA_TASK_ROOT", "/opt/") put_annotation_mock = mocker.MagicMock() provider = provider_stub(put_annotation_mock=put_annotation_mock) tracer = Tracer(provider=provider) @@ -214,8 +217,9 @@ def greeting(name, message): @mock.patch("aws_lambda_powertools.tracing.tracer.aws_xray_sdk.core.patch") -def test_tracer_patch_modules(xray_patch_mock, mocker): +def test_tracer_patch_modules(xray_patch_mock, monkeypatch, mocker): # GIVEN tracer is initialized with a list of modules to patch + monkeypatch.setenv("LAMBDA_TASK_ROOT", "/opt/") modules = ["boto3"] # WHEN modules are supported by X-Ray From cb8169e6ddc759dcde19ffceeaced695e59f6842 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 21:02:38 +0200 Subject: [PATCH 25/35] docs(tracer): update wording that it auto-disables on non-Lambda env --- docs/core/tracer.md | 9 ++------- docs/index.md | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/docs/core/tracer.md b/docs/core/tracer.md index b9cd86862f5..c6f2baa59fd 100644 --- a/docs/core/tracer.md +++ b/docs/core/tracer.md @@ -10,8 +10,7 @@ Tracer is an opinionated thin wrapper for [AWS X-Ray Python SDK](https://github. ## Key features * Auto capture cold start as annotation, and responses or full exceptions as metadata -* Run functions locally with SAM CLI without code change to disable tracing -* Explicitly disable tracing via env var `POWERTOOLS_TRACE_DISABLED="true"` +* Auto-disable when not running in AWS Lambda environment * Support tracing async methods, generators, and context managers * Auto patch supported modules by AWS X-Ray @@ -357,11 +356,7 @@ Tracer keeps a copy of its configuration after the first initialization. This is ## Testing your code -You can safely disable Tracer when unit testing your code using `POWERTOOLS_TRACE_DISABLED` environment variable. - -```bash -POWERTOOLS_TRACE_DISABLED=1 python -m pytest -``` +Tracer is disabled by default when not running in the AWS Lambda environment - This means no code changes or environment variables to be set. ## Tips diff --git a/docs/index.md b/docs/index.md index 9220f2e1ca7..bd9d7875ece 100644 --- a/docs/index.md +++ b/docs/index.md @@ -223,7 +223,7 @@ aws serverlessrepo list-application-versions \ | ------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------- | | **POWERTOOLS_SERVICE_NAME** | Sets service name used for tracing namespace, metrics dimension and structured logging | All | `"service_undefined"` | | **POWERTOOLS_METRICS_NAMESPACE** | Sets namespace used for metrics | [Metrics](./core/metrics) | `None` | -| **POWERTOOLS_TRACE_DISABLED** | Disables tracing | [Tracing](./core/tracer) | `false` | +| **POWERTOOLS_TRACE_DISABLED** | Explicitly disables tracing | [Tracing](./core/tracer) | `false` | | **POWERTOOLS_TRACER_CAPTURE_RESPONSE** | Captures Lambda or method return as metadata. | [Tracing](./core/tracer) | `true` | | **POWERTOOLS_TRACER_CAPTURE_ERROR** | Captures Lambda or method exception as metadata. | [Tracing](./core/tracer) | `true` | | **POWERTOOLS_TRACE_MIDDLEWARES** | Creates sub-segment for each custom middleware | [Middleware factory](./utilities/middleware_factory) | `false` | From 0b0b38bf43ab309a88ff3b1a44c50bbb9afbabb9 Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Tue, 10 Aug 2021 21:16:12 +0200 Subject: [PATCH 26/35] docs(parameters): auto-transforming values based on suffix (#573) Co-authored-by: Andrea Amorosi Co-authored-by: heitorlessa --- docs/utilities/parameters.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/utilities/parameters.md b/docs/utilities/parameters.md index 8fc3227e2c6..871ea199e5a 100644 --- a/docs/utilities/parameters.md +++ b/docs/utilities/parameters.md @@ -437,6 +437,41 @@ For example, if you have three parameters, */param/a*, */param/b* and */param/c* values = ssm_provider.get_multiple("/param", transform="json", raise_on_transform_error=True) ``` +#### Auto-transform values on suffix + +If you use `transform` with `get_multiple()`, you might want to retrieve and transform parameters encoded in different formats. + +You can do this with a single request by using `transform="auto"`. This will instruct any Parameter to to infer its type based on the suffix and transform it accordingly. + +!!! info "`transform="auto"` feature is available across all providers, including the high level functions" + +=== "transform_auto.py" + + ```python hl_lines="6" + from aws_lambda_powertools.utilities import parameters + + ssm_provider = parameters.SSMProvider() + + def handler(event, context): + values = ssm_provider.get_multiple("/param", transform="auto") + ``` + +For example, if you have two parameters with the following suffixes `.json` and `.binary`: + +| Parameter name | Parameter value | +| --------------- | -------------------- | +| /param/a.json | [some encoded value] | +| /param/a.binary | [some encoded value] | + +The return of `ssm_provider.get_multiple("/param", transform="auto")` call will be a dictionary like: + +```json +{ + "a.json": [some value], + "b.binary": [some value] +} +``` + ### Passing additional SDK arguments You can use arbitrary keyword arguments to pass it directly to the underlying SDK method. From 06cd8da1dcf02a5c6b062b49956e2013701e5884 Mon Sep 17 00:00:00 2001 From: Ran Isenberg <60175085+risenberg-cyberark@users.noreply.github.com> Date: Tue, 10 Aug 2021 22:18:55 +0300 Subject: [PATCH 27/35] fix(feature-flags): bug handling multiple conditions (#599) Co-authored-by: Heitor Lessa --- .../utilities/feature_flags/feature_flags.py | 12 ++++-- .../feature_flags/test_feature_flags.py | 38 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py index 50d65175e51..d04e74ff293 100644 --- a/aws_lambda_powertools/utilities/feature_flags/feature_flags.py +++ b/aws_lambda_powertools/utilities/feature_flags/feature_flags.py @@ -64,6 +64,13 @@ def _evaluate_conditions( rule_match_value = rule.get(schema.RULE_MATCH_VALUE) conditions = cast(List[Dict], rule.get(schema.CONDITIONS_KEY)) + if not conditions: + logger.debug( + f"rule did not match, no conditions to match, rule_name={rule_name}, rule_value={rule_match_value}, " + f"name={feature_name} " + ) + return False + for condition in conditions: context_value = context.get(str(condition.get(schema.CONDITION_KEY))) cond_action = condition.get(schema.CONDITION_ACTION, "") @@ -76,9 +83,8 @@ def _evaluate_conditions( ) return False # context doesn't match condition - logger.debug(f"rule matched, rule_name={rule_name}, rule_value={rule_match_value}, name={feature_name}") - return True - return False + logger.debug(f"rule matched, rule_name={rule_name}, rule_value={rule_match_value}, name={feature_name}") + return True def _evaluate_rules( self, *, feature_name: str, context: Dict[str, Any], feat_default: bool, rules: Dict[str, Any] diff --git a/tests/functional/feature_flags/test_feature_flags.py b/tests/functional/feature_flags/test_feature_flags.py index 9d647a81d2f..5342105da3d 100644 --- a/tests/functional/feature_flags/test_feature_flags.py +++ b/tests/functional/feature_flags/test_feature_flags.py @@ -121,6 +121,44 @@ def test_flags_conditions_no_match(mocker, config): # check that a rule can match when it has multiple conditions, see rule name for further explanation +def test_flags_conditions_rule_not_match_multiple_conditions_match_only_one_condition(mocker, config): + expected_value = False + tenant_id_val = "6" + username_val = "a" + mocked_app_config_schema = { + "my_feature": { + "default": expected_value, + "rules": { + "tenant id equals 6 and username is a": { + "when_match": True, + "conditions": [ + { + "action": RuleAction.EQUALS.value, # this condition matches + "key": "tenant_id", + "value": tenant_id_val, + }, + { + "action": RuleAction.EQUALS.value, # this condition does not + "key": "username", + "value": "bbb", + }, + ], + } + }, + } + } + feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config) + toggle = feature_flags.evaluate( + name="my_feature", + context={ + "tenant_id": tenant_id_val, + "username": username_val, + }, + default=True, + ) + assert toggle == expected_value + + def test_flags_conditions_rule_match_equal_multiple_conditions(mocker, config): expected_value = False tenant_id_val = "6" From 4aa84d3e0eea5cd8d2fb421cd0a783b335fb0724 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 21:26:15 +0200 Subject: [PATCH 28/35] docs(event-handler): new custom serializer option --- docs/core/event_handler/api_gateway.md | 43 ++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docs/core/event_handler/api_gateway.md b/docs/core/event_handler/api_gateway.md index a87eefbd5cd..a87daa3299a 100644 --- a/docs/core/event_handler/api_gateway.md +++ b/docs/core/event_handler/api_gateway.md @@ -730,6 +730,49 @@ This will enable full tracebacks errors in the response, print request and respo return app.resolve(event, context) ``` +### Custom serializer + +You can instruct API Gateway handler to use a custom serializer to best suit your needs, for example take into account Enums when serializing. + +=== "custom_serializer.py" + ```python hl_lines="19-20 24" + import json + from enum import Enum + from json import JSONEncoder + from typing import Dict + + class CustomEncoder(JSONEncoder): + """Your customer json encoder""" + def default(self, obj): + if isinstance(obj, Enum): + return obj.value + try: + iterable = iter(obj) + except TypeError: + pass + else: + return sorted(iterable) + return JSONEncoder.default(self, obj) + + def custom_serializer(obj) -> str: + """Your custom serializer function ApiGatewayResolver will use""" + return json.dumps(obj, cls=CustomEncoder) + + # Assigning your custom serializer + app = ApiGatewayResolver(serializer=custom_serializer) + + class Color(Enum): + RED = 1 + BLUE = 2 + + @app.get("/colors") + def get_color() -> Dict: + return { + # Color.RED will be serialized to 1 as expected now + "color": Color.RED, + "variations": {"light", "dark"}, + } + ``` ## Testing your code From 144084a3e50ff5f931f8f246ef9d8afe84636d9e Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 21:33:59 +0200 Subject: [PATCH 29/35] chore: bump to 1.19.0 --- CHANGELOG.md | 42 ++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49744f1b7d6..c7ed99783c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,48 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) fo ## [Unreleased] +## [1.19.0] - 2021-08-11 + +### Bug Fixes + +* **deps:** bump poetry to latest ([#592](https://github.com/awslabs/aws-lambda-powertools-python/issues/592)) +* **feature-flags:** bug handling multiple conditions ([#599](https://github.com/awslabs/aws-lambda-powertools-python/issues/599)) +* **parser:** API Gateway WebSocket validation under check_message_id; plus some housekeeping ([#553](https://github.com/awslabs/aws-lambda-powertools-python/issues/553)) + +### Code Refactoring + +* **feature-flags:** add debug statements for all feature evaluations ([#590](https://github.com/awslabs/aws-lambda-powertools-python/issues/590)) +* **feature-flags:** optimize UX and maintenance ([#563](https://github.com/awslabs/aws-lambda-powertools-python/issues/563)) + +### Documentation + +* **event-handler:** new custom serializer option +* **feature-flags:** create concrete documentation ([#594](https://github.com/awslabs/aws-lambda-powertools-python/issues/594)) +* **feature-flags:** correct docs and typing ([#588](https://github.com/awslabs/aws-lambda-powertools-python/issues/588)) +* **parameters:** auto-transforming values based on suffix ([#573](https://github.com/awslabs/aws-lambda-powertools-python/issues/573)) +* **readme:** add code coverage badge ([#577](https://github.com/awslabs/aws-lambda-powertools-python/issues/577)) +* **tracer:** update wording that it auto-disables on non-Lambda env + +### Features + +* **api-gateway:** add support for custom serializer ([#568](https://github.com/awslabs/aws-lambda-powertools-python/issues/568)) +* **data-classes:** decode json_body if based64 encoded ([#560](https://github.com/awslabs/aws-lambda-powertools-python/issues/560)) +* **feature-flags:** Add not_in action and rename contains to in ([#589](https://github.com/awslabs/aws-lambda-powertools-python/issues/589)) +* **params:** expose params `max_age`, `raise_on_transform_error` to high level functions ([#567](https://github.com/awslabs/aws-lambda-powertools-python/issues/567)) +* **tracer:** auto-disable tracer for non-Lambda environments to ease testing ([#598](https://github.com/awslabs/aws-lambda-powertools-python/issues/598)) + +### Maintenance + +* **deps:** bump boto3 from 1.18.1 to 1.18.15 ([#591](https://github.com/awslabs/aws-lambda-powertools-python/issues/591)) +* **deps:** bump codecov/codecov-action from 2.0.1 to 2.0.2 ([#558](https://github.com/awslabs/aws-lambda-powertools-python/issues/558)) +* **deps:** bump boto3 from 1.18.15 to 1.18.17 ([#597](https://github.com/awslabs/aws-lambda-powertools-python/issues/597)) +* **deps-dev:** bump mkdocs-material from 7.2.2 to 7.2.3 ([#596](https://github.com/awslabs/aws-lambda-powertools-python/issues/596)) +* **deps-dev:** bump mkdocs-material from 7.2.1 to 7.2.2 ([#582](https://github.com/awslabs/aws-lambda-powertools-python/issues/582)) +* **deps-dev:** bump pdoc3 from 0.9.2 to 0.10.0 ([#584](https://github.com/awslabs/aws-lambda-powertools-python/issues/584)) +* **deps-dev:** bump isort from 5.9.2 to 5.9.3 ([#574](https://github.com/awslabs/aws-lambda-powertools-python/issues/574)) +* **deps-dev:** bump mkdocs-material from 7.2.0 to 7.2.1 ([#566](https://github.com/awslabs/aws-lambda-powertools-python/issues/566)) +* **deps-dev:** bump mkdocs-material from 7.1.11 to 7.2.0 ([#551](https://github.com/awslabs/aws-lambda-powertools-python/issues/551)) +* **deps-dev:** bump flake8-black from 0.2.1 to 0.2.3 ([#541](https://github.com/awslabs/aws-lambda-powertools-python/issues/541)) ## [1.18.1] - 2021-07-23 ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index aad776d0a28..56b930f3084 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "aws_lambda_powertools" -version = "1.18.1" +version = "1.19.0" description = "Python utilities for AWS Lambda functions including but not limited to tracing, logging and custom metric" authors = ["Amazon Web Services"] include = ["aws_lambda_powertools/py.typed"] From 02642943779209826fe1dff4b9d6b6dcc6456507 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 21:35:44 +0200 Subject: [PATCH 30/35] chore: update pypi description, keywords --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 56b930f3084..5aeed9fdcf7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "aws_lambda_powertools" version = "1.19.0" -description = "Python utilities for AWS Lambda functions including but not limited to tracing, logging and custom metric" +description = "A suite of utilities for AWS Lambda functions to ease adopting best practices such as tracing, structured logging, custom metrics, and more." authors = ["Amazon Web Services"] include = ["aws_lambda_powertools/py.typed"] classifiers=[ @@ -15,7 +15,7 @@ classifiers=[ ] repository="https://github.com/awslabs/aws-lambda-powertools-python" readme = "README.md" -keywords = ["aws_lambda_powertools", "aws", "tracing", "logging", "lambda", "powertools"] +keywords = ["aws_lambda_powertools", "aws", "tracing", "logging", "lambda", "powertools", "feature_flags", "idempotency", "middleware"] license = "MIT-0" [tool.poetry.dependencies] From ccaf04c90bd43143afd4d5825189671683d3947b Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 21:51:00 +0200 Subject: [PATCH 31/35] docs(feature_flags): fix SAM infra, convert CDK to Python --- docs/utilities/feature_flags.md | 101 ++++++++++++++------------------ 1 file changed, 45 insertions(+), 56 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 806680e0794..c6a9f5ce942 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -52,21 +52,21 @@ The following sample infrastructure will be used throughout this documentation: FeatureStoreApp: Type: AWS::AppConfig::Application Properties: - Description: "AppConfig Appliction for feature toggles" - Name: my-app + Description: "AppConfig Application for feature toggles" + Name: product-catalogue FeatureStoreDevEnv: Type: AWS::AppConfig::Environment Properties: ApplicationId: !Ref FeatureStoreApp Description: "Development Environment for the App Config Store" - Name: "development" + Name: dev FeatureStoreConfigProfile: Type: AWS::AppConfig::ConfigurationProfile Properties: ApplicationId: !Ref FeatureStoreApp - Name: "MyTestProfile" + Name: features LocationUri: "hosted" HostedConfigVersion: @@ -110,69 +110,58 @@ The following sample infrastructure will be used throughout this documentation: === "CDK" - ```typescript hl_lines="2-7 13-32 34-35 40 47 54" - import * as cdk from '@aws-cdk/core'; - import { - CfnApplication, - CfnConfigurationProfile, CfnDeployment, - CfnEnvironment, - CfnHostedConfigurationVersion - } from "@aws-cdk/aws-appconfig"; + ```python hl_lines="13-29 31 33 37 41 47" + import json - export class CdkStack extends cdk.Stack { - constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { - super(scope, id, props); + import aws_cdk.aws_appconfig as appconfig + from aws_cdk import core - const featureConfig = { + + class SampleFeatureFlagStore(core.Construct): + ​ + def __init__(self, scope: core.Construct, id_: str) -> None: + super().__init__(scope, id_) + ​ + features_config = { "premium_features": { - "default": false, + "default": False, "rules": { "customer tier equals premium": { - "when_match": true, - "conditions": [ - { - "action": "EQUALS", - "key": "tier", - "value": "premium" - } - ] + "when_match": True, + "conditions": [{ + "action": "EQUALS", + "key": "tier", + "value": "premium" + }] } } }, "ten_percent_off_campaign": { - "default": true + "default": True } } - - const app = new CfnApplication(this, "app", {name: "productapp"}); - const env = new CfnEnvironment(this, "env", { - applicationId: app.ref, - name: "dev-env" - }); - - const configProfile = new CfnConfigurationProfile(this, "profile", { - applicationId: app.ref, - locationUri: "hosted", - name: "configProfile" - }); - - - const hostedConfigVersion = new CfnHostedConfigurationVersion(this, "version", { - applicationId: app.ref, - configurationProfileId: configProfile.ref, - content: JSON.stringify(featureConfig), - contentType: "application/json" - }); - - new CfnDeployment(this, "deploy", { - applicationId: app.ref, - configurationProfileId: configProfile.ref, - configurationVersion: hostedConfigVersion.ref, - deploymentStrategyId: "AppConfig.AllAtOnce", - environmentId: env.ref - }); - } - } + ​ + self.config_app = appconfig.CfnApplication(self, id='app', name="product-catalogue") + + self.config_env = appconfig.CfnEnvironment(self, id='env', + application_id=self.config_app.ref, + name="dev-env") + + self.config_profile = appconfig.CfnConfigurationProfile(self, id='profile', + application_id=self.config_app.ref, + location_uri='hosted', name="features") + ​ + self.hosted_cfg_version = appconfig.CfnHostedConfigurationVersion(self, 'version', + application_id=self.config_app.ref, + configuration_profile_id=self.config_profile.ref, + content=json_dumps(features_config), + content_type='application/json') + ​ + self.app_config_deployment = appconfig.CfnDeployment(self, id='deploy', application_id=self.config_app.ref, + configuration_profile_id=self.config_profile.ref, + configuration_version=self.hosted_cfg_version.ref, + deployment_strategy_id="AppConfig.AllAtOnce", + environment_id=self.config_env.ref) ``` ### Evaluating a single feature flag From 2a5be0ff2ff6392a87fa1e2c5948a5ce6233118f Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 21:52:22 +0200 Subject: [PATCH 32/35] chore: include feature-flags docs hotfix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7ed99783c1..dc5e216cd6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) fo * **parameters:** auto-transforming values based on suffix ([#573](https://github.com/awslabs/aws-lambda-powertools-python/issues/573)) * **readme:** add code coverage badge ([#577](https://github.com/awslabs/aws-lambda-powertools-python/issues/577)) * **tracer:** update wording that it auto-disables on non-Lambda env +* **feature-flags:** fix SAM infra, convert CDK to Python ### Features From 4318ac993329feefad483bf5fe09347a880f1611 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 10 Aug 2021 22:09:06 +0200 Subject: [PATCH 33/35] docs(feature-flags): fix sample feature name in evaluate --- CHANGELOG.md | 2 +- docs/utilities/feature_flags.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc5e216cd6e..1f5bca1cf21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) fo * **readme:** add code coverage badge ([#577](https://github.com/awslabs/aws-lambda-powertools-python/issues/577)) * **tracer:** update wording that it auto-disables on non-Lambda env * **feature-flags:** fix SAM infra, convert CDK to Python - +* **feature-flags:** fix sample feature name in evaluate method ### Features * **api-gateway:** add support for custom serializer ([#568](https://github.com/awslabs/aws-lambda-powertools-python/issues/568)) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index c6a9f5ce942..4870c97eb27 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -192,7 +192,7 @@ The `evaluate` method supports two optional parameters: # Evaluate whether customer's tier has access to premium features # based on `has_premium_features` rules - has_premium_features: bool = feature_flags.evaluate(name="has_premium_features", + has_premium_features: bool = feature_flags.evaluate(name="premium_features", context=ctx, default=False) if has_premium_features: # enable premium features From 1a21b3ff85ceb0af4187ef94cc8b64e0616e4345 Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Tue, 10 Aug 2021 22:31:18 -0700 Subject: [PATCH 34/35] fix(feature-toggles): correct cdk example (#601) --- docs/utilities/feature_flags.md | 110 +++++++++++++++++--------------- 1 file changed, 59 insertions(+), 51 deletions(-) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index 4870c97eb27..d955f2ef8c1 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -110,59 +110,67 @@ The following sample infrastructure will be used throughout this documentation: === "CDK" - ```python hl_lines="13-29 31 33 37 41 47" - import json - - import aws_cdk.aws_appconfig as appconfig - from aws_cdk import core + ```python hl_lines="11-22 24 29 35 42 50" + import json + + import aws_cdk.aws_appconfig as appconfig + from aws_cdk import core + + + class SampleFeatureFlagStore(core.Construct): + def __init__(self, scope: core.Construct, id_: str) -> None: + super().__init__(scope, id_) + + features_config = { + "premium_features": { + "default": False, + "rules": { + "customer tier equals premium": { + "when_match": True, + "conditions": [{"action": "EQUALS", "key": "tier", "value": "premium"}], + } + }, + }, + "ten_percent_off_campaign": {"default": True}, + } + self.config_app = appconfig.CfnApplication( + self, + id="app", + name="product-catalogue", + ) + self.config_env = appconfig.CfnEnvironment( + self, + id="env", + application_id=self.config_app.ref, + name="dev-env", + ) + self.config_profile = appconfig.CfnConfigurationProfile( + self, + id="profile", + application_id=self.config_app.ref, + location_uri="hosted", + name="features", + ) + self.hosted_cfg_version = appconfig.CfnHostedConfigurationVersion( + self, + "version", + application_id=self.config_app.ref, + configuration_profile_id=self.config_profile.ref, + content=json.dumps(features_config), + content_type="application/json", + ) + self.app_config_deployment = appconfig.CfnDeployment( + self, + id="deploy", + application_id=self.config_app.ref, + configuration_profile_id=self.config_profile.ref, + configuration_version=self.hosted_cfg_version.ref, + deployment_strategy_id="AppConfig.AllAtOnce", + environment_id=self.config_env.ref, + ) - class SampleFeatureFlagStore(core.Construct): - ​ - def __init__(self, scope: core.Construct, id_: str) -> None: - super().__init__(scope, id_) - ​ - features_config = { - "premium_features": { - "default": False, - "rules": { - "customer tier equals premium": { - "when_match": True, - "conditions": [{ - "action": "EQUALS", - "key": "tier", - "value": "premium" - }] - } - } - }, - "ten_percent_off_campaign": { - "default": True - } - } - ​ - self.config_app = appconfig.CfnApplication(self, id='app', name="product-catalogue") - - self.config_env = appconfig.CfnEnvironment(self, id='env', - application_id=self.config_app.ref, - name="dev-env") - - self.config_profile = appconfig.CfnConfigurationProfile(self, id='profile', - application_id=self.config_app.ref, - location_uri='hosted', name="features") - ​ - self.hosted_cfg_version = appconfig.CfnHostedConfigurationVersion(self, 'version', - application_id=self.config_app.ref, - configuration_profile_id=self.config_profile.ref, - content=json_dumps(features_config), - content_type='application/json') - ​ - self.app_config_deployment = appconfig.CfnDeployment(self, id='deploy', application_id=self.config_app.ref, - configuration_profile_id=self.config_profile.ref, - configuration_version=self.hosted_cfg_version.ref, - deployment_strategy_id="AppConfig.AllAtOnce", - environment_id=self.config_env.ref) - ``` + ``` ### Evaluating a single feature flag From f627b02ee3fb52859a620413bb8310c74c778046 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Wed, 11 Aug 2021 08:23:51 +0200 Subject: [PATCH 35/35] docs(feature-flags): add guidance when to use vs env vars vs parameters --- CHANGELOG.md | 2 ++ docs/utilities/feature_flags.md | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f5bca1cf21..84ff76df5d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) fo * **deps:** bump poetry to latest ([#592](https://github.com/awslabs/aws-lambda-powertools-python/issues/592)) * **feature-flags:** bug handling multiple conditions ([#599](https://github.com/awslabs/aws-lambda-powertools-python/issues/599)) * **parser:** API Gateway WebSocket validation under check_message_id; plus some housekeeping ([#553](https://github.com/awslabs/aws-lambda-powertools-python/issues/553)) +* **feature-toggles:** correct cdk example ([#601](https://github.com/awslabs/aws-lambda-powertools-python/issues/601)) ### Code Refactoring @@ -30,6 +31,7 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) fo * **tracer:** update wording that it auto-disables on non-Lambda env * **feature-flags:** fix SAM infra, convert CDK to Python * **feature-flags:** fix sample feature name in evaluate method +* **feature-flags:** add guidance when to use vs env vars vs parameters ### Features * **api-gateway:** add support for custom serializer ([#568](https://github.com/awslabs/aws-lambda-powertools-python/issues/568)) diff --git a/docs/utilities/feature_flags.md b/docs/utilities/feature_flags.md index d955f2ef8c1..556cf9f4925 100644 --- a/docs/utilities/feature_flags.md +++ b/docs/utilities/feature_flags.md @@ -644,3 +644,11 @@ You can unit test your feature flags locally and independently without setting u # THEN assert flag == expected_value ``` + +## Feature flags vs Parameters vs env vars + +Method | When to use | Requires new deployment on changes | Supported services +------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------- +**[Environment variables](https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html){target="_blank"}** | Simple configuration that will rarely if ever change, because changing it requires a Lambda function deployment. | Yes | Lambda +**[Parameters utility](parameters.md)** | Access to secrets, or fetch parameters in different formats from AWS System Manager Parameter Store or Amazon DynamoDB. | No | Parameter Store, DynamoDB, Secrets Manager, AppConfig +**Feature flags utility** | Rule engine to define when one or multiple features should be enabled depending on the input. | No | AppConfig