From 43b828e9aa9736d857dfb42f0e16da32b1edbc47 Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Fri, 9 Jul 2021 01:36:30 -0700 Subject: [PATCH 01/11] feat(api-gateway): add debug mode (#507) Co-authored-by: Heitor Lessa --- .../event_handler/api_gateway.py | 95 +++++++++----- .../event_handler/content_types.py | 3 +- aws_lambda_powertools/shared/constants.py | 7 +- .../event_handler/test_api_gateway.py | 120 +++++++++++++++--- tests/functional/py.typed | 0 5 files changed, 172 insertions(+), 53 deletions(-) create mode 100644 tests/functional/py.typed diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 391b1e4a2c..b6e9cd4698 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -1,7 +1,9 @@ import base64 import json import logging +import os import re +import traceback import zlib from enum import Enum from http import HTTPStatus @@ -9,6 +11,8 @@ from aws_lambda_powertools.event_handler import content_types from aws_lambda_powertools.event_handler.exceptions import ServiceError +from aws_lambda_powertools.shared import constants +from aws_lambda_powertools.shared.functions import resolve_truthy_env_var_choice from aws_lambda_powertools.shared.json_encoder import Encoder from aws_lambda_powertools.utilities.data_classes import ALBEvent, APIGatewayProxyEvent, APIGatewayProxyEventV2 from aws_lambda_powertools.utilities.data_classes.common import BaseProxyEvent @@ -28,43 +32,46 @@ class ProxyEventType(Enum): class CORSConfig(object): """CORS Config - Examples -------- Simple cors example using the default permissive cors, not this should only be used during early prototyping - from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver + ```python + from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver - app = ApiGatewayResolver() + app = ApiGatewayResolver() - @app.get("/my/path", cors=True) - def with_cors(): - return {"message": "Foo"} + @app.get("/my/path", cors=True) + def with_cors(): + return {"message": "Foo"} + ``` Using a custom CORSConfig where `with_cors` used the custom provided CORSConfig and `without_cors` do not include any cors headers. - from aws_lambda_powertools.event_handler.api_gateway import ( - ApiGatewayResolver, CORSConfig - ) - - cors_config = CORSConfig( - allow_origin="https://wwww.example.com/", - expose_headers=["x-exposed-response-header"], - allow_headers=["x-custom-request-header"], - max_age=100, - allow_credentials=True, - ) - app = ApiGatewayResolver(cors=cors_config) - - @app.get("/my/path") - def with_cors(): - return {"message": "Foo"} + ```python + from aws_lambda_powertools.event_handler.api_gateway import ( + ApiGatewayResolver, CORSConfig + ) + + cors_config = CORSConfig( + allow_origin="https://wwww.example.com/", + expose_headers=["x-exposed-response-header"], + allow_headers=["x-custom-request-header"], + max_age=100, + allow_credentials=True, + ) + app = ApiGatewayResolver(cors=cors_config) + + @app.get("/my/path") + def with_cors(): + return {"message": "Foo"} - @app.get("/another-one", cors=False) - def without_cors(): - return {"message": "Foo"} + @app.get("/another-one", cors=False) + def without_cors(): + return {"message": "Foo"} + ``` """ _REQUIRED_HEADERS = ["Authorization", "Content-Type", "X-Amz-Date", "X-Api-Key", "X-Amz-Security-Token"] @@ -240,7 +247,12 @@ def lambda_handler(event, context): current_event: BaseProxyEvent lambda_context: LambdaContext - def __init__(self, proxy_type: Enum = ProxyEventType.APIGatewayProxyEvent, cors: CORSConfig = None): + def __init__( + self, + proxy_type: Enum = ProxyEventType.APIGatewayProxyEvent, + cors: CORSConfig = None, + debug: Optional[bool] = None, + ): """ Parameters ---------- @@ -248,12 +260,18 @@ def __init__(self, proxy_type: Enum = ProxyEventType.APIGatewayProxyEvent, cors: Proxy request type, defaults to API Gateway V1 cors: CORSConfig Optionally configure and enabled CORS. Not each route will need to have to cors=True + debug: Optional[bool] + Enables debug mode, by default False. Can be also be enabled by "POWERTOOLS_EVENT_HANDLER_DEBUG" + environment variable """ self._proxy_type = proxy_type self._routes: List[Route] = [] self._cors = cors self._cors_enabled: bool = cors is not None self._cors_methods: Set[str] = {"OPTIONS"} + self._debug = resolve_truthy_env_var_choice( + choice=debug, env=os.getenv(constants.EVENT_HANDLER_DEBUG_ENV, "false") + ) def get(self, rule: str, cors: bool = None, compress: bool = False, cache_control: str = None): """Get route decorator with GET `method` @@ -416,6 +434,8 @@ def resolve(self, event, context) -> Dict[str, Any]: dict Returns the dict response """ + if self._debug: + print(self._json_dump(event)) self.current_event = self._to_proxy_event(event) self.lambda_context = context return self._resolve().build(self.current_event, self._cors) @@ -489,6 +509,19 @@ def _call_route(self, route: Route, args: Dict[str, str]) -> ResponseBuilder: ), route, ) + except Exception: + if self._debug: + # If the user has turned on debug mode, + # we'll let the original exception propagate so + # they get more information about what went wrong. + return ResponseBuilder( + Response( + status_code=500, + content_type=content_types.TEXT_PLAIN, + body="".join(traceback.format_exc()), + ) + ) + raise def _to_response(self, result: Union[Dict, Response]) -> Response: """Convert the route's result to a Response @@ -509,7 +542,9 @@ def _to_response(self, result: Union[Dict, Response]) -> Response: body=self._json_dump(result), ) - @staticmethod - def _json_dump(obj: Any) -> str: - """Does a concise json serialization""" - return json.dumps(obj, separators=(",", ":"), cls=Encoder) + 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) diff --git a/aws_lambda_powertools/event_handler/content_types.py b/aws_lambda_powertools/event_handler/content_types.py index 00ec3db168..0f55b1088a 100644 --- a/aws_lambda_powertools/event_handler/content_types.py +++ b/aws_lambda_powertools/event_handler/content_types.py @@ -1,4 +1,5 @@ # use mimetypes library to be certain, e.g., mimetypes.types_map[".json"] APPLICATION_JSON = "application/json" -PLAIN_TEXT = "text/plain" +TEXT_PLAIN = "text/plain" +TEXT_HTML = "text/html" diff --git a/aws_lambda_powertools/shared/constants.py b/aws_lambda_powertools/shared/constants.py index eaad5640df..8388eded65 100644 --- a/aws_lambda_powertools/shared/constants.py +++ b/aws_lambda_powertools/shared/constants.py @@ -10,11 +10,12 @@ METRICS_NAMESPACE_ENV: str = "POWERTOOLS_METRICS_NAMESPACE" +EVENT_HANDLER_DEBUG_ENV: str = "POWERTOOLS_EVENT_HANDLER_DEBUG" + SAM_LOCAL_ENV: str = "AWS_SAM_LOCAL" CHALICE_LOCAL_ENV: str = "AWS_CHALICE_CLI_MODE" SERVICE_NAME_ENV: str = "POWERTOOLS_SERVICE_NAME" XRAY_TRACE_ID_ENV: str = "_X_AMZN_TRACE_ID" - -XRAY_SDK_MODULE = "aws_xray_sdk" -XRAY_SDK_CORE_MODULE = "aws_xray_sdk.core" +XRAY_SDK_MODULE: str = "aws_xray_sdk" +XRAY_SDK_CORE_MODULE: str = "aws_xray_sdk.core" diff --git a/tests/functional/event_handler/test_api_gateway.py b/tests/functional/event_handler/test_api_gateway.py index e542483d73..b39dccc608 100644 --- a/tests/functional/event_handler/test_api_gateway.py +++ b/tests/functional/event_handler/test_api_gateway.py @@ -5,6 +5,8 @@ from pathlib import Path from typing import Dict +import pytest + from aws_lambda_powertools.event_handler import content_types from aws_lambda_powertools.event_handler.api_gateway import ( ApiGatewayResolver, @@ -20,6 +22,7 @@ ServiceError, UnauthorizedError, ) +from aws_lambda_powertools.shared import constants from aws_lambda_powertools.shared.json_encoder import Encoder from aws_lambda_powertools.utilities.data_classes import ALBEvent, APIGatewayProxyEvent, APIGatewayProxyEventV2 from tests.functional.utils import load_event @@ -31,7 +34,6 @@ def read_media(file_name: str) -> bytes: LOAD_GW_EVENT = load_event("apiGatewayProxyEvent.json") -TEXT_HTML = "text/html" def test_alb_event(): @@ -42,7 +44,7 @@ def test_alb_event(): def foo(): assert isinstance(app.current_event, ALBEvent) assert app.lambda_context == {} - return Response(200, TEXT_HTML, "foo") + return Response(200, content_types.TEXT_HTML, "foo") # WHEN calling the event handler result = app(load_event("albEvent.json"), {}) @@ -50,7 +52,7 @@ def foo(): # THEN process event correctly # AND set the current_event type as ALBEvent assert result["statusCode"] == 200 - assert result["headers"]["Content-Type"] == TEXT_HTML + assert result["headers"]["Content-Type"] == content_types.TEXT_HTML assert result["body"] == "foo" @@ -80,7 +82,7 @@ def test_api_gateway(): @app.get("/my/path") def get_lambda() -> Response: assert isinstance(app.current_event, APIGatewayProxyEvent) - return Response(200, TEXT_HTML, "foo") + return Response(200, content_types.TEXT_HTML, "foo") # WHEN calling the event handler result = app(LOAD_GW_EVENT, {}) @@ -88,7 +90,7 @@ def get_lambda() -> Response: # THEN process event correctly # AND set the current_event type as APIGatewayProxyEvent assert result["statusCode"] == 200 - assert result["headers"]["Content-Type"] == TEXT_HTML + assert result["headers"]["Content-Type"] == content_types.TEXT_HTML assert result["body"] == "foo" @@ -100,7 +102,7 @@ def test_api_gateway_v2(): def my_path() -> Response: assert isinstance(app.current_event, APIGatewayProxyEventV2) post_data = app.current_event.json_body - return Response(200, content_types.PLAIN_TEXT, post_data["username"]) + return Response(200, content_types.TEXT_PLAIN, post_data["username"]) # WHEN calling the event handler result = app(load_event("apiGatewayProxyV2Event.json"), {}) @@ -108,7 +110,7 @@ def my_path() -> Response: # THEN process event correctly # AND set the current_event type as APIGatewayProxyEventV2 assert result["statusCode"] == 200 - assert result["headers"]["Content-Type"] == content_types.PLAIN_TEXT + assert result["headers"]["Content-Type"] == content_types.TEXT_PLAIN assert result["body"] == "tom" @@ -119,14 +121,14 @@ def test_include_rule_matching(): @app.get("//") def get_lambda(my_id: str, name: str) -> Response: assert name == "my" - return Response(200, TEXT_HTML, my_id) + return Response(200, content_types.TEXT_HTML, my_id) # WHEN calling the event handler result = app(LOAD_GW_EVENT, {}) # THEN assert result["statusCode"] == 200 - assert result["headers"]["Content-Type"] == TEXT_HTML + assert result["headers"]["Content-Type"] == content_types.TEXT_HTML assert result["body"] == "path" @@ -187,11 +189,11 @@ def test_cors(): @app.get("/my/path", cors=True) def with_cors() -> Response: - return Response(200, TEXT_HTML, "test") + return Response(200, content_types.TEXT_HTML, "test") @app.get("/without-cors") def without_cors() -> Response: - return Response(200, TEXT_HTML, "test") + return Response(200, content_types.TEXT_HTML, "test") def handler(event, context): return app.resolve(event, context) @@ -202,7 +204,7 @@ def handler(event, context): # THEN the headers should include cors headers assert "headers" in result headers = result["headers"] - assert headers["Content-Type"] == TEXT_HTML + assert headers["Content-Type"] == content_types.TEXT_HTML assert headers["Access-Control-Allow-Origin"] == "*" assert "Access-Control-Allow-Credentials" not in headers assert headers["Access-Control-Allow-Headers"] == ",".join(sorted(CORSConfig._REQUIRED_HEADERS)) @@ -268,7 +270,7 @@ def test_compress_no_accept_encoding(): @app.get("/my/path", compress=True) def return_text() -> Response: - return Response(200, content_types.PLAIN_TEXT, expected_value) + return Response(200, content_types.TEXT_PLAIN, expected_value) # WHEN calling the event handler result = app({"path": "/my/path", "httpMethod": "GET", "headers": {}}, None) @@ -284,7 +286,7 @@ def test_cache_control_200(): @app.get("/success", cache_control="max-age=600") def with_cache_control() -> Response: - return Response(200, TEXT_HTML, "has 200 response") + return Response(200, content_types.TEXT_HTML, "has 200 response") def handler(event, context): return app.resolve(event, context) @@ -295,7 +297,7 @@ def handler(event, context): # THEN return the set Cache-Control headers = result["headers"] - assert headers["Content-Type"] == TEXT_HTML + assert headers["Content-Type"] == content_types.TEXT_HTML assert headers["Cache-Control"] == "max-age=600" @@ -305,7 +307,7 @@ def test_cache_control_non_200(): @app.delete("/fails", cache_control="max-age=600") def with_cache_control_has_500() -> Response: - return Response(503, TEXT_HTML, "has 503 response") + return Response(503, content_types.TEXT_HTML, "has 503 response") def handler(event, context): return app.resolve(event, context) @@ -316,7 +318,7 @@ def handler(event, context): # THEN return a Cache-Control of "no-cache" headers = result["headers"] - assert headers["Content-Type"] == TEXT_HTML + assert headers["Content-Type"] == content_types.TEXT_HTML assert headers["Cache-Control"] == "no-cache" @@ -479,7 +481,7 @@ def test_custom_preflight_response(): def custom_preflight(): return Response( status_code=200, - content_type=TEXT_HTML, + content_type=content_types.TEXT_HTML, body="Foo", headers={"Access-Control-Allow-Methods": "CUSTOM"}, ) @@ -495,7 +497,7 @@ def custom_method(): assert result["statusCode"] == 200 assert result["body"] == "Foo" headers = result["headers"] - assert headers["Content-Type"] == TEXT_HTML + assert headers["Content-Type"] == content_types.TEXT_HTML assert "Access-Control-Allow-Origin" in result["headers"] assert headers["Access-Control-Allow-Methods"] == "CUSTOM" @@ -582,3 +584,83 @@ def service_error(): assert "Access-Control-Allow-Origin" in result["headers"] expected = {"statusCode": 502, "message": "Something went wrong!"} assert result["body"] == json_dump(expected) + + +def test_debug_unhandled_exceptions_debug_on(): + # GIVEN debug is enabled + # AND an unhandled exception is raised + app = ApiGatewayResolver(debug=True) + assert app._debug + + @app.get("/raises-error") + def raises_error(): + raise RuntimeError("Foo") + + # WHEN calling the handler + result = app({"path": "/raises-error", "httpMethod": "GET"}, None) + + # THEN return a 500 + # AND Content-Type is set to text/plain + # AND include the exception traceback in the response + assert result["statusCode"] == 500 + assert "Traceback (most recent call last)" in result["body"] + headers = result["headers"] + assert headers["Content-Type"] == content_types.TEXT_PLAIN + + +def test_debug_unhandled_exceptions_debug_off(): + # GIVEN debug is disabled + # AND an unhandled exception is raised + app = ApiGatewayResolver(debug=False) + assert not app._debug + + @app.get("/raises-error") + def raises_error(): + raise RuntimeError("Foo") + + # WHEN calling the handler + # THEN raise the original exception + with pytest.raises(RuntimeError) as e: + app({"path": "/raises-error", "httpMethod": "GET"}, None) + + # AND include the original error + assert e.value.args == ("Foo",) + + +def test_debug_mode_environment_variable(monkeypatch): + # GIVEN a debug mode environment variable is set + monkeypatch.setenv(constants.EVENT_HANDLER_DEBUG_ENV, "true") + app = ApiGatewayResolver() + + # WHEN calling app._debug + # THEN the debug mode is enabled + assert app._debug + + +def test_debug_json_formatting(): + # GIVEN debug is True + app = ApiGatewayResolver(debug=True) + response = {"message": "Foo"} + + @app.get("/foo") + def foo(): + return response + + # WHEN calling the handler + result = app({"path": "/foo", "httpMethod": "GET"}, None) + + # THEN return a pretty print json in the body + assert result["body"] == json.dumps(response, indent=4) + + +def test_debug_print_event(capsys): + # GIVE debug is True + app = ApiGatewayResolver(debug=True) + + # WHEN calling resolve + event = {"path": "/foo", "httpMethod": "GET"} + app(event, None) + + # THEN print the event + out, err = capsys.readouterr() + assert json.loads(out) == event diff --git a/tests/functional/py.typed b/tests/functional/py.typed new file mode 100644 index 0000000000..e69de29bb2 From 27c82d814701a3b7d3140b51e7ebd0f6e030d15d Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Mon, 12 Jul 2021 07:17:46 -0700 Subject: [PATCH 02/11] feat(mypy): add mypy support to makefile (#508) --- Makefile | 3 ++ aws_lambda_powertools/logging/logger.py | 10 ++-- .../utilities/validation/base.py | 6 +-- mypy.ini | 13 ++++++ poetry.lock | 46 ++++++++++++++++++- pyproject.toml | 1 + 6 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 mypy.ini diff --git a/Makefile b/Makefile index da43c1de67..e14ccf8dde 100644 --- a/Makefile +++ b/Makefile @@ -83,3 +83,6 @@ release: pr changelog: @echo "[+] Pre-generating CHANGELOG for tag: $$(git describe --abbrev=0 --tag)" docker run -v "${PWD}":/workdir quay.io/git-chglog/git-chglog $$(git describe --abbrev=0 --tag).. > TMP_CHANGELOG.md + +mypy: + poetry run mypy aws_lambda_powertools diff --git a/aws_lambda_powertools/logging/logger.py b/aws_lambda_powertools/logging/logger.py index 689409d981..72e22b38c7 100644 --- a/aws_lambda_powertools/logging/logger.py +++ b/aws_lambda_powertools/logging/logger.py @@ -4,7 +4,7 @@ import os import random import sys -from typing import Any, Callable, Dict, Iterable, Optional, TypeVar, Union +from typing import IO, Any, Callable, Dict, Iterable, Optional, TypeVar, Union import jmespath @@ -171,7 +171,7 @@ def __init__( level: Union[str, int] = None, child: bool = False, sampling_rate: float = None, - stream: sys.stdout = None, + stream: IO[str] = None, logger_formatter: Optional[PowertoolsFormatter] = None, logger_handler: Optional[logging.Handler] = None, **kwargs, @@ -363,7 +363,7 @@ def registered_handler(self) -> logging.Handler: @property def registered_formatter(self) -> Optional[PowertoolsFormatter]: """Convenience property to access logger formatter""" - return self.registered_handler.formatter + return self.registered_handler.formatter # type: ignore def structure_logs(self, append: bool = False, **keys): """Sets logging formatting to JSON. @@ -384,7 +384,7 @@ def structure_logs(self, append: bool = False, **keys): self.append_keys(**keys) else: log_keys = {**self._default_log_keys, **keys} - formatter = self.logger_formatter or LambdaPowertoolsFormatter(**log_keys) + formatter = self.logger_formatter or LambdaPowertoolsFormatter(**log_keys) # type: ignore self.registered_handler.setFormatter(formatter) def set_correlation_id(self, value: str): @@ -421,7 +421,7 @@ def _get_caller_filename(): def set_package_logger( - level: Union[str, int] = logging.DEBUG, stream: sys.stdout = None, formatter: logging.Formatter = None + level: Union[str, int] = logging.DEBUG, stream: IO[str] = None, formatter: logging.Formatter = None ): """Set an additional stream handler, formatter, and log level for aws_lambda_powertools package logger. diff --git a/aws_lambda_powertools/utilities/validation/base.py b/aws_lambda_powertools/utilities/validation/base.py index ec4165f487..ec63e39eb3 100644 --- a/aws_lambda_powertools/utilities/validation/base.py +++ b/aws_lambda_powertools/utilities/validation/base.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Union import fastjsonschema import jmespath @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) -def validate_data_against_schema(data: Dict, schema: Dict, formats: Optional[Dict] = None): +def validate_data_against_schema(data: Union[Dict, str], schema: Dict, formats: Optional[Dict] = None): """Validate dict data against given JSON Schema Parameters @@ -41,7 +41,7 @@ def validate_data_against_schema(data: Dict, schema: Dict, formats: Optional[Dic raise SchemaValidationError(message) -def unwrap_event_from_envelope(data: Dict, envelope: str, jmespath_options: Optional[Dict]) -> Any: +def unwrap_event_from_envelope(data: Union[Dict, str], envelope: str, jmespath_options: Optional[Dict]) -> Any: """Searches data using JMESPath expression Parameters diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000000..42185a692a --- /dev/null +++ b/mypy.ini @@ -0,0 +1,13 @@ +[mypy] +warn_return_any=False +warn_unused_configs=True +no_implicit_optional=True +warn_redundant_casts=True +warn_unused_ignores=True +pretty = True +show_column_numbers = True +show_error_codes = True +show_error_context = True + +[mypy-jmespath] +ignore_missing_imports=True diff --git a/poetry.lock b/poetry.lock index 9a6f1fdbc0..adcf21ab46 100644 --- a/poetry.lock +++ b/poetry.lock @@ -616,6 +616,24 @@ python-versions = ">=3.5" [package.dependencies] mkdocs-material = ">=5.0.0" +[[package]] +name = "mypy" +version = "0.910" +description = "Optional static typing for Python" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +mypy-extensions = ">=0.4.3,<0.5.0" +toml = "*" +typed-ast = {version = ">=1.4.0,<1.5.0", markers = "python_version < \"3.8\""} +typing-extensions = ">=3.7.4" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +python2 = ["typed-ast (>=1.4.0,<1.5.0)"] + [[package]] name = "mypy-extensions" version = "0.4.3" @@ -1066,7 +1084,7 @@ pydantic = ["pydantic", "email-validator"] [metadata] lock-version = "1.1" python-versions = "^3.6.1" -content-hash = "c07aad70013171c8bb000d163f997efef709189584089f68d471f69eb8bb38c0" +content-hash = "d9c5750e95bd6f09037f407d2f94649c1c52b1d26ec8a50e52d1c9399e427cf9" [metadata.files] appdirs = [ @@ -1195,7 +1213,6 @@ flake8 = [ ] 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"}, ] flake8-bugbear = [ {file = "flake8-bugbear-21.4.3.tar.gz", hash = "sha256:2346c81f889955b39e4a368eb7d508de723d9de05716c287dc860a4073dc57e7"}, @@ -1350,6 +1367,31 @@ mkdocs-material-extensions = [ {file = "mkdocs-material-extensions-1.0.1.tar.gz", hash = "sha256:6947fb7f5e4291e3c61405bad3539d81e0b3cd62ae0d66ced018128af509c68f"}, {file = "mkdocs_material_extensions-1.0.1-py3-none-any.whl", hash = "sha256:d90c807a88348aa6d1805657ec5c0b2d8d609c110e62b9dce4daf7fa981fa338"}, ] +mypy = [ + {file = "mypy-0.910-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a155d80ea6cee511a3694b108c4494a39f42de11ee4e61e72bc424c490e46457"}, + {file = "mypy-0.910-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b94e4b785e304a04ea0828759172a15add27088520dc7e49ceade7834275bedb"}, + {file = "mypy-0.910-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:088cd9c7904b4ad80bec811053272986611b84221835e079be5bcad029e79dd9"}, + {file = "mypy-0.910-cp35-cp35m-win_amd64.whl", hash = "sha256:adaeee09bfde366d2c13fe6093a7df5df83c9a2ba98638c7d76b010694db760e"}, + {file = "mypy-0.910-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ecd2c3fe726758037234c93df7e98deb257fd15c24c9180dacf1ef829da5f921"}, + {file = "mypy-0.910-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d9dd839eb0dc1bbe866a288ba3c1afc33a202015d2ad83b31e875b5905a079b6"}, + {file = "mypy-0.910-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:3e382b29f8e0ccf19a2df2b29a167591245df90c0b5a2542249873b5c1d78212"}, + {file = "mypy-0.910-cp36-cp36m-win_amd64.whl", hash = "sha256:53fd2eb27a8ee2892614370896956af2ff61254c275aaee4c230ae771cadd885"}, + {file = "mypy-0.910-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b6fb13123aeef4a3abbcfd7e71773ff3ff1526a7d3dc538f3929a49b42be03f0"}, + {file = "mypy-0.910-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e4dab234478e3bd3ce83bac4193b2ecd9cf94e720ddd95ce69840273bf44f6de"}, + {file = "mypy-0.910-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:7df1ead20c81371ccd6091fa3e2878559b5c4d4caadaf1a484cf88d93ca06703"}, + {file = "mypy-0.910-cp37-cp37m-win_amd64.whl", hash = "sha256:0aadfb2d3935988ec3815952e44058a3100499f5be5b28c34ac9d79f002a4a9a"}, + {file = "mypy-0.910-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ec4e0cd079db280b6bdabdc807047ff3e199f334050db5cbb91ba3e959a67504"}, + {file = "mypy-0.910-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:119bed3832d961f3a880787bf621634ba042cb8dc850a7429f643508eeac97b9"}, + {file = "mypy-0.910-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:866c41f28cee548475f146aa4d39a51cf3b6a84246969f3759cb3e9c742fc072"}, + {file = "mypy-0.910-cp38-cp38-win_amd64.whl", hash = "sha256:ceb6e0a6e27fb364fb3853389607cf7eb3a126ad335790fa1e14ed02fba50811"}, + {file = "mypy-0.910-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a85e280d4d217150ce8cb1a6dddffd14e753a4e0c3cf90baabb32cefa41b59e"}, + {file = "mypy-0.910-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42c266ced41b65ed40a282c575705325fa7991af370036d3f134518336636f5b"}, + {file = "mypy-0.910-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:3c4b8ca36877fc75339253721f69603a9c7fdb5d4d5a95a1a1b899d8b86a4de2"}, + {file = "mypy-0.910-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:c0df2d30ed496a08de5daed2a9ea807d07c21ae0ab23acf541ab88c24b26ab97"}, + {file = "mypy-0.910-cp39-cp39-win_amd64.whl", hash = "sha256:c6c2602dffb74867498f86e6129fd52a2770c48b7cd3ece77ada4fa38f94eba8"}, + {file = "mypy-0.910-py3-none-any.whl", hash = "sha256:ef565033fa5a958e62796867b1df10c40263ea9ded87164d67572834e57a174d"}, + {file = "mypy-0.910.tar.gz", hash = "sha256:704098302473cb31a218f1775a873b376b30b4c18229421e9e9dc8916fd16150"}, +] mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, diff --git a/pyproject.toml b/pyproject.toml index f7743f2eba..58579dac1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ flake8-bugbear = "^21.3.2" mkdocs-material = "^7.1.9" mkdocs-git-revision-date-plugin = "^0.3.1" mike = "^0.6.0" +mypy = "^0.910" [tool.poetry.extras] From 3efc98f7d2daa880919c49b2349b818111f05ac7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Jul 2021 04:39:59 +0000 Subject: [PATCH 03/11] chore(deps-dev): bump isort from 5.9.1 to 5.9.2 (#514) Bumps [isort](https://github.com/pycqa/isort) from 5.9.1 to 5.9.2.
Release notes

Sourced from isort's releases.

5.9.2 July 8th 2021

  • Improved behavior of isort --check --atomic against Cython files.
  • Fixed #1769: Future imports added below assignments when no other imports present.
  • Fixed #1772: skip-gitignore will check files not in the git repository.
  • Fixed #1762: in some cases when skip-gitignore is set, isort fails to skip any files.
  • Fixed #1767: Encoding issues surfacing when invalid characters set in __init__.py files during placement.
  • Fixed #1771: Improved handling of skips against named streamed in content.
Changelog

Sourced from isort's changelog.

5.9.2 July 8th 2021

  • Improved behavior of isort --check --atomic against Cython files.
  • Fixed #1769: Future imports added below assignments when no other imports present.
  • Fixed #1772: skip-gitignore will check files not in the git repository.
  • Fixed #1762: in some cases when skip-gitignore is set, isort fails to skip any files.
  • Fixed #1767: Encoding issues surfacing when invalid characters set in __init__.py files during placement.
  • Fixed #1771: Improved handling of skips against named streamed in content.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=isort&package-manager=pip&previous-version=5.9.1&new-version=5.9.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--- poetry.lock | 9 +++++---- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index adcf21ab46..16024c0ad0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -413,7 +413,7 @@ python-versions = "*" [[package]] name = "isort" -version = "5.9.1" +version = "5.9.2" description = "A Python utility / library to sort Python imports." category = "dev" optional = false @@ -1084,7 +1084,7 @@ pydantic = ["pydantic", "email-validator"] [metadata] lock-version = "1.1" python-versions = "^3.6.1" -content-hash = "d9c5750e95bd6f09037f407d2f94649c1c52b1d26ec8a50e52d1c9399e427cf9" +content-hash = "280bed1df43e61ee78cc81ad974e54ce98b2a3f0fa8246acb98da86a4c6c320e" [metadata.files] appdirs = [ @@ -1213,6 +1213,7 @@ flake8 = [ ] 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"}, ] flake8-bugbear = [ {file = "flake8-bugbear-21.4.3.tar.gz", hash = "sha256:2346c81f889955b39e4a368eb7d508de723d9de05716c287dc860a4073dc57e7"}, @@ -1273,8 +1274,8 @@ iniconfig = [ {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] isort = [ - {file = "isort-5.9.1-py3-none-any.whl", hash = "sha256:8e2c107091cfec7286bc0f68a547d0ba4c094d460b732075b6fba674f1035c0c"}, - {file = "isort-5.9.1.tar.gz", hash = "sha256:83510593e07e433b77bd5bff0f6f607dbafa06d1a89022616f02d8b699cfcd56"}, + {file = "isort-5.9.2-py3-none-any.whl", hash = "sha256:eed17b53c3e7912425579853d078a0832820f023191561fcee9d7cae424e0813"}, + {file = "isort-5.9.2.tar.gz", hash = "sha256:f65ce5bd4cbc6abdfbe29afc2f0245538ab358c14590912df638033f157d555e"}, ] jinja2 = [ {file = "Jinja2-3.0.1-py3-none-any.whl", hash = "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4"}, diff --git a/pyproject.toml b/pyproject.toml index 58579dac1b..6385da9d2d 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.1" +isort = "^5.9.2" pytest-cov = "^2.12.1" pytest-mock = "^3.5.1" pdoc3 = "^0.9.2" From 2ea419a6546981a21cff259893073894992eb6fc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Jul 2021 05:41:09 +0000 Subject: [PATCH 04/11] chore(deps): bump boto3 from 1.17.102 to 1.17.110 (#523) Bumps [boto3](https://github.com/boto/boto3) from 1.17.102 to 1.17.110.
Changelog

Sourced from boto3's changelog.

1.17.110

  • api-change:eks: [botocore] Documentation updates for Wesley to support the parallel node upgrade feature.
  • api-change:kendra: [botocore] Amazon Kendra now supports Principal Store

1.17.109

  • api-change:sagemaker: [botocore] Releasing new APIs related to Tuning steps in model building pipelines.
  • api-change:frauddetector: [botocore] This release adds support for ML Explainability to display model variable importance value in Amazon Fraud Detector.
  • api-change:mediaconvert: [botocore] MediaConvert now supports color, style and position information passthrough from 608 and Teletext to SRT and WebVTT subtitles. MediaConvert now also supports Automatic QVBR quality levels for QVBR RateControlMode.

1.17.108

  • api-change:eks: [botocore] Added waiters for EKS FargateProfiles.
  • api-change:outposts: [botocore] Added property filters for listOutposts
  • api-change:fms: [botocore] AWS Firewall Manager now supports route table monitoring, and provides remediation action recommendations to security administrators for AWS Network Firewall policies with misconfigured routes.
  • api-change:mediatailor: [botocore] Add ListAlerts for Channel, Program, Source Location, and VOD Source to return alerts for resources.
  • api-change:devops-guru: [botocore] Add AnomalyReportedTimeRange field to include open and close time of anomalies.
  • api-change:ssm-contacts: [botocore] Updated description for CreateContactChannel contactId.

1.17.107

  • api-change:iam: [botocore] Documentation updates for AWS Identity and Access Management (IAM).
  • api-change:sts: [botocore] Documentation updates for AWS Security Token Service.
  • api-change:mq: [botocore] adds support for modifying the maintenance window for brokers.
  • api-change:cloudfront: [botocore] Amazon CloudFront now provides two new APIs, ListConflictingAliases and AssociateAlias, that help locate and move Alternate Domain Names (CNAMEs) if you encounter the CNAMEAlreadyExists error code.
  • api-change:chime: [botocore] Releasing new APIs for AWS Chime MediaCapturePipeline
  • api-change:iotsitewise: [botocore] This release add storage configuration APIs for AWS IoT SiteWise.
  • api-change:storagegateway: [botocore] Adding support for oplocks for SMB file shares, S3 Access Point and S3 Private Link for all file shares and IP address support for file system associations
  • api-change:ec2: [botocore] This release adds resource ids and tagging support for VPC security group rules.

1.17.106

  • api-change:lambda: [botocore] Added support for AmazonMQRabbitMQ as an event source. Added support for VIRTUAL_HOST as SourceAccessType for streams event source mappings.
  • api-change:imagebuilder: [botocore] Adds support for specifying parameters to customize components for recipes. Expands configuration of the Amazon EC2 instances that are used for building and testing images, including the ability to specify commands to run on launch, and more control over installation and removal of the SSM agent.
  • api-change:mgn: [botocore] Bug fix: Remove not supported EBS encryption type "NONE"
  • api-change:eks: [botocore] Adding new error code UnsupportedAddonModification for Addons in EKS
  • api-change:macie2: [botocore] Sensitive data findings in Amazon Macie now include enhanced location data for JSON and JSON Lines files
  • api-change:sns: [botocore] Documentation updates for Amazon SNS.

... (truncated)

Commits
  • 454ddc9 Merge branch 'release-1.17.110'
  • 1de1a9e Bumping version to 1.17.110
  • c193c24 Add changelog entries from botocore
  • eee2f26 Merge branch 'release-1.17.109'
  • ee6ee3b Merge branch 'release-1.17.109' into develop
  • 77c1ab9 Bumping version to 1.17.109
  • 66b5997 Add changelog entries from botocore
  • 7142baa Merge branch 'release-1.17.108'
  • 646aeab Merge branch 'release-1.17.108' into develop
  • 63ecaa5 Bumping version to 1.17.108
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=boto3&package-manager=pip&previous-version=1.17.102&new-version=1.17.110)](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 16024c0ad0..f1616f1373 100644 --- a/poetry.lock +++ b/poetry.lock @@ -81,20 +81,20 @@ d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] [[package]] name = "boto3" -version = "1.17.102" +version = "1.17.110" description = "The AWS SDK for Python" category = "main" optional = false python-versions = ">= 2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [package.dependencies] -botocore = ">=1.20.102,<1.21.0" +botocore = ">=1.20.110,<1.21.0" jmespath = ">=0.7.1,<1.0.0" s3transfer = ">=0.4.0,<0.5.0" [[package]] name = "botocore" -version = "1.20.102" +version = "1.20.110" description = "Low-level, data-driven core of boto 3." category = "main" optional = false @@ -1111,12 +1111,12 @@ black = [ {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, ] boto3 = [ - {file = "boto3-1.17.102-py2.py3-none-any.whl", hash = "sha256:6300e9ee9a404038113250bd218e2c4827f5e676efb14e77de2ad2dcb67679bc"}, - {file = "boto3-1.17.102.tar.gz", hash = "sha256:be4714f0475c1f5183eea09ddbf568ced6fa41b0fc9976f2698b8442e1b17303"}, + {file = "boto3-1.17.110-py2.py3-none-any.whl", hash = "sha256:3a7b183def075f6fe17c1154ecec42fc42f9c4ac05a7e7e018f267b7d5ef5961"}, + {file = "boto3-1.17.110.tar.gz", hash = "sha256:00be3c440db39a34a049eabce79377a0b3d453b6a24e2fa52e5156fa08f929bd"}, ] botocore = [ - {file = "botocore-1.20.102-py2.py3-none-any.whl", hash = "sha256:bdf08a4f7f01ead00d386848f089c08270499711447569c18d0db60023619c06"}, - {file = "botocore-1.20.102.tar.gz", hash = "sha256:2f57f7ceed1598d96cc497aeb45317db5d3b21a5aafea4732d0e561d0fc2a8fa"}, + {file = "botocore-1.20.110-py2.py3-none-any.whl", hash = "sha256:3500d0f0f15240a86efa6be91bf37df412d8cc10fc4b98ffea369dc13fb014da"}, + {file = "botocore-1.20.110.tar.gz", hash = "sha256:b69fd6c72d30b2ea0a42e7a2c3b9d65da3f4ccdff57bfaf6c721b0555a971bd6"}, ] certifi = [ {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, From c52a98768c28a51f53f2f24dfd947e80b6431170 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Jul 2021 09:00:13 +0000 Subject: [PATCH 05/11] chore(deps-dev): bump mkdocs-material from 7.1.9 to 7.1.10 (#522) Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 7.1.9 to 7.1.10.
Release notes

Sourced from mkdocs-material's releases.

mkdocs-material-7.1.10

  • Refactored appearance of back-to-top button
  • Fixed graceful handling of search when browsing locally
Changelog

Sourced from mkdocs-material's changelog.

7.1.10 _ July 10, 2021

  • Refactored appearance of back-to-top button
  • Fixed graceful handling of search when browsing locally
Commits
  • 714a9cc Prepare 7.1.10 release
  • 30a4d58 Added documentation for cookie consent
  • 580f118 Improved graceful handling of broken search when browsing locally
  • 52d773b Added distribution files
  • 4fb80ea Fixed back-to-top button overlaying search
  • 7abeb68 Fixed rendering of back-to-top button when used with sticky tabs
  • 7ca4ca1 Fixed transitions on back-to-top button
  • f6efb78 Updated dependencies
  • e4d6207 Updated dependencies
  • 68de4db Removed back-to-top button in print view
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=mkdocs-material&package-manager=pip&previous-version=7.1.9&new-version=7.1.10)](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 f1616f1373..9973d2cc45 100644 --- a/poetry.lock +++ b/poetry.lock @@ -592,7 +592,7 @@ mkdocs = ">=0.17" [[package]] name = "mkdocs-material" -version = "7.1.9" +version = "7.1.10" description = "A Material Design theme for MkDocs" category = "dev" optional = false @@ -1084,7 +1084,7 @@ pydantic = ["pydantic", "email-validator"] [metadata] lock-version = "1.1" python-versions = "^3.6.1" -content-hash = "280bed1df43e61ee78cc81ad974e54ce98b2a3f0fa8246acb98da86a4c6c320e" +content-hash = "97a8aca56202d6047233c32cf8960b85b1fbee1218663c1654288fa40c3268ef" [metadata.files] appdirs = [ @@ -1361,8 +1361,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.9.tar.gz", hash = "sha256:5a2fd487f769f382a7c979e869e4eab1372af58d7dec44c4365dd97ef5268cb5"}, - {file = "mkdocs_material-7.1.9-py2.py3-none-any.whl", hash = "sha256:92c8a2bd3bd44d5948eefc46ba138e2d3285cac658900112b6bf5722c7d067a5"}, + {file = "mkdocs-material-7.1.10.tar.gz", hash = "sha256:890e9be00bfbe4d22ccccbcde1bf9bad67a3ba495f2a7d2422ea4acb5099f014"}, + {file = "mkdocs_material-7.1.10-py2.py3-none-any.whl", hash = "sha256:92ff8c4a8e78555ef7b7ed0ba3043421d18971b48d066ea2cefb50e889fc66db"}, ] mkdocs-material-extensions = [ {file = "mkdocs-material-extensions-1.0.1.tar.gz", hash = "sha256:6947fb7f5e4291e3c61405bad3539d81e0b3cd62ae0d66ced018128af509c68f"}, diff --git a/pyproject.toml b/pyproject.toml index 6385da9d2d..f65572f7b3 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.9" +mkdocs-material = "^7.1.10" mkdocs-git-revision-date-plugin = "^0.3.1" mike = "^0.6.0" mypy = "^0.910" From c0c32bc5cab5b8e7b1cec00cef0e6cb791cea4e3 Mon Sep 17 00:00:00 2001 From: Ran Isenberg <60175085+risenberg-cyberark@users.noreply.github.com> Date: Wed, 14 Jul 2021 13:02:13 +0300 Subject: [PATCH 06/11] feat(feat-toggle): New simple feature toggles rule engine (WIP) (#494) Main features: * Define global boolean feature toggles * Define boolean feature toggles per customer, email or any other key/value. Keys are always strings, values can be other valid json types. * Use get_configuration API to get the entire configuration dict. Basically, it's an easy way to get a JSON file, the same way the AppConfig utility did. * get_all_enabled_feature_toggles - get a list of strings - names of boolean feature toggles that are True according to the input context, i.e. all the rules that matched/True by default. * Current recommended default is to use AppConfig as the feature store but allows for extension with other services via the Schema Fetcher. Before releasing to prod we should fix: * Missing docstrings with examples on how to use it in public Classes and public methods * Document and explain the rules mechanism and rule match flow. * Review whether we have sufficient logger.debug coverage for future diagnostic * Docs: Extract key features for getting started vs advanced * Use mypy doc strings --- .../utilities/feature_toggles/__init__.py | 16 + .../feature_toggles/appconfig_fetcher.py | 57 +++ .../feature_toggles/configuration_store.py | 191 ++++++++ .../utilities/feature_toggles/exceptions.py | 2 + .../utilities/feature_toggles/schema.py | 83 ++++ .../feature_toggles/schema_fetcher.py | 20 + .../utilities/parameters/appconfig.py | 2 +- tests/functional/feature_toggles/__init__.py | 0 .../feature_toggles/test_feature_toggles.py | 422 ++++++++++++++++++ .../feature_toggles/test_schema_validation.py | 264 +++++++++++ 10 files changed, 1056 insertions(+), 1 deletion(-) create mode 100644 aws_lambda_powertools/utilities/feature_toggles/__init__.py create mode 100644 aws_lambda_powertools/utilities/feature_toggles/appconfig_fetcher.py create mode 100644 aws_lambda_powertools/utilities/feature_toggles/configuration_store.py create mode 100644 aws_lambda_powertools/utilities/feature_toggles/exceptions.py create mode 100644 aws_lambda_powertools/utilities/feature_toggles/schema.py create mode 100644 aws_lambda_powertools/utilities/feature_toggles/schema_fetcher.py create mode 100644 tests/functional/feature_toggles/__init__.py create mode 100644 tests/functional/feature_toggles/test_feature_toggles.py create mode 100644 tests/functional/feature_toggles/test_schema_validation.py diff --git a/aws_lambda_powertools/utilities/feature_toggles/__init__.py b/aws_lambda_powertools/utilities/feature_toggles/__init__.py new file mode 100644 index 0000000000..378f7e23f4 --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_toggles/__init__.py @@ -0,0 +1,16 @@ +"""Advanced feature toggles utility +""" +from .appconfig_fetcher import AppConfigFetcher +from .configuration_store import ConfigurationStore +from .exceptions import ConfigurationException +from .schema import ACTION, SchemaValidator +from .schema_fetcher import SchemaFetcher + +__all__ = [ + "ConfigurationException", + "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 new file mode 100644 index 0000000000..177d4ed0ae --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_toggles/appconfig_fetcher.py @@ -0,0 +1,57 @@ +import logging +from typing import Any, Dict, Optional + +from botocore.config import Config + +from aws_lambda_powertools.utilities.parameters import AppConfigProvider, GetParameterError, TransformParameterError + +from .exceptions import ConfigurationException +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 + + Args: + 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: + ConfigurationException: Any validation error or appconfig error that can occur + + Returns: + 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 + except (GetParameterError, TransformParameterError) as exc: + error_str = f"unable to get AWS AppConfig configuration file, exception={str(exc)}" + self._logger.error(error_str) + raise ConfigurationException(error_str) diff --git a/aws_lambda_powertools/utilities/feature_toggles/configuration_store.py b/aws_lambda_powertools/utilities/feature_toggles/configuration_store.py new file mode 100644 index 0000000000..e540447737 --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_toggles/configuration_store.py @@ -0,0 +1,191 @@ +import logging +from typing import Any, Dict, List, Optional + +from . import schema +from .exceptions import ConfigurationException +from .schema_fetcher import SchemaFetcher + +logger = logging.getLogger(__name__) + + +class ConfigurationStore: + def __init__(self, schema_fetcher: SchemaFetcher): + """constructor + + Args: + 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: Dict[str, str] = rule.get(schema.CONDITIONS_KEY) + + for condition in conditions: + context_value = rules_context.get(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}, feature_name={feature_name}, context_value={str(context_value)}" # noqa: E501 + ) + # 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}, feature_name={feature_name}" # noqa: E501 + ) + return True + + 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 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}, feature_name={feature_name}" # noqa: E501 + ) + return feature_default_value + + def get_configuration(self) -> Dict[str, Any]: + """Get configuration string from AWs AppConfig and returned the parsed JSON dictionary + + Raises: + ConfigurationException: Any validation error or appconfig error that can occur + + Returns: + Dict[str, Any]: parsed JSON dictionary + """ + schema: Dict[ + str, Any + ] = ( + self._schema_fetcher.get_json_configuration() + ) # parse result conf as JSON, keep in cache for self.max_age seconds + # validate schema + self._schema_validator.validate_json_schema(schema) + return schema + + 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. + + Args: + 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 ConfigurationException: + logger.error("unable to get feature toggles JSON, returning provided value_if_missing value") # noqa: E501 + 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, feature_name={feature_name}, value_if_missing={value_if_missing}" # noqa: E501 + ) + 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}, default_value={feature_default_value}" # noqa: E501 + ) + return 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}" + ) # noqa: E501 + return self._handle_rules( + feature_name=feature_name, + rules_context=rules_context, + feature_default_value=feature_default_value, + rules=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) + + Args: + 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 ConfigurationException: + logger.error("unable to get feature toggles JSON") # noqa: E501 + 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 new file mode 100644 index 0000000000..9bbb5f200b --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_toggles/exceptions.py @@ -0,0 +1,2 @@ +class ConfigurationException(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 new file mode 100644 index 0000000000..58e75fabfc --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_toggles/schema.py @@ -0,0 +1,83 @@ +from enum import Enum +from typing import Any, Dict + +from .exceptions import ConfigurationException + +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: object): + self._logger = logger + + def _raise_conf_exc(self, error_str: str) -> None: + self._logger.error(error_str) + raise ConfigurationException(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): + 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: Dict = schema.get(FEATURES_KEY) + if not isinstance(features_dict, dict): + 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 new file mode 100644 index 0000000000..37dee63f7f --- /dev/null +++ b/aws_lambda_powertools/utilities/feature_toggles/schema_fetcher.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractclassmethod +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 + + @abstractclassmethod + def get_json_configuration(self) -> Dict[str, Any]: + """Get configuration string from any configuration storing service and return the parsed JSON dictionary + + Raises: + ConfigurationException: Any error that can occur during schema fetch or JSON parse + + Returns: + Dict[str, Any]: parsed JSON dictionary + """ + return None diff --git a/aws_lambda_powertools/utilities/parameters/appconfig.py b/aws_lambda_powertools/utilities/parameters/appconfig.py index 4490e26036..63a8415f1e 100644 --- a/aws_lambda_powertools/utilities/parameters/appconfig.py +++ b/aws_lambda_powertools/utilities/parameters/appconfig.py @@ -149,7 +149,7 @@ def get_app_config( >>> print(value) My configuration value - **Retrieves a confiugration value and decodes it using a JSON decoder** + **Retrieves a configuration value and decodes it using a JSON decoder** >>> from aws_lambda_powertools.utilities.parameters import get_parameter >>> diff --git a/tests/functional/feature_toggles/__init__.py b/tests/functional/feature_toggles/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/functional/feature_toggles/test_feature_toggles.py b/tests/functional/feature_toggles/test_feature_toggles.py new file mode 100644 index 0000000000..27f89eb151 --- /dev/null +++ b/tests/functional/feature_toggles/test_feature_toggles.py @@ -0,0 +1,422 @@ +from typing import Dict, List + +import pytest # noqa: F401 +from botocore.config import Config + +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 + + +@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 + + +# 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 diff --git a/tests/functional/feature_toggles/test_schema_validation.py b/tests/functional/feature_toggles/test_schema_validation.py new file mode 100644 index 0000000000..3b024c854b --- /dev/null +++ b/tests/functional/feature_toggles/test_schema_validation.py @@ -0,0 +1,264 @@ +import logging + +import pytest # noqa: F401 + +from aws_lambda_powertools.utilities.feature_toggles.exceptions import ConfigurationException +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(ConfigurationException): + validator.validate_json_schema(schema) + + schema = [] + # invalid type + with pytest.raises(ConfigurationException): + validator.validate_json_schema(schema) + + # invalid features key + schema = {FEATURES_KEY: []} + with pytest.raises(ConfigurationException): + 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(ConfigurationException): + validator.validate_json_schema(schema) + + # empty feature dict + schema = {FEATURES_KEY: {"my_feature": {}}} + with pytest.raises(ConfigurationException): + 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(ConfigurationException): + 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(ConfigurationException): + 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(ConfigurationException): + 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(ConfigurationException): + 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(ConfigurationException): + 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(ConfigurationException): + 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(ConfigurationException): + 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(ConfigurationException): + 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(ConfigurationException): + 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(ConfigurationException): + 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(ConfigurationException): + 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) From cce758419a384f06548dd1f145a7fc52572fa2ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 16 Jul 2021 10:37:21 +0000 Subject: [PATCH 07/11] chore(deps): bump boto3 from 1.17.110 to 1.18.0 (#527) Bumps [boto3](https://github.com/boto/boto3) from 1.17.110 to 1.18.0.
Changelog

Sourced from boto3's changelog.

1.18.0

  • api-change:ec2: [botocore] This feature enables customers to specify weekly recurring time window(s) for scheduled events that reboot, stop or terminate EC2 instances.
  • api-change:cognito-idp: [botocore] Documentation updates for cognito-idp
  • api-change:ecs: [botocore] Documentation updates for support of awsvpc mode on Windows.
  • api-change:lex-models: [botocore] Lex now supports the en-IN locale
  • api-change:iotsitewise: [botocore] Update the default endpoint for the APIs used to manage asset models, assets, gateways, tags, and account configurations. If you have firewalls with strict egress rules, configure the rules to grant you access to api.iotsitewise.[region].amazonaws.com or api.iotsitewise.[cn-region].amazonaws.com.cn.
  • feature:Python: Drop support for Python 2.7
  • feature:Python: [botocore] Dropped support for Python 2.7

1.17.112

  • api-change:dms: [botocore] Release of feature needed for ECA-Endpoint settings. This allows customer to delete a field in endpoint settings by using --exact-settings flag in modify-endpoint api. This also displays default values for certain required fields of endpoint settings in describe-endpoint-settings api.
  • api-change:glue: [botocore] Add support for Event Driven Workflows
  • api-change:acm: [botocore] Added support for RSA 3072 SSL certificate import
  • api-change:healthlake: [botocore] General availability for Amazon HealthLake. StartFHIRImportJob and StartFHIRExportJob APIs now require AWS KMS parameter. For more information, see the Amazon HealthLake Documentation https://docs.aws.amazon.com/healthlake/index.html.
  • api-change:wellarchitected: [botocore] This update provides support for Well-Architected API users to mark answer choices as not applicable.
  • api-change:lightsail: [botocore] This release adds support for the Amazon Lightsail object storage service, which allows you to create buckets and store objects.

1.17.111

  • api-change:amplifybackend: [botocore] Added Sign in with Apple OAuth provider.
  • api-change:redshift: [botocore] Release new APIs to support new Redshift feature - Authentication Profile
  • api-change:ssm: [botocore] Changes to OpsCenter APIs to support a new feature, operational insights.
  • api-change:lex-models: [botocore] Customers can now migrate bots built with Lex V1 APIs to V2 APIs. This release adds APIs to initiate and manage the migration of a bot.
  • api-change:directconnect: [botocore] This release adds a new filed named awsLogicalDeviceId that it displays the AWS Direct Connect endpoint which terminates a physical connection's BGP Sessions.
  • api-change:pricing: [botocore] Documentation updates for api.pricing
Commits
  • 5bbeeca Merge branch 'release-1.18.0'
  • c4d92f9 Bumping version to 1.18.0
  • b05c412 Add changelog entries from botocore
  • e27a3a3 Remove Python 2.7 support references (#2922)
  • 5776722 Merge branch 'release-1.17.112'
  • 76070a4 Merge branch 'release-1.17.112' into develop
  • 53f5982 Bumping version to 1.17.112
  • b1f7936 Add changelog entries from botocore
  • f77e500 Merge branch 'release-1.17.111'
  • 58664ad Merge branch 'release-1.17.111' into develop
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=boto3&package-manager=pip&previous-version=1.17.110&new-version=1.18.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--- poetry.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/poetry.lock b/poetry.lock index 9973d2cc45..c3cb672950 100644 --- a/poetry.lock +++ b/poetry.lock @@ -81,24 +81,24 @@ d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] [[package]] name = "boto3" -version = "1.17.110" +version = "1.18.0" description = "The AWS SDK for Python" category = "main" optional = false -python-versions = ">= 2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = ">= 3.6" [package.dependencies] -botocore = ">=1.20.110,<1.21.0" +botocore = ">=1.21.0,<1.22.0" jmespath = ">=0.7.1,<1.0.0" -s3transfer = ">=0.4.0,<0.5.0" +s3transfer = ">=0.5.0,<0.6.0" [[package]] name = "botocore" -version = "1.20.110" +version = "1.21.0" description = "Low-level, data-driven core of boto 3." category = "main" optional = false -python-versions = ">= 2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = ">= 3.6" [package.dependencies] jmespath = ">=0.7.1,<1.0.0" @@ -934,11 +934,11 @@ python-versions = "*" [[package]] name = "s3transfer" -version = "0.4.2" +version = "0.5.0" description = "An Amazon S3 Transfer Manager" category = "main" optional = false -python-versions = "*" +python-versions = ">= 3.6" [package.dependencies] botocore = ">=1.12.36,<2.0a.0" @@ -1111,12 +1111,12 @@ black = [ {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, ] boto3 = [ - {file = "boto3-1.17.110-py2.py3-none-any.whl", hash = "sha256:3a7b183def075f6fe17c1154ecec42fc42f9c4ac05a7e7e018f267b7d5ef5961"}, - {file = "boto3-1.17.110.tar.gz", hash = "sha256:00be3c440db39a34a049eabce79377a0b3d453b6a24e2fa52e5156fa08f929bd"}, + {file = "boto3-1.18.0-py3-none-any.whl", hash = "sha256:22802b3b4806cafff41ed591e578f048789a8dd0deeff07d055cd6f59a7c6076"}, + {file = "boto3-1.18.0.tar.gz", hash = "sha256:054e347824064b7cd77616f35596420eb4f6aca049ecc131a2aec23bcf4cf6ba"}, ] botocore = [ - {file = "botocore-1.20.110-py2.py3-none-any.whl", hash = "sha256:3500d0f0f15240a86efa6be91bf37df412d8cc10fc4b98ffea369dc13fb014da"}, - {file = "botocore-1.20.110.tar.gz", hash = "sha256:b69fd6c72d30b2ea0a42e7a2c3b9d65da3f4ccdff57bfaf6c721b0555a971bd6"}, + {file = "botocore-1.21.0-py3-none-any.whl", hash = "sha256:0d46f0addc1d7930c42115a814e5e0c3399666f6d11c1e58d9ec726dfed02ada"}, + {file = "botocore-1.21.0.tar.gz", hash = "sha256:84ea51660c758416f75ac612374156f6423a1d1ade449d38944de6eb8493cca2"}, ] certifi = [ {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, @@ -1608,8 +1608,8 @@ requests = [ {file = "ruamel.yaml.clib-0.2.2.tar.gz", hash = "sha256:2d24bd98af676f4990c4d715bcdc2a60b19c56a3fb3a763164d2d8ca0e806ba7"}, ] s3transfer = [ - {file = "s3transfer-0.4.2-py2.py3-none-any.whl", hash = "sha256:9b3752887a2880690ce628bc263d6d13a3864083aeacff4890c1c9839a5eb0bc"}, - {file = "s3transfer-0.4.2.tar.gz", hash = "sha256:cb022f4b16551edebbb31a377d3f09600dbada7363d8c5db7976e7f47732e1b2"}, + {file = "s3transfer-0.5.0-py3-none-any.whl", hash = "sha256:9c1dc369814391a6bda20ebbf4b70a0f34630592c9aa520856bf384916af2803"}, + {file = "s3transfer-0.5.0.tar.gz", hash = "sha256:50ed823e1dc5868ad40c8dc92072f757aa0e653a192845c94a3b676f4a62da4c"}, ] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, From 5b87bb195fb154d2a112364a5d1d5c9513898e55 Mon Sep 17 00:00:00 2001 From: Michael Brewer Date: Fri, 16 Jul 2021 11:34:45 -0700 Subject: [PATCH 08/11] fix(mypy): fixes to resolve no implicit optional errors (#521) --- .../event_handler/api_gateway.py | 37 ++++++++++---- .../event_handler/appsync.py | 4 +- aws_lambda_powertools/logging/formatter.py | 4 +- aws_lambda_powertools/logging/logger.py | 22 ++++---- aws_lambda_powertools/metrics/base.py | 16 +++--- aws_lambda_powertools/metrics/metric.py | 2 +- aws_lambda_powertools/metrics/metrics.py | 6 +-- .../middleware_factory/factory.py | 8 +-- aws_lambda_powertools/shared/functions.py | 4 +- aws_lambda_powertools/tracing/base.py | 8 +-- aws_lambda_powertools/tracing/tracer.py | 51 ++++++++++--------- .../utilities/idempotency/config.py | 4 +- .../utilities/idempotency/idempotency.py | 2 +- .../utilities/idempotency/persistence/base.py | 4 +- .../idempotency/persistence/dynamodb.py | 2 +- .../utilities/validation/base.py | 4 +- .../utilities/validation/validator.py | 12 ++--- mypy.ini | 15 ++++++ 18 files changed, 121 insertions(+), 84 deletions(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index b6e9cd4698..8b6c368af3 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -126,7 +126,11 @@ class Response: """Response data class that provides greater control over what is returned from the proxy event""" def __init__( - self, status_code: int, content_type: Optional[str], body: Union[str, bytes, None], headers: Dict = None + self, + status_code: int, + content_type: Optional[str], + body: Union[str, bytes, None], + headers: Optional[Dict] = None, ): """ @@ -167,7 +171,7 @@ def __init__( class ResponseBuilder: """Internally used Response builder""" - def __init__(self, response: Response, route: Route = None): + def __init__(self, response: Response, route: Optional[Route] = None): self.response = response self.route = route @@ -199,7 +203,7 @@ def _route(self, event: BaseProxyEvent, cors: Optional[CORSConfig]): if self.route.compress and "gzip" in (event.get_header_value("accept-encoding", "") or ""): self._compress() - def build(self, event: BaseProxyEvent, cors: CORSConfig = None) -> Dict[str, Any]: + def build(self, event: BaseProxyEvent, cors: Optional[CORSConfig] = None) -> Dict[str, Any]: """Build the full response dict to be returned by the lambda""" self._route(event, cors) @@ -250,7 +254,7 @@ def lambda_handler(event, context): def __init__( self, proxy_type: Enum = ProxyEventType.APIGatewayProxyEvent, - cors: CORSConfig = None, + cors: Optional[CORSConfig] = None, debug: Optional[bool] = None, ): """ @@ -270,10 +274,10 @@ def __init__( self._cors_enabled: bool = cors is not None self._cors_methods: Set[str] = {"OPTIONS"} self._debug = resolve_truthy_env_var_choice( - choice=debug, env=os.getenv(constants.EVENT_HANDLER_DEBUG_ENV, "false") + env=os.getenv(constants.EVENT_HANDLER_DEBUG_ENV, "false"), choice=debug ) - def get(self, rule: str, cors: bool = None, compress: bool = False, cache_control: str = None): + def get(self, rule: str, cors: Optional[bool] = None, compress: bool = False, cache_control: Optional[str] = None): """Get route decorator with GET `method` Examples @@ -298,7 +302,7 @@ def lambda_handler(event, context): """ return self.route(rule, "GET", cors, compress, cache_control) - def post(self, rule: str, cors: bool = None, compress: bool = False, cache_control: str = None): + def post(self, rule: str, cors: Optional[bool] = None, compress: bool = False, cache_control: Optional[str] = None): """Post route decorator with POST `method` Examples @@ -324,7 +328,7 @@ def lambda_handler(event, context): """ return self.route(rule, "POST", cors, compress, cache_control) - def put(self, rule: str, cors: bool = None, compress: bool = False, cache_control: str = None): + def put(self, rule: str, cors: Optional[bool] = None, compress: bool = False, cache_control: Optional[str] = None): """Put route decorator with PUT `method` Examples @@ -350,7 +354,9 @@ def lambda_handler(event, context): """ return self.route(rule, "PUT", cors, compress, cache_control) - def delete(self, rule: str, cors: bool = None, compress: bool = False, cache_control: str = None): + def delete( + self, rule: str, cors: Optional[bool] = None, compress: bool = False, cache_control: Optional[str] = None + ): """Delete route decorator with DELETE `method` Examples @@ -375,7 +381,9 @@ def lambda_handler(event, context): """ return self.route(rule, "DELETE", cors, compress, cache_control) - def patch(self, rule: str, cors: bool = None, compress: bool = False, cache_control: str = None): + def patch( + self, rule: str, cors: Optional[bool] = None, compress: bool = False, cache_control: Optional[str] = None + ): """Patch route decorator with PATCH `method` Examples @@ -403,7 +411,14 @@ def lambda_handler(event, context): """ return self.route(rule, "PATCH", cors, compress, cache_control) - def route(self, rule: str, method: str, cors: bool = None, compress: bool = False, cache_control: str = None): + def route( + self, + rule: str, + method: str, + cors: Optional[bool] = None, + compress: bool = False, + cache_control: Optional[str] = None, + ): """Route decorator includes parameter `method`""" def register_resolver(func: Callable): diff --git a/aws_lambda_powertools/event_handler/appsync.py b/aws_lambda_powertools/event_handler/appsync.py index 021afaa665..7f4cce5c8b 100644 --- a/aws_lambda_powertools/event_handler/appsync.py +++ b/aws_lambda_powertools/event_handler/appsync.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Callable +from typing import Any, Callable, Optional from aws_lambda_powertools.utilities.data_classes import AppSyncResolverEvent from aws_lambda_powertools.utilities.typing import LambdaContext @@ -44,7 +44,7 @@ def common_field() -> str: def __init__(self): self._resolvers: dict = {} - def resolver(self, type_name: str = "*", field_name: str = None): + def resolver(self, type_name: str = "*", field_name: Optional[str] = None): """Registers the resolver for field_name Parameters diff --git a/aws_lambda_powertools/logging/formatter.py b/aws_lambda_powertools/logging/formatter.py index 7ff9881062..de9254a337 100644 --- a/aws_lambda_powertools/logging/formatter.py +++ b/aws_lambda_powertools/logging/formatter.py @@ -60,8 +60,8 @@ def __init__( json_serializer: Optional[Callable[[Dict], str]] = None, json_deserializer: Optional[Callable[[Dict], str]] = None, json_default: Optional[Callable[[Any], Any]] = None, - datefmt: str = None, - log_record_order: List[str] = None, + datefmt: Optional[str] = None, + log_record_order: Optional[List[str]] = None, utc: bool = False, **kwargs ): diff --git a/aws_lambda_powertools/logging/logger.py b/aws_lambda_powertools/logging/logger.py index 72e22b38c7..8ac911d4ca 100644 --- a/aws_lambda_powertools/logging/logger.py +++ b/aws_lambda_powertools/logging/logger.py @@ -167,11 +167,11 @@ class Logger(logging.Logger): # lgtm [py/missing-call-to-init] def __init__( self, - service: str = None, - level: Union[str, int] = None, + service: Optional[str] = None, + level: Union[str, int, None] = None, child: bool = False, - sampling_rate: float = None, - stream: IO[str] = None, + sampling_rate: Optional[float] = None, + stream: Optional[IO[str]] = None, logger_formatter: Optional[PowertoolsFormatter] = None, logger_handler: Optional[logging.Handler] = None, **kwargs, @@ -261,10 +261,10 @@ def _configure_sampling(self): def inject_lambda_context( self, - lambda_handler: Callable[[Dict, Any], Any] = None, - log_event: bool = None, - correlation_id_path: str = None, - clear_state: bool = False, + lambda_handler: Optional[Callable[[Dict, Any], Any]] = None, + log_event: Optional[bool] = None, + correlation_id_path: Optional[str] = None, + clear_state: Optional[bool] = False, ): """Decorator to capture Lambda contextual info and inject into logger @@ -324,7 +324,7 @@ def handler(event, context): ) log_event = resolve_truthy_env_var_choice( - choice=log_event, env=os.getenv(constants.LOGGER_LOG_EVENT_ENV, "false") + env=os.getenv(constants.LOGGER_LOG_EVENT_ENV, "false"), choice=log_event ) @functools.wraps(lambda_handler) @@ -421,7 +421,9 @@ def _get_caller_filename(): def set_package_logger( - level: Union[str, int] = logging.DEBUG, stream: IO[str] = None, formatter: logging.Formatter = None + level: Union[str, int] = logging.DEBUG, + stream: Optional[IO[str]] = None, + formatter: Optional[logging.Formatter] = None, ): """Set an additional stream handler, formatter, and log level for aws_lambda_powertools package logger. diff --git a/aws_lambda_powertools/metrics/base.py b/aws_lambda_powertools/metrics/base.py index dc4fe34ee1..853f06f210 100644 --- a/aws_lambda_powertools/metrics/base.py +++ b/aws_lambda_powertools/metrics/base.py @@ -5,7 +5,7 @@ import os from collections import defaultdict from enum import Enum -from typing import Any, Dict, List, Union +from typing import Any, Dict, List, Optional, Union from ..shared import constants from ..shared.functions import resolve_env_var_choice @@ -76,11 +76,11 @@ class MetricManager: def __init__( self, - metric_set: Dict[str, Any] = None, - dimension_set: Dict = None, - namespace: str = None, - metadata_set: Dict[str, Any] = None, - service: str = None, + metric_set: Optional[Dict[str, Any]] = None, + dimension_set: Optional[Dict] = None, + namespace: Optional[str] = None, + metadata_set: Optional[Dict[str, Any]] = None, + service: Optional[str] = None, ): self.metric_set = metric_set if metric_set is not None else {} self.dimension_set = dimension_set if dimension_set is not None else {} @@ -136,7 +136,9 @@ def add_metric(self, name: str, unit: Union[MetricUnit, str], value: float): # since we could have more than 100 metrics self.metric_set.clear() - def serialize_metric_set(self, metrics: Dict = None, dimensions: Dict = None, metadata: Dict = None) -> Dict: + def serialize_metric_set( + self, metrics: Optional[Dict] = None, dimensions: Optional[Dict] = None, metadata: Optional[Dict] = None + ) -> Dict: """Serializes metric and dimensions set Parameters diff --git a/aws_lambda_powertools/metrics/metric.py b/aws_lambda_powertools/metrics/metric.py index 8bdd0d800b..1ac2bd9450 100644 --- a/aws_lambda_powertools/metrics/metric.py +++ b/aws_lambda_powertools/metrics/metric.py @@ -61,7 +61,7 @@ def add_metric(self, name: str, unit: Union[MetricUnit, str], value: float): @contextmanager -def single_metric(name: str, unit: MetricUnit, value: float, namespace: str = None): +def single_metric(name: str, unit: MetricUnit, value: float, namespace: Optional[str] = None): """Context manager to simplify creation of a single metric Example diff --git a/aws_lambda_powertools/metrics/metrics.py b/aws_lambda_powertools/metrics/metrics.py index 8cc4895f03..fafc604b50 100644 --- a/aws_lambda_powertools/metrics/metrics.py +++ b/aws_lambda_powertools/metrics/metrics.py @@ -71,7 +71,7 @@ def lambda_handler(): _metadata: Dict[str, Any] = {} _default_dimensions: Dict[str, Any] = {} - def __init__(self, service: str = None, namespace: str = None): + def __init__(self, service: Optional[str] = None, namespace: Optional[str] = None): self.metric_set = self._metrics self.service = service self.namespace: Optional[str] = namespace @@ -125,10 +125,10 @@ def clear_metrics(self): def log_metrics( self, - lambda_handler: Callable[[Any, Any], Any] = None, + lambda_handler: Optional[Callable[[Any, Any], Any]] = None, capture_cold_start_metric: bool = False, raise_on_empty_metrics: bool = False, - default_dimensions: Dict[str, str] = None, + default_dimensions: Optional[Dict[str, str]] = None, ): """Decorator to serialize and publish metrics at the end of a function execution. diff --git a/aws_lambda_powertools/middleware_factory/factory.py b/aws_lambda_powertools/middleware_factory/factory.py index 7727705227..74858bf670 100644 --- a/aws_lambda_powertools/middleware_factory/factory.py +++ b/aws_lambda_powertools/middleware_factory/factory.py @@ -2,7 +2,7 @@ import inspect import logging import os -from typing import Callable +from typing import Callable, Optional from ..shared import constants from ..shared.functions import resolve_truthy_env_var_choice @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) -def lambda_handler_decorator(decorator: Callable = None, trace_execution: bool = None): +def lambda_handler_decorator(decorator: Optional[Callable] = None, trace_execution: Optional[bool] = None): """Decorator factory for decorating Lambda handlers. You can use lambda_handler_decorator to create your own middlewares, @@ -106,11 +106,11 @@ def lambda_handler(event, context): return functools.partial(lambda_handler_decorator, trace_execution=trace_execution) trace_execution = resolve_truthy_env_var_choice( - choice=trace_execution, env=os.getenv(constants.MIDDLEWARE_FACTORY_TRACE_ENV, "false") + env=os.getenv(constants.MIDDLEWARE_FACTORY_TRACE_ENV, "false"), choice=trace_execution ) @functools.wraps(decorator) - def final_decorator(func: Callable = None, **kwargs): + def final_decorator(func: Optional[Callable] = None, **kwargs): # If called with kwargs return new func with kwargs if func is None: return functools.partial(final_decorator, **kwargs) diff --git a/aws_lambda_powertools/shared/functions.py b/aws_lambda_powertools/shared/functions.py index b8f5cb9f74..0b117cc32b 100644 --- a/aws_lambda_powertools/shared/functions.py +++ b/aws_lambda_powertools/shared/functions.py @@ -2,14 +2,14 @@ from typing import Any, Optional, Union -def resolve_truthy_env_var_choice(env: Any, choice: bool = None) -> bool: +def resolve_truthy_env_var_choice(env: str, choice: Optional[bool] = None) -> bool: """Pick explicit choice over truthy env value, if available, otherwise return truthy env value NOTE: Environment variable should be resolved by the caller. Parameters ---------- - env : Any + env : str environment variable actual value choice : bool explicit choice diff --git a/aws_lambda_powertools/tracing/base.py b/aws_lambda_powertools/tracing/base.py index 1857ed52a7..722652ce08 100644 --- a/aws_lambda_powertools/tracing/base.py +++ b/aws_lambda_powertools/tracing/base.py @@ -2,11 +2,11 @@ import numbers import traceback from contextlib import contextmanager -from typing import Any, AsyncContextManager, ContextManager, List, NoReturn, Set, Union +from typing import Any, AsyncContextManager, ContextManager, List, NoReturn, Optional, Set, Union class BaseProvider(abc.ABC): - @abc.abstractmethod + @abc.abstractmethod # type: ignore @contextmanager def in_subsegment(self, name=None, **kwargs) -> ContextManager: """Return a subsegment context manger. @@ -19,7 +19,7 @@ def in_subsegment(self, name=None, **kwargs) -> ContextManager: Optional parameters to be propagated to segment """ - @abc.abstractmethod + @abc.abstractmethod # type: ignore @contextmanager def in_subsegment_async(self, name=None, **kwargs) -> AsyncContextManager: """Return a subsegment async context manger. @@ -81,7 +81,7 @@ class BaseSegment(abc.ABC): """Holds common properties and methods on segment and subsegment.""" @abc.abstractmethod - def close(self, end_time: int = None): + def close(self, end_time: Optional[int] = None): """Close the trace entity by setting `end_time` and flip the in progress flag to False. diff --git a/aws_lambda_powertools/tracing/tracer.py b/aws_lambda_powertools/tracing/tracer.py index 4756880220..48b7866cf0 100644 --- a/aws_lambda_powertools/tracing/tracer.py +++ b/aws_lambda_powertools/tracing/tracer.py @@ -5,7 +5,7 @@ import logging import numbers import os -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing import Any, Callable, Dict, Optional, Sequence, Union from ..shared import constants from ..shared.functions import resolve_env_var_choice, resolve_truthy_env_var_choice @@ -53,7 +53,7 @@ class Tracer: disabled: bool Flag to explicitly disable tracing, useful when running/testing locally `Env POWERTOOLS_TRACE_DISABLED="true"` - patch_modules: Tuple[str] + patch_modules: Optional[Sequence[str]] Tuple of modules supported by tracing provider to patch, by default all modules are patched provider: BaseProvider Tracing provider, by default it is aws_xray_sdk.core.xray_recorder @@ -146,11 +146,11 @@ def handler(event: dict, context: Any) -> Dict: def __init__( self, - service: str = None, - disabled: bool = None, - auto_patch: bool = None, - patch_modules: Optional[Tuple[str]] = None, - provider: BaseProvider = None, + service: Optional[str] = None, + disabled: Optional[bool] = None, + auto_patch: Optional[bool] = None, + patch_modules: Optional[Sequence[str]] = None, + provider: Optional[BaseProvider] = None, ): self.__build_config( service=service, disabled=disabled, auto_patch=auto_patch, patch_modules=patch_modules, provider=provider @@ -195,7 +195,7 @@ def put_annotation(self, key: str, value: Union[str, numbers.Number, bool]): logger.debug(f"Annotating on key '{key}' with '{value}'") self.provider.put_annotation(key=key, value=value) - def put_metadata(self, key: str, value: Any, namespace: str = None): + def put_metadata(self, key: str, value: Any, namespace: Optional[str] = None): """Adds metadata to existing segment or subsegment Parameters @@ -223,14 +223,14 @@ def put_metadata(self, key: str, value: Any, namespace: str = None): logger.debug(f"Adding metadata on key '{key}' with '{value}' at namespace '{namespace}'") self.provider.put_metadata(key=key, value=value, namespace=namespace) - def patch(self, modules: Tuple[str] = None): + def patch(self, modules: Optional[Sequence[str]] = None): """Patch modules for instrumentation. Patches all supported modules by default if none are given. Parameters ---------- - modules : Tuple[str] + modules : Optional[Sequence[str]] List of modules to be patched, optional by default """ if self.disabled: @@ -244,7 +244,7 @@ def patch(self, modules: Tuple[str] = None): def capture_lambda_handler( self, - lambda_handler: Union[Callable[[Dict, Any], Any], Callable[[Dict, Any, Optional[Dict]], Any]] = None, + lambda_handler: Union[Callable[[Dict, Any], Any], Optional[Callable[[Dict, Any, Optional[Dict]], Any]]] = None, capture_response: Optional[bool] = None, capture_error: Optional[bool] = None, ): @@ -330,7 +330,10 @@ def decorate(event, context, **kwargs): return decorate def capture_method( - self, method: Callable = None, capture_response: Optional[bool] = None, capture_error: Optional[bool] = None + self, + method: Optional[Callable] = None, + capture_response: Optional[bool] = None, + capture_error: Optional[bool] = None, ): """Decorator to create subsegment for arbitrary functions @@ -520,7 +523,7 @@ def _decorate_async_function( method: Callable, capture_response: Optional[Union[bool, str]] = None, capture_error: Optional[Union[bool, str]] = None, - method_name: str = None, + method_name: Optional[str] = None, ): @functools.wraps(method) async def decorate(*args, **kwargs): @@ -547,7 +550,7 @@ def _decorate_generator_function( method: Callable, capture_response: Optional[Union[bool, str]] = None, capture_error: Optional[Union[bool, str]] = None, - method_name: str = None, + method_name: Optional[str] = None, ): @functools.wraps(method) def decorate(*args, **kwargs): @@ -574,7 +577,7 @@ def _decorate_generator_function_with_context_manager( method: Callable, capture_response: Optional[Union[bool, str]] = None, capture_error: Optional[Union[bool, str]] = None, - method_name: str = None, + method_name: Optional[str] = None, ): @functools.wraps(method) @contextlib.contextmanager @@ -602,7 +605,7 @@ def _decorate_sync_function( method: Callable, capture_response: Optional[Union[bool, str]] = None, capture_error: Optional[Union[bool, str]] = None, - method_name: str = None, + method_name: Optional[str] = None, ): @functools.wraps(method) def decorate(*args, **kwargs): @@ -629,9 +632,9 @@ def decorate(*args, **kwargs): def _add_response_as_metadata( self, - method_name: str = None, - data: Any = None, - subsegment: BaseSegment = None, + method_name: Optional[str] = None, + data: Optional[Any] = None, + subsegment: Optional[BaseSegment] = None, capture_response: Optional[Union[bool, str]] = None, ): """Add response as metadata for given subsegment @@ -714,11 +717,11 @@ def _is_tracer_disabled() -> Union[bool, str]: def __build_config( self, - service: str = None, - disabled: bool = None, - auto_patch: bool = None, - patch_modules: Union[List, Tuple] = None, - provider: BaseProvider = None, + service: Optional[str] = None, + disabled: Optional[bool] = None, + auto_patch: Optional[bool] = None, + patch_modules: Optional[Sequence[str]] = None, + provider: Optional[BaseProvider] = None, ): """Populates Tracer config for new and existing initializations""" is_disabled = disabled if disabled is not None else self._is_tracer_disabled() diff --git a/aws_lambda_powertools/utilities/idempotency/config.py b/aws_lambda_powertools/utilities/idempotency/config.py index 52afb3bad8..06468cc74a 100644 --- a/aws_lambda_powertools/utilities/idempotency/config.py +++ b/aws_lambda_powertools/utilities/idempotency/config.py @@ -1,4 +1,4 @@ -from typing import Dict +from typing import Dict, Optional class IdempotencyConfig: @@ -6,7 +6,7 @@ def __init__( self, event_key_jmespath: str = "", payload_validation_jmespath: str = "", - jmespath_options: Dict = None, + jmespath_options: Optional[Dict] = None, raise_on_no_idempotency_key: bool = False, expires_after_seconds: int = 60 * 60, # 1 hour default use_local_cache: bool = False, diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py index 6f73a842af..c2bcc62fd6 100644 --- a/aws_lambda_powertools/utilities/idempotency/idempotency.py +++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py @@ -31,7 +31,7 @@ def idempotent( event: Dict[str, Any], context: LambdaContext, persistence_store: BasePersistenceLayer, - config: IdempotencyConfig = None, + config: Optional[IdempotencyConfig] = None, ) -> Any: """ Middleware to handle idempotency diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index 31aef6dc0f..eb43a8b30c 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -39,9 +39,9 @@ def __init__( self, idempotency_key, status: str = "", - expiry_timestamp: int = None, + expiry_timestamp: Optional[int] = None, response_data: Optional[str] = "", - payload_hash: str = None, + payload_hash: Optional[str] = None, ) -> None: """ diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py b/aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py index dc00334277..ae3a1be490 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/validation/base.py b/aws_lambda_powertools/utilities/validation/base.py index ec63e39eb3..b818f11a40 100644 --- a/aws_lambda_powertools/utilities/validation/base.py +++ b/aws_lambda_powertools/utilities/validation/base.py @@ -1,9 +1,9 @@ import logging from typing import Any, Dict, Optional, Union -import fastjsonschema +import fastjsonschema # type: ignore import jmespath -from jmespath.exceptions import LexerError +from jmespath.exceptions import LexerError # type: ignore from aws_lambda_powertools.shared.jmespath_functions import PowertoolsFunctions diff --git a/aws_lambda_powertools/utilities/validation/validator.py b/aws_lambda_powertools/utilities/validation/validator.py index 3628d486eb..0497a49a71 100644 --- a/aws_lambda_powertools/utilities/validation/validator.py +++ b/aws_lambda_powertools/utilities/validation/validator.py @@ -12,12 +12,12 @@ def validator( handler: Callable, event: Union[Dict, str], context: Any, - inbound_schema: Dict = None, + inbound_schema: Optional[Dict] = None, inbound_formats: Optional[Dict] = None, - outbound_schema: Dict = None, + outbound_schema: Optional[Dict] = None, outbound_formats: Optional[Dict] = None, - envelope: str = None, - jmespath_options: Dict = None, + envelope: Optional[str] = None, + jmespath_options: Optional[Dict] = None, ) -> Any: """Lambda handler decorator to validate incoming/outbound data using a JSON Schema @@ -135,8 +135,8 @@ def validate( event: Any, schema: Dict, formats: Optional[Dict] = None, - envelope: str = None, - jmespath_options: Dict = None, + envelope: Optional[str] = None, + jmespath_options: Optional[Dict] = None, ): """Standalone function to validate event data using a JSON Schema diff --git a/mypy.ini b/mypy.ini index 42185a692a..66bed405f5 100644 --- a/mypy.ini +++ b/mypy.ini @@ -11,3 +11,18 @@ show_error_context = True [mypy-jmespath] ignore_missing_imports=True + +[mypy-boto3] +ignore_missing_imports = True + +[mypy-boto3.dynamodb.conditions] +ignore_missing_imports = True + +[mypy-botocore.config] +ignore_missing_imports = True + +[mypy-botocore.exceptions] +ignore_missing_imports = True + +[mypy-aws_xray_sdk.ext.aiohttp.client] +ignore_missing_imports = True From 89337a29a2dfe81365142fe1ba8a15a138166463 Mon Sep 17 00:00:00 2001 From: walmsles <2704782+walmsles@users.noreply.github.com> Date: Sat, 17 Jul 2021 21:11:59 +1000 Subject: [PATCH 09/11] fix(parser): Make ApiGateway version, authorizer fields optional (#532) Co-authored-by: Walmsley, Michael --- .../utilities/parser/models/apigw.py | 4 +- .../apiGatewayProxyEvent_noVersionAuth.json | 75 +++++++++++++++++++ tests/functional/test_data_classes.py | 64 ++++++++++++++++ 3 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 tests/events/apiGatewayProxyEvent_noVersionAuth.json diff --git a/aws_lambda_powertools/utilities/parser/models/apigw.py b/aws_lambda_powertools/utilities/parser/models/apigw.py index de968e20ec..4de8ee96cc 100644 --- a/aws_lambda_powertools/utilities/parser/models/apigw.py +++ b/aws_lambda_powertools/utilities/parser/models/apigw.py @@ -46,7 +46,7 @@ class APIGatewayEventAuthorizer(BaseModel): class APIGatewayEventRequestContext(BaseModel): accountId: str apiId: str - authorizer: APIGatewayEventAuthorizer + authorizer: Optional[APIGatewayEventAuthorizer] stage: str protocol: str identity: APIGatewayEventIdentity @@ -70,7 +70,7 @@ class APIGatewayEventRequestContext(BaseModel): class APIGatewayProxyEventModel(BaseModel): - version: str + version: Optional[str] resource: str path: str httpMethod: Literal["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] diff --git a/tests/events/apiGatewayProxyEvent_noVersionAuth.json b/tests/events/apiGatewayProxyEvent_noVersionAuth.json new file mode 100644 index 0000000000..055301f8f1 --- /dev/null +++ b/tests/events/apiGatewayProxyEvent_noVersionAuth.json @@ -0,0 +1,75 @@ +{ + "resource": "/my/path", + "path": "/my/path", + "httpMethod": "GET", + "headers": { + "Header1": "value1", + "Header2": "value2" + }, + "multiValueHeaders": { + "Header1": [ + "value1" + ], + "Header2": [ + "value1", + "value2" + ] + }, + "queryStringParameters": { + "parameter1": "value1", + "parameter2": "value" + }, + "multiValueQueryStringParameters": { + "parameter1": [ + "value1", + "value2" + ], + "parameter2": [ + "value" + ] + }, + "requestContext": { + "accountId": "123456789012", + "apiId": "id", + "domainName": "id.execute-api.us-east-1.amazonaws.com", + "domainPrefix": "id", + "extendedRequestId": "request-id", + "httpMethod": "GET", + "identity": { + "accessKey": null, + "accountId": null, + "caller": null, + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "principalOrgId": null, + "sourceIp": "192.168.0.1/32", + "user": null, + "userAgent": "user-agent", + "userArn": null, + "clientCert": { + "clientCertPem": "CERT_CONTENT", + "subjectDN": "www.example.com", + "issuerDN": "Example issuer", + "serialNumber": "a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1", + "validity": { + "notBefore": "May 28 12:30:02 2019 GMT", + "notAfter": "Aug 5 09:36:04 2021 GMT" + } + } + }, + "path": "/my/path", + "protocol": "HTTP/1.1", + "requestId": "id=", + "requestTime": "04/Mar/2020:19:15:17 +0000", + "requestTimeEpoch": 1583349317135, + "resourceId": null, + "resourcePath": "/my/path", + "stage": "$default" + }, + "pathParameters": null, + "stageVariables": null, + "body": "Hello from Lambda!", + "isBase64Encoded": true +} diff --git a/tests/functional/test_data_classes.py b/tests/functional/test_data_classes.py index e65ae94dd2..cbbaf83437 100644 --- a/tests/functional/test_data_classes.py +++ b/tests/functional/test_data_classes.py @@ -743,6 +743,70 @@ def test_seq_trigger_event(): assert record.aws_region == "us-east-2" +def test_default_api_gateway_proxy_event(): + event = APIGatewayProxyEvent(load_event("apiGatewayProxyEvent_noVersionAuth.json")) + + assert event.get("version") is None + assert event.resource == event["resource"] + assert event.path == event["path"] + assert event.http_method == event["httpMethod"] + assert event.headers == event["headers"] + assert event.multi_value_headers == event["multiValueHeaders"] + assert event.query_string_parameters == event["queryStringParameters"] + assert event.multi_value_query_string_parameters == event["multiValueQueryStringParameters"] + + request_context = event.request_context + assert request_context.account_id == event["requestContext"]["accountId"] + assert request_context.api_id == event["requestContext"]["apiId"] + + assert request_context.get("authorizer") is None + + assert request_context.domain_name == event["requestContext"]["domainName"] + assert request_context.domain_prefix == event["requestContext"]["domainPrefix"] + assert request_context.extended_request_id == event["requestContext"]["extendedRequestId"] + assert request_context.http_method == event["requestContext"]["httpMethod"] + + identity = request_context.identity + assert identity.access_key == event["requestContext"]["identity"]["accessKey"] + assert identity.account_id == event["requestContext"]["identity"]["accountId"] + assert identity.caller == event["requestContext"]["identity"]["caller"] + assert ( + identity.cognito_authentication_provider == event["requestContext"]["identity"]["cognitoAuthenticationProvider"] + ) + assert identity.cognito_authentication_type == event["requestContext"]["identity"]["cognitoAuthenticationType"] + assert identity.cognito_identity_id == event["requestContext"]["identity"]["cognitoIdentityId"] + assert identity.cognito_identity_pool_id == event["requestContext"]["identity"]["cognitoIdentityPoolId"] + assert identity.principal_org_id == event["requestContext"]["identity"]["principalOrgId"] + assert identity.source_ip == event["requestContext"]["identity"]["sourceIp"] + assert identity.user == event["requestContext"]["identity"]["user"] + assert identity.user_agent == event["requestContext"]["identity"]["userAgent"] + assert identity.user_arn == event["requestContext"]["identity"]["userArn"] + + assert request_context.path == event["requestContext"]["path"] + assert request_context.protocol == event["requestContext"]["protocol"] + assert request_context.request_id == event["requestContext"]["requestId"] + assert request_context.request_time == event["requestContext"]["requestTime"] + assert request_context.request_time_epoch == event["requestContext"]["requestTimeEpoch"] + assert request_context.resource_id == event["requestContext"]["resourceId"] + assert request_context.resource_path == event["requestContext"]["resourcePath"] + assert request_context.stage == event["requestContext"]["stage"] + + assert event.path_parameters == event["pathParameters"] + assert event.stage_variables == event["stageVariables"] + assert event.body == event["body"] + assert event.is_base64_encoded == event["isBase64Encoded"] + + assert request_context.connected_at is None + assert request_context.connection_id is None + assert request_context.event_type is None + assert request_context.message_direction is None + assert request_context.message_id is None + assert request_context.route_key is None + assert request_context.operation_name is None + assert identity.api_key is None + assert identity.api_key_id is None + + def test_api_gateway_proxy_event(): event = APIGatewayProxyEvent(load_event("apiGatewayProxyEvent.json")) From d5c34311ce9ff93ffc01f6eb0a61d54f0f739082 Mon Sep 17 00:00:00 2001 From: Heitor Lessa Date: Sat, 17 Jul 2021 14:46:14 +0200 Subject: [PATCH 10/11] fix(tracer): mypy generic to preserve decorated method signature (#529) --- aws_lambda_powertools/tracing/tracer.py | 36 ++++++++++++++++++------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/aws_lambda_powertools/tracing/tracer.py b/aws_lambda_powertools/tracing/tracer.py index 48b7866cf0..5709b1956c 100644 --- a/aws_lambda_powertools/tracing/tracer.py +++ b/aws_lambda_powertools/tracing/tracer.py @@ -5,7 +5,7 @@ import logging import numbers import os -from typing import Any, Callable, Dict, Optional, Sequence, Union +from typing import Any, Awaitable, Callable, Dict, Optional, Sequence, TypeVar, Union, cast, overload from ..shared import constants from ..shared.functions import resolve_env_var_choice, resolve_truthy_env_var_choice @@ -18,6 +18,9 @@ aws_xray_sdk = LazyLoader(constants.XRAY_SDK_MODULE, globals(), constants.XRAY_SDK_MODULE) aws_xray_sdk.core = LazyLoader(constants.XRAY_SDK_CORE_MODULE, globals(), constants.XRAY_SDK_CORE_MODULE) +AnyCallableT = TypeVar("AnyCallableT", bound=Callable[..., Any]) # noqa: VNE001 +AnyAwaitableT = TypeVar("AnyAwaitableT", bound=Awaitable) + class Tracer: """Tracer using AWS-XRay to provide decorators with known defaults for Lambda functions @@ -329,12 +332,26 @@ def decorate(event, context, **kwargs): return decorate + # see #465 + @overload + def capture_method(self, method: "AnyCallableT") -> "AnyCallableT": + ... + + @overload def capture_method( self, - method: Optional[Callable] = None, + method: None = None, capture_response: Optional[bool] = None, capture_error: Optional[bool] = None, - ): + ) -> Callable[["AnyCallableT"], "AnyCallableT"]: + ... + + def capture_method( + self, + method: Optional[AnyCallableT] = None, + capture_response: Optional[bool] = None, + capture_error: Optional[bool] = None, + ) -> AnyCallableT: """Decorator to create subsegment for arbitrary functions It also captures both response and exceptions as metadata @@ -487,8 +504,9 @@ async def async_tasks(): # Return a partial function with args filled if method is None: logger.debug("Decorator called with parameters") - return functools.partial( - self.capture_method, capture_response=capture_response, capture_error=capture_error + return cast( + AnyCallableT, + functools.partial(self.capture_method, capture_response=capture_response, capture_error=capture_error), ) method_name = f"{method.__name__}" @@ -509,7 +527,7 @@ async def async_tasks(): return self._decorate_generator_function( method=method, capture_response=capture_response, capture_error=capture_error, method_name=method_name ) - elif hasattr(method, "__wrapped__") and inspect.isgeneratorfunction(method.__wrapped__): + elif hasattr(method, "__wrapped__") and inspect.isgeneratorfunction(method.__wrapped__): # type: ignore return self._decorate_generator_function_with_context_manager( method=method, capture_response=capture_response, capture_error=capture_error, method_name=method_name ) @@ -602,11 +620,11 @@ def decorate(*args, **kwargs): def _decorate_sync_function( self, - method: Callable, + method: AnyCallableT, capture_response: Optional[Union[bool, str]] = None, capture_error: Optional[Union[bool, str]] = None, method_name: Optional[str] = None, - ): + ) -> AnyCallableT: @functools.wraps(method) def decorate(*args, **kwargs): with self.provider.in_subsegment(name=f"## {method_name}") as subsegment: @@ -628,7 +646,7 @@ def decorate(*args, **kwargs): return response - return decorate + return cast(AnyCallableT, decorate) def _add_response_as_metadata( self, From 018a91b1eaf1b56e8a398d538174b20711a77ba1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 17 Jul 2021 17:21:59 +0000 Subject: [PATCH 11/11] chore(deps): bump boto3 from 1.18.0 to 1.18.1 (#528) Bumps [boto3](https://github.com/boto/boto3) from 1.18.0 to 1.18.1.
Changelog

Sourced from boto3's changelog.

1.18.1

  • api-change:appintegrations: [botocore] Documentation update for AppIntegrations Service
  • api-change:chime: [botocore] This SDK release adds Account Status as one of the attributes in Account API response
  • api-change:auditmanager: [botocore] This release relaxes the S3 URL character restrictions in AWS Audit Manager. Regex patterns have been updated for the following attributes: s3RelativePath, destination, and s3ResourcePath. 'AWS' terms have also been replaced with entities to align with China Rebrand documentation efforts.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=boto3&package-manager=pip&previous-version=1.18.0&new-version=1.18.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--- poetry.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index c3cb672950..b289b51e5e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -81,20 +81,20 @@ d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] [[package]] name = "boto3" -version = "1.18.0" +version = "1.18.1" description = "The AWS SDK for Python" category = "main" optional = false python-versions = ">= 3.6" [package.dependencies] -botocore = ">=1.21.0,<1.22.0" +botocore = ">=1.21.1,<1.22.0" jmespath = ">=0.7.1,<1.0.0" s3transfer = ">=0.5.0,<0.6.0" [[package]] name = "botocore" -version = "1.21.0" +version = "1.21.1" description = "Low-level, data-driven core of boto 3." category = "main" optional = false @@ -1111,12 +1111,12 @@ black = [ {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, ] boto3 = [ - {file = "boto3-1.18.0-py3-none-any.whl", hash = "sha256:22802b3b4806cafff41ed591e578f048789a8dd0deeff07d055cd6f59a7c6076"}, - {file = "boto3-1.18.0.tar.gz", hash = "sha256:054e347824064b7cd77616f35596420eb4f6aca049ecc131a2aec23bcf4cf6ba"}, + {file = "boto3-1.18.1-py3-none-any.whl", hash = "sha256:a6399df957bfc7944fbd97e9fb0755cba29b1cb135b91d7e43fd298b268ab804"}, + {file = "boto3-1.18.1.tar.gz", hash = "sha256:ddfe4a78f04cd2d3a7a37d5cdfa07b4889b24296508786969bc968bee6b8b003"}, ] botocore = [ - {file = "botocore-1.21.0-py3-none-any.whl", hash = "sha256:0d46f0addc1d7930c42115a814e5e0c3399666f6d11c1e58d9ec726dfed02ada"}, - {file = "botocore-1.21.0.tar.gz", hash = "sha256:84ea51660c758416f75ac612374156f6423a1d1ade449d38944de6eb8493cca2"}, + {file = "botocore-1.21.1-py3-none-any.whl", hash = "sha256:b845220eb580d10f7714798a96e380eb8f94dca89905a41d8a3c35119c757b01"}, + {file = "botocore-1.21.1.tar.gz", hash = "sha256:200887ce5f3b47d7499b7ded75dc65c4649abdaaddd06cebc118a3a954d6fd73"}, ] certifi = [ {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"},