Skip to content

Commit fc38f63

Browse files
committed
add support to dynamic model deducation
1 parent 58c803c commit fc38f63

File tree

5 files changed

+84
-12
lines changed

5 files changed

+84
-12
lines changed

aws_lambda_powertools/utilities/idempotency/exceptions.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,15 @@ class IdempotencyKeyError(BaseError):
7171
"""
7272
Payload does not contain an idempotent key
7373
"""
74+
75+
76+
class IdempotencyModelTypeError(BaseError):
77+
"""
78+
Model type does not match expected type
79+
"""
80+
81+
82+
class IdempotencyNoSerializationModelError(BaseError):
83+
"""
84+
No model was supplied to the serializer
85+
"""

aws_lambda_powertools/utilities/idempotency/idempotency.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
import functools
55
import logging
66
import os
7-
from typing import Any, Callable, Dict, Optional, cast
7+
from inspect import isclass
8+
from typing import Any, Callable, Dict, Optional, Type, Union, cast
89

910
from aws_lambda_powertools.middleware_factory import lambda_handler_decorator
1011
from aws_lambda_powertools.shared import constants
@@ -14,7 +15,10 @@
1415
from aws_lambda_powertools.utilities.idempotency.persistence.base import (
1516
BasePersistenceLayer,
1617
)
17-
from aws_lambda_powertools.utilities.idempotency.serialization.base import BaseIdempotencySerializer
18+
from aws_lambda_powertools.utilities.idempotency.serialization.base import (
19+
BaseIdempotencyModelSerializer,
20+
BaseIdempotencySerializer,
21+
)
1822
from aws_lambda_powertools.utilities.typing import LambdaContext
1923

2024
logger = logging.getLogger(__name__)
@@ -86,7 +90,7 @@ def idempotent_function(
8690
data_keyword_argument: str,
8791
persistence_store: BasePersistenceLayer,
8892
config: Optional[IdempotencyConfig] = None,
89-
output_serializer: Optional[BaseIdempotencySerializer] = None,
93+
output_serializer: Optional[Union[BaseIdempotencySerializer, Type[BaseIdempotencyModelSerializer]]] = None,
9094
) -> Any:
9195
"""
9296
Decorator to handle idempotency of any function
@@ -101,9 +105,11 @@ def idempotent_function(
101105
Instance of BasePersistenceLayer to store data
102106
config: IdempotencyConfig
103107
Configuration
104-
output_serializer: Optional[BaseIdempotencySerializer]
108+
output_serializer: Optional[Union[BaseIdempotencySerializer, Type[BaseIdempotencyModelSerializer]]]
105109
Serializer to transform the data to and from a dictionary.
106-
If not supplied, no serialization is done via the NoOpSerializer
110+
If not supplied, no serialization is done via the NoOpSerializer.
111+
In case a serializer of type inheriting BaseIdempotencyModelSerializer] is given,
112+
the serializer is deduced from the function return type.
107113
Examples
108114
--------
109115
**Processes an order in an idempotent manner**
@@ -131,6 +137,9 @@ def process_order(customer_id: str, order: dict, **kwargs):
131137
output_serializer=output_serializer,
132138
),
133139
)
140+
if isclass(output_serializer) and issubclass(output_serializer, BaseIdempotencyModelSerializer):
141+
# instantiate an instance of the serializer class
142+
output_serializer = output_serializer.instantiate(function.__annotations__.get("return", None))
134143

135144
config = config or IdempotencyConfig()
136145

aws_lambda_powertools/utilities/idempotency/serialization/base.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,31 @@ def to_dict(self, data: Any) -> Dict:
1717
@abstractmethod
1818
def from_dict(self, data: Dict) -> Any:
1919
pass
20+
21+
22+
class BaseIdempotencyModelSerializer(BaseIdempotencySerializer):
23+
"""
24+
Abstract Base Class for Idempotency serialization layer, for using a model as data object representation.
25+
"""
26+
27+
@classmethod
28+
@abstractmethod
29+
def instantiate(cls, model_type: Any) -> BaseIdempotencySerializer:
30+
"""
31+
Instantiate the serializer from the given model type.
32+
In case there is the model_type is unknown, None will be sent to the method.
33+
It's on the implementer to verify that:
34+
- None is handled
35+
- A model type not matching the expected types is handled
36+
37+
Parameters
38+
----------
39+
model_type: Any
40+
The model type to instantiate the class for
41+
42+
Returns
43+
-------
44+
BaseIdempotencySerializer
45+
Instance of the serializer class
46+
"""
47+
pass

aws_lambda_powertools/utilities/idempotency/serialization/pydantic.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1-
from typing import Dict, Type
1+
from typing import Any, Dict, Type
22

33
from pydantic import BaseModel
44

5-
from aws_lambda_powertools.utilities.idempotency.serialization.base import BaseIdempotencySerializer
5+
from aws_lambda_powertools.utilities.idempotency.exceptions import (
6+
IdempotencyModelTypeError,
7+
IdempotencyNoSerializationModelError,
8+
)
9+
from aws_lambda_powertools.utilities.idempotency.serialization.base import (
10+
BaseIdempotencyModelSerializer,
11+
BaseIdempotencySerializer,
12+
)
613

714
Model = BaseModel
815

916

10-
class PydanticSerializer(BaseIdempotencySerializer):
17+
class PydanticSerializer(BaseIdempotencyModelSerializer):
1118
def __init__(self, model: Type[Model]):
1219
"""
1320
Parameters
@@ -28,3 +35,13 @@ def from_dict(self, data: Dict) -> Model:
2835
# Support for pydantic V2
2936
return self.__model.model_validate(data) # type: ignore[unused-ignore,attr-defined]
3037
return self.__model.parse_obj(data)
38+
39+
@classmethod
40+
def instantiate(cls, model_type: Any) -> BaseIdempotencySerializer:
41+
if model_type is None:
42+
raise IdempotencyNoSerializationModelError("No serialization model was supplied")
43+
44+
if not issubclass(model_type, BaseModel):
45+
raise IdempotencyModelTypeError("Model type is not inherited from pydantic BaseModel")
46+
47+
return cls(model=model_type)

tests/functional/idempotency/test_idempotency.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,7 +1278,8 @@ def record_handler(record):
12781278
assert from_dict_called is False, "in case response is None, from_dict should not be called"
12791279

12801280

1281-
def test_idempotent_function_serialization_pydantic():
1281+
@pytest.mark.parametrize("output_serializer_type", ["explicit", "deduced"])
1282+
def test_idempotent_function_serialization_pydantic(output_serializer_type: str):
12821283
# GIVEN
12831284
config = IdempotencyConfig(use_local_cache=True)
12841285
mock_event = {"customer_id": "fake", "transaction_id": "fake-id"}
@@ -1293,13 +1294,18 @@ class PaymentOutput(BaseModel):
12931294
customer_id: str
12941295
transaction_id: str
12951296

1297+
if output_serializer_type == "explicit":
1298+
output_serializer = PydanticSerializer(
1299+
model=PaymentOutput,
1300+
)
1301+
else:
1302+
output_serializer = PydanticSerializer
1303+
12961304
@idempotent_function(
12971305
data_keyword_argument="payment",
12981306
persistence_store=persistence_layer,
12991307
config=config,
1300-
output_serializer=PydanticSerializer(
1301-
model=PaymentOutput,
1302-
),
1308+
output_serializer=output_serializer,
13031309
)
13041310
def collect_payment(payment: PaymentInput) -> PaymentOutput:
13051311
return PaymentOutput(**payment.dict())

0 commit comments

Comments
 (0)