diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 5bd3bc0b70e..30c13ada6b5 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -579,7 +579,7 @@ def _remove_prefix(self, path: str) -> str: @staticmethod def _path_starts_with(path: str, prefix: str): """Returns true if the `path` starts with a prefix plus a `/`""" - if not isinstance(prefix, str) or len(prefix) == 0: + if not isinstance(prefix, str) or prefix == "": return False return path.startswith(prefix + "/") @@ -633,7 +633,9 @@ def _call_route(self, route: Route, args: Dict[str, str]) -> ResponseBuilder: raise - def not_found(self, func: Callable): + def not_found(self, func: Optional[Callable] = None): + if func is None: + return self.exception_handler(NotFoundError) return self.exception_handler(NotFoundError)(func) def exception_handler(self, exc_class: Type[Exception]): diff --git a/aws_lambda_powertools/shared/functions.py b/aws_lambda_powertools/shared/functions.py index 51f55b2cf2f..11c4e4ce77c 100644 --- a/aws_lambda_powertools/shared/functions.py +++ b/aws_lambda_powertools/shared/functions.py @@ -1,14 +1,21 @@ from typing import Any, Optional, Union -def strtobool(value): +def strtobool(value: str) -> bool: + """Convert a string representation of truth to True or False. + + True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values + are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if + 'value' is anything else. + + > note:: Copied from distutils.util. + """ value = value.lower() if value in ("y", "yes", "t", "true", "on", "1"): - return 1 - elif value in ("n", "no", "f", "false", "off", "0"): - return 0 - else: - raise ValueError("invalid truth value %r" % (value,)) + return True + if value in ("n", "no", "f", "false", "off", "0"): + return False + raise ValueError(f"invalid truth value {value!r}") def resolve_truthy_env_var_choice(env: str, choice: Optional[bool] = None) -> bool: diff --git a/aws_lambda_powertools/utilities/data_classes/cognito_user_pool_event.py b/aws_lambda_powertools/utilities/data_classes/cognito_user_pool_event.py index 954d3d15b5f..df2726ee722 100644 --- a/aws_lambda_powertools/utilities/data_classes/cognito_user_pool_event.py +++ b/aws_lambda_powertools/utilities/data_classes/cognito_user_pool_event.py @@ -687,7 +687,7 @@ def session(self) -> List[ChallengeResult]: @property def client_metadata(self) -> Optional[Dict[str, str]]: """One or more key-value pairs that you can provide as custom input to the Lambda function that you - specify for the create auth challenge trigger..""" + specify for the create auth challenge trigger.""" return self["request"].get("clientMetadata") diff --git a/aws_lambda_powertools/utilities/data_classes/common.py b/aws_lambda_powertools/utilities/data_classes/common.py index 88d1f1d9761..45f6bafc957 100644 --- a/aws_lambda_powertools/utilities/data_classes/common.py +++ b/aws_lambda_powertools/utilities/data_classes/common.py @@ -38,7 +38,7 @@ def get_header_value( name_lower = name.lower() return next( - # Iterate over the dict and do a case insensitive key comparison + # Iterate over the dict and do a case-insensitive key comparison (value for key, value in headers.items() if key.lower() == name_lower), # Default value is returned if no matches was found default_value, @@ -116,7 +116,7 @@ def get_header_value( default_value: str, optional Default value if no value was found by name case_sensitive: bool - Whether to use a case sensitive look up + Whether to use a case-sensitive look up Returns ------- str, optional diff --git a/aws_lambda_powertools/utilities/idempotency/exceptions.py b/aws_lambda_powertools/utilities/idempotency/exceptions.py index 6c7318ebca0..e114ab57e8d 100644 --- a/aws_lambda_powertools/utilities/idempotency/exceptions.py +++ b/aws_lambda_powertools/utilities/idempotency/exceptions.py @@ -47,5 +47,5 @@ class IdempotencyPersistenceLayerError(Exception): class IdempotencyKeyError(Exception): """ - Payload does not contain a idempotent key + Payload does not contain an idempotent key """ diff --git a/aws_lambda_powertools/utilities/idempotency/idempotency.py b/aws_lambda_powertools/utilities/idempotency/idempotency.py index 6984cfbbd8e..42b8052fd32 100644 --- a/aws_lambda_powertools/utilities/idempotency/idempotency.py +++ b/aws_lambda_powertools/utilities/idempotency/idempotency.py @@ -7,7 +7,7 @@ from typing import Any, Callable, Dict, Optional, cast from aws_lambda_powertools.middleware_factory import lambda_handler_decorator -from aws_lambda_powertools.shared.constants import IDEMPOTENCY_DISABLED_ENV +from aws_lambda_powertools.shared import constants from aws_lambda_powertools.shared.types import AnyCallableT from aws_lambda_powertools.utilities.idempotency.base import IdempotencyHandler from aws_lambda_powertools.utilities.idempotency.config import IdempotencyConfig @@ -58,7 +58,7 @@ def idempotent( >>> return {"StatusCode": 200} """ - if os.getenv(IDEMPOTENCY_DISABLED_ENV): + if os.getenv(constants.IDEMPOTENCY_DISABLED_ENV): return handler(event, context) config = config or IdempotencyConfig() @@ -127,7 +127,7 @@ def process_order(customer_id: str, order: dict, **kwargs): @functools.wraps(function) def decorate(*args, **kwargs): - if os.getenv(IDEMPOTENCY_DISABLED_ENV): + if os.getenv(constants.IDEMPOTENCY_DISABLED_ENV): return function(*args, **kwargs) payload = kwargs.get(data_keyword_argument) diff --git a/tests/functional/event_handler/test_api_gateway.py b/tests/functional/event_handler/test_api_gateway.py index 45b1e3f41a4..76ecbc7cdd7 100644 --- a/tests/functional/event_handler/test_api_gateway.py +++ b/tests/functional/event_handler/test_api_gateway.py @@ -1142,10 +1142,26 @@ def handle_not_found(exc: NotFoundError) -> Response: return Response(status_code=404, content_type=content_types.TEXT_PLAIN, body="I am a teapot!") # WHEN calling the event handler - # AND not route is found + # AND no route is found result = app(LOAD_GW_EVENT, {}) # THEN call the exception_handler assert result["statusCode"] == 404 assert result["headers"]["Content-Type"] == content_types.TEXT_PLAIN assert result["body"] == "I am a teapot!" + + +def test_exception_handler_not_found_alt(): + # GIVEN a resolver with `@app.not_found()` + app = ApiGatewayResolver() + + @app.not_found() + def handle_not_found(_) -> Response: + return Response(status_code=404, content_type=content_types.APPLICATION_JSON, body="{}") + + # WHEN calling the event handler + # AND no route is found + result = app(LOAD_GW_EVENT, {}) + + # THEN call the @app.not_found() function + assert result["statusCode"] == 404 diff --git a/tests/functional/idempotency/conftest.py b/tests/functional/idempotency/conftest.py index a6bcf072a82..017445ab348 100644 --- a/tests/functional/idempotency/conftest.py +++ b/tests/functional/idempotency/conftest.py @@ -165,6 +165,11 @@ def persistence_store(config): return DynamoDBPersistenceLayer(table_name=TABLE_NAME, boto_config=config) +@pytest.fixture +def persistence_store_compound(config): + return DynamoDBPersistenceLayer(table_name=TABLE_NAME, boto_config=config, key_attr="id", sort_key_attr="sk") + + @pytest.fixture def idempotency_config(config, request, default_jmespath): return IdempotencyConfig( diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index 0ed2cfcfb59..0732f1d58b1 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -1148,3 +1148,49 @@ def collect_payment(payment: Payment): # THEN idempotency key assertion happens at MockPersistenceLayer assert result == payment.transaction_id + + +@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}], indirect=True) +def test_idempotent_lambda_compound_already_completed( + idempotency_config: IdempotencyConfig, + persistence_store_compound: DynamoDBPersistenceLayer, + lambda_apigw_event, + timestamp_future, + hashed_idempotency_key, + serialized_lambda_response, + deserialized_lambda_response, + lambda_context, +): + """ + Test idempotent decorator having a DynamoDBPersistenceLayer with a compound key + """ + + stubber = stub.Stubber(persistence_store_compound.table.meta.client) + stubber.add_client_error("put_item", "ConditionalCheckFailedException") + ddb_response = { + "Item": { + "id": {"S": "idempotency#"}, + "sk": {"S": hashed_idempotency_key}, + "expiration": {"N": timestamp_future}, + "data": {"S": serialized_lambda_response}, + "status": {"S": "COMPLETED"}, + } + } + expected_params = { + "TableName": TABLE_NAME, + "Key": {"id": "idempotency#", "sk": hashed_idempotency_key}, + "ConsistentRead": True, + } + stubber.add_response("get_item", ddb_response, expected_params) + + stubber.activate() + + @idempotent(config=idempotency_config, persistence_store=persistence_store_compound) + def lambda_handler(event, context): + raise ValueError + + lambda_resp = lambda_handler(lambda_apigw_event, lambda_context) + assert lambda_resp == deserialized_lambda_response + + stubber.assert_no_pending_responses() + stubber.deactivate() diff --git a/tests/functional/test_shared_functions.py b/tests/functional/test_shared_functions.py index cc4fd77fbe5..c71b7239739 100644 --- a/tests/functional/test_shared_functions.py +++ b/tests/functional/test_shared_functions.py @@ -1,4 +1,6 @@ -from aws_lambda_powertools.shared.functions import resolve_env_var_choice, resolve_truthy_env_var_choice +import pytest + +from aws_lambda_powertools.shared.functions import resolve_env_var_choice, resolve_truthy_env_var_choice, strtobool def test_resolve_env_var_choice_explicit_wins_over_env_var(): @@ -9,3 +11,19 @@ def test_resolve_env_var_choice_explicit_wins_over_env_var(): def test_resolve_env_var_choice_env_wins_over_absent_explicit(): assert resolve_truthy_env_var_choice(env="true") == 1 assert resolve_env_var_choice(env="something") == "something" + + +@pytest.mark.parametrize("true_value", ["y", "yes", "t", "true", "on", "1"]) +def test_strtobool_true(true_value): + assert strtobool(true_value) + + +@pytest.mark.parametrize("false_value", ["n", "no", "f", "false", "off", "0"]) +def test_strtobool_false(false_value): + assert strtobool(false_value) is False + + +def test_strtobool_value_error(): + with pytest.raises(ValueError) as exp: + strtobool("fail") + assert str(exp.value) == "invalid truth value 'fail'"