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
[](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
- Added support for search suggestions to save keystrokes
- Added support for search highlighting
- Added support for search sharing (i.e. deep linking)
Changelog
Sourced from mkdocs-material's changelog.
7.2.0 _ July 21, 2021
- Added support for search suggestions to save keystrokes
- Added support for search highlighting
- Added support for search sharing (i.e. deep linking)
Commits
f151f71
Prepare 7.2.0 release
d68fe91
Merge of Insiders features tied to 'Biquinho Vermelho' funding goal
ffd62bc
Merge branch 'master' of github.com:squidfunk/mkdocs-material
79e5285
Update cookie consent documentation
d2ccbf3
Added missing translations for French (#2852)
ec4de0d
Updated Insiders changelog
31f4d0b
Updated README
0bbcdd4
Updated cookie consent documentation
c843fc7
Fixed octicons example in icon documentation
- See full diff in compare view
[](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
- Fixed #2862: Back-to-top button overlays active search bar
Changelog
Sourced from mkdocs-material's changelog.
template: overrides/main.html
Changelog
Material for MkDocs
Commits
[](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
[](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)

+[](https://app.codecov.io/gh/awslabs/aws-lambda-powertools-python)
  
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
[](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
[](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
[](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
[](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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