Skip to content

Commit 04dbc04

Browse files
exgleandrodamascenaclaude
authored
refactor(typing): add type hints to middleware-based decorators (#8541)
* refactor(typing): add type hints to middleware-based decorators lambda_handler_decorator returns an untyped Callable, so handlers decorated with event_parser, idempotent, kafka_consumer and validator were typed as Any. event_parser, kafka_consumer and validator are typed with overloads. idempotent, which can only be called with options, is instead cast to a Protocol, following the existing event_source pattern. Decorated handlers are typed with a shared LambdaHandler Protocol, so they can be called with keyword arguments. * docs(idempotency,kafka): keep testing examples type-checked with typed decorators Decorated handlers now expect a LambdaContext, so the fake contexts used in the testing examples are cast to LambdaContext, and the Kafka example passes a real LambdaContext. Also fixes the DynamoDB Local and I/O examples, which called a `handler` attribute that does not exist. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Leandro Damascena <lcdama@amazon.pt> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent b2e940b commit 04dbc04

14 files changed

Lines changed: 227 additions & 34 deletions

File tree

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
11
from collections.abc import Callable
2-
from typing import Any, TypeVar
2+
from typing import Any, Protocol, TypeVar
33

44
AnyCallableT = TypeVar("AnyCallableT", bound=Callable[..., Any])
5+
6+
EventT_contra = TypeVar("EventT_contra", contravariant=True)
7+
ContextT_contra = TypeVar("ContextT_contra", contravariant=True)
8+
ReturnT_co = TypeVar("ReturnT_co", covariant=True)
9+
10+
11+
class LambdaHandler(Protocol[EventT_contra, ContextT_contra, ReturnT_co]):
12+
"""Lambda handler, callable with `event` and `context` as positional or keyword arguments."""
13+
14+
def __call__(self, event: EventT_contra, context: ContextT_contra) -> ReturnT_co: ...

‎aws_lambda_powertools/utilities/data_classes/event_source.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
if TYPE_CHECKING:
88
from collections.abc import Callable
99

10+
from aws_lambda_powertools.shared.types import LambdaHandler
1011
from aws_lambda_powertools.utilities.data_classes.common import DictWrapper
1112
from aws_lambda_powertools.utilities.typing import LambdaContext
1213

@@ -15,15 +16,15 @@
1516

1617

1718
class _EventSourceDecorator(Protocol):
18-
"""Annotation of event_source, that lambda_handler_decorator erases at runtime."""
19+
"""Type of `event_source`, as `lambda_handler_decorator` returns an untyped `Callable`."""
1920

2021
def __call__(
2122
self,
2223
*,
2324
data_class: type[DataClassT],
2425
) -> Callable[
2526
[Callable[[DataClassT, LambdaContext], OutputT]],
26-
Callable[[dict[str, Any], LambdaContext], OutputT],
27+
LambdaHandler[dict[str, Any], LambdaContext, OutputT],
2728
]: ...
2829

2930

‎aws_lambda_powertools/utilities/idempotency/idempotency.py‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import os
1010
import warnings
1111
from inspect import isclass
12-
from typing import TYPE_CHECKING, Any, cast
12+
from typing import TYPE_CHECKING, Any, Protocol, TypeVar, cast
1313

1414
from aws_lambda_powertools.middleware_factory import lambda_handler_decorator
1515
from aws_lambda_powertools.shared import constants
@@ -25,6 +25,7 @@
2525
if TYPE_CHECKING:
2626
from collections.abc import Callable
2727

28+
from aws_lambda_powertools.shared.types import LambdaHandler
2829
from aws_lambda_powertools.utilities.idempotency.persistence.base import (
2930
BasePersistenceLayer,
3031
)
@@ -34,9 +35,33 @@
3435

3536
logger = logging.getLogger(__name__)
3637

38+
EventT = TypeVar("EventT")
39+
ContextT = TypeVar("ContextT", bound="LambdaContext | DurableContextProtocol")
40+
41+
42+
class _IdempotentHandlerDecorator(Protocol):
43+
# The return type is Any, as responses replayed from the persistence store are deserialized JSON
44+
def __call__(
45+
self,
46+
handler: Callable[[EventT, ContextT], Any],
47+
/,
48+
) -> LambdaHandler[EventT, ContextT, Any]: ...
49+
50+
51+
class _IdempotentDecorator(Protocol):
52+
"""Type of `idempotent`, as `lambda_handler_decorator` returns an untyped `Callable`."""
53+
54+
def __call__(
55+
self,
56+
*,
57+
persistence_store: BasePersistenceLayer,
58+
config: IdempotencyConfig | None = None,
59+
key_prefix: str | None = None,
60+
) -> _IdempotentHandlerDecorator: ...
61+
3762

3863
@lambda_handler_decorator
39-
def idempotent(
64+
def _idempotent(
4065
handler: Callable[[Any, LambdaContext | DurableContextProtocol], Any],
4166
event: dict[str, Any],
4267
context: LambdaContext | DurableContextProtocol,
@@ -117,6 +142,9 @@ def handler(event, context):
117142
return idempotency_handler.handle(is_replay=is_replay)
118143

119144

145+
idempotent = cast(_IdempotentDecorator, _idempotent)
146+
147+
120148
def idempotent_function(
121149
function: AnyCallableT | None = None,
122150
*,

‎aws_lambda_powertools/utilities/kafka/kafka_consumer.py‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,41 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Any
3+
from typing import TYPE_CHECKING, Any, Protocol, TypeVar, overload
44

55
from aws_lambda_powertools.middleware_factory import lambda_handler_decorator
66
from aws_lambda_powertools.utilities.kafka.consumer_records import ConsumerRecords
77

88
if TYPE_CHECKING:
99
from collections.abc import Callable
1010

11+
from aws_lambda_powertools.shared.types import LambdaHandler
1112
from aws_lambda_powertools.utilities.kafka.schema_config import SchemaConfig
1213
from aws_lambda_powertools.utilities.typing import LambdaContext
1314

15+
ReturnT = TypeVar("ReturnT")
16+
17+
18+
class _KafkaConsumerHandlerDecorator(Protocol):
19+
def __call__(
20+
self,
21+
handler: Callable[[ConsumerRecords, LambdaContext], ReturnT],
22+
/,
23+
) -> LambdaHandler[dict[str, Any], LambdaContext, ReturnT]: ...
24+
25+
26+
@overload
27+
def kafka_consumer(
28+
handler: Callable[[ConsumerRecords, LambdaContext], ReturnT],
29+
/,
30+
) -> LambdaHandler[dict[str, Any], LambdaContext, ReturnT]: ...
31+
32+
33+
@overload
34+
def kafka_consumer(
35+
*,
36+
schema_config: SchemaConfig | None = None,
37+
) -> _KafkaConsumerHandlerDecorator: ...
38+
1439

1540
@lambda_handler_decorator
1641
def kafka_consumer(

‎aws_lambda_powertools/utilities/parser/parser.py‎

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import logging
1111
import typing
12-
from typing import TYPE_CHECKING, Any, Callable, overload
12+
from typing import TYPE_CHECKING, Any, Callable, Protocol, TypeVar, overload
1313

1414
from pydantic import PydanticSchemaGenerationError
1515

@@ -21,12 +21,62 @@
2121
)
2222

2323
if TYPE_CHECKING:
24-
from aws_lambda_powertools.utilities.parser.envelopes.base import Envelope
24+
from pydantic import TypeAdapter
25+
26+
from aws_lambda_powertools.shared.types import LambdaHandler
27+
from aws_lambda_powertools.utilities.parser.envelopes.base import BaseEnvelope, Envelope
2528
from aws_lambda_powertools.utilities.parser.types import EventParserReturnType, T
2629
from aws_lambda_powertools.utilities.typing import LambdaContext
2730

2831
logger = logging.getLogger(__name__)
2932

33+
ModelT_co = TypeVar("ModelT_co", covariant=True)
34+
ModelT = TypeVar("ModelT")
35+
ReturnT = TypeVar("ReturnT")
36+
37+
38+
class _EventParserModelHandlerDecorator(Protocol[ModelT_co]):
39+
def __call__(
40+
self,
41+
handler: Callable[[ModelT_co, LambdaContext], ReturnT],
42+
/,
43+
) -> LambdaHandler[dict[str, Any] | str, LambdaContext, ReturnT]: ...
44+
45+
46+
class _EventParserHandlerDecorator(Protocol):
47+
# The handler's event type is Any, as it can't be derived from the arguments, e.g.
48+
# - @event_parser()
49+
# - @event_parser(model=Annotated[A | B, Field(discriminator="kind")])
50+
# - @event_parser(model=A, envelope=SqsEnvelope)
51+
def __call__(
52+
self,
53+
handler: Callable[[Any, LambdaContext], ReturnT],
54+
/,
55+
) -> LambdaHandler[dict[str, Any] | str, LambdaContext, ReturnT]: ...
56+
57+
58+
@overload
59+
def event_parser(
60+
handler: Callable[[Any, LambdaContext], ReturnT],
61+
/,
62+
) -> LambdaHandler[dict[str, Any] | str, LambdaContext, ReturnT]: ...
63+
64+
65+
@overload
66+
def event_parser(
67+
*,
68+
model: type[ModelT] | TypeAdapter[ModelT],
69+
envelope: None = None,
70+
) -> _EventParserModelHandlerDecorator[ModelT]: ...
71+
72+
73+
@overload
74+
def event_parser(
75+
*,
76+
model: Any = None,
77+
envelope: type[BaseEnvelope] | None = None,
78+
) -> _EventParserHandlerDecorator: ...
79+
3080

3181
@lambda_handler_decorator
3282
def event_parser(

‎aws_lambda_powertools/utilities/validation/validator.py‎

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import logging
4-
from typing import TYPE_CHECKING, Any
4+
from typing import TYPE_CHECKING, Any, Protocol, TypeVar, overload
55

66
from aws_lambda_powertools.middleware_factory import lambda_handler_decorator
77
from aws_lambda_powertools.utilities import jmespath_utils
@@ -10,8 +10,69 @@
1010
if TYPE_CHECKING:
1111
from collections.abc import Callable
1212

13+
from aws_lambda_powertools.shared.types import LambdaHandler
14+
1315
logger = logging.getLogger(__name__)
1416

17+
EventT = TypeVar("EventT")
18+
ContextT = TypeVar("ContextT")
19+
ReturnT = TypeVar("ReturnT")
20+
21+
22+
class _ValidatorHandlerDecorator(Protocol):
23+
def __call__(
24+
self,
25+
handler: Callable[[EventT, ContextT], ReturnT],
26+
/,
27+
) -> LambdaHandler[EventT, ContextT, ReturnT]: ...
28+
29+
30+
class _ValidatorEnvelopeHandlerDecorator(Protocol):
31+
# The handler's event type is Any, as it can't be derived from the arguments, e.g.
32+
# - @validator(envelope=envelopes.SQS)
33+
def __call__(
34+
self,
35+
handler: Callable[[Any, ContextT], ReturnT],
36+
/,
37+
) -> LambdaHandler[dict[str, Any] | str, ContextT, ReturnT]: ...
38+
39+
40+
@overload
41+
def validator(
42+
handler: Callable[[EventT, ContextT], ReturnT],
43+
/,
44+
) -> LambdaHandler[EventT, ContextT, ReturnT]: ...
45+
46+
47+
@overload
48+
def validator(
49+
*,
50+
inbound_schema: dict | None = None,
51+
inbound_formats: dict | None = None,
52+
inbound_handlers: dict | None = None,
53+
inbound_provider_options: dict | None = None,
54+
outbound_schema: dict | None = None,
55+
outbound_formats: dict | None = None,
56+
outbound_handlers: dict | None = None,
57+
outbound_provider_options: dict | None = None,
58+
) -> _ValidatorHandlerDecorator: ...
59+
60+
61+
@overload
62+
def validator(
63+
*,
64+
inbound_schema: dict | None = None,
65+
inbound_formats: dict | None = None,
66+
inbound_handlers: dict | None = None,
67+
inbound_provider_options: dict | None = None,
68+
outbound_schema: dict | None = None,
69+
outbound_formats: dict | None = None,
70+
outbound_handlers: dict | None = None,
71+
outbound_provider_options: dict | None = None,
72+
envelope: str,
73+
jmespath_options: dict | None = None,
74+
) -> _ValidatorEnvelopeHandlerDecorator: ...
75+
1576

1677
@lambda_handler_decorator
1778
def validator(

‎docs/utilities/idempotency.md‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,14 +1054,17 @@ Explore how to handle conditional write errors in high-concurrency scenarios wit
10541054

10551055
The idempotency utility provides several routes to test your code.
10561056

1057+
???+ tip "Type-checked tests"
1058+
The `idempotent` decorator is typed, so a decorated handler expects a `LambdaContext` as its second argument. When you use a lightweight fake context in tests, as in the examples below, `cast` it to `LambdaContext` to keep your type checker happy. Runtime behaviour is unchanged.
1059+
10571060
### Disabling the idempotency utility
10581061

10591062
When testing your code, you may wish to disable the idempotency logic altogether and focus on testing your business logic. To do this, you can set the environment variable `POWERTOOLS_IDEMPOTENCY_DISABLED`
10601063
with a truthy value. If you prefer setting this for specific tests, and are using Pytest, you can use [monkeypatch](https://docs.pytest.org/en/latest/monkeypatch.html){target="_blank" rel="nofollow"} fixture:
10611064

10621065
=== "test_disabling_idempotency_utility.py"
10631066

1064-
```python hl_lines="3 4 23 24"
1067+
```python hl_lines="4 5 23 26 28"
10651068
--8<-- "examples/idempotency/tests/test_disabling_idempotency_utility.py"
10661069
```
10671070

@@ -1077,7 +1080,7 @@ To test with [DynamoDB Local](https://docs.aws.amazon.com/amazondynamodb/latest/
10771080

10781081
=== "test_with_dynamodb_local.py"
10791082

1080-
```python hl_lines="3-5 25 26"
1083+
```python hl_lines="4 5 6 24 29 30"
10811084
--8<-- "examples/idempotency/tests/test_with_dynamodb_local.py"
10821085
```
10831086

@@ -1094,7 +1097,7 @@ This means it is possible to pass a mocked Table resource, or stub various metho
10941097

10951098
=== "test_with_io_operations.py"
10961099

1097-
```python hl_lines="4 5 24 25 27"
1100+
```python hl_lines="5 6 24 28 29 31"
10981101
--8<-- "examples/idempotency/tests/test_with_io_operations.py"
10991102
```
11001103

@@ -1110,7 +1113,7 @@ To test locally, you can either utilize [fakeredis-py](https://github.com/cunla/
11101113

11111114
=== "test_with_mock_redis.py"
11121115

1113-
```python hl_lines="2 3 29 31"
1116+
```python hl_lines="3 4 30 32"
11141117
--8<-- "examples/idempotency/tests/test_with_mock_redis.py"
11151118
```
11161119

@@ -1124,7 +1127,7 @@ If you want to set up a real Redis client for integration testing, you can refer
11241127

11251128
=== "test_with_real_redis.py"
11261129

1127-
```python hl_lines="3 4 29 38"
1130+
```python hl_lines="4 5 30 39"
11281131
--8<-- "examples/idempotency/tests/test_with_real_redis.py"
11291132
```
11301133

‎examples/idempotency/tests/test_disabling_idempotency_utility.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
from dataclasses import dataclass
2+
from typing import cast
23

34
import app_test_disabling_idempotency_utility
45
import pytest
56

7+
from aws_lambda_powertools.utilities.typing import LambdaContext
8+
69

710
@dataclass
8-
class LambdaContext:
11+
class FakeLambdaContext:
912
function_name: str = "test"
1013
memory_limit_in_mb: int = 128
1114
invoked_function_arn: str = "arn:aws:lambda:eu-west-1:809313241:function:test"
@@ -17,7 +20,7 @@ def get_remaining_time_in_millis(self) -> int:
1720

1821
@pytest.fixture
1922
def lambda_context() -> LambdaContext:
20-
return LambdaContext()
23+
return cast(LambdaContext, FakeLambdaContext())
2124

2225

2326
def test_idempotent_lambda_handler(monkeypatch, lambda_context: LambdaContext):

0 commit comments

Comments
 (0)