Skip to content

Commit 16efb64

Browse files
feat(parser): Allow primitive data types to be parsed using TypeAdapter (aws-powertools#4502)
* feat(parser): allow union types * change validation method in the parser * test test * fix tests * fixes exception handling in parse * annotations fix * add some docs for unions * move to generics for parser * split docs example out * split docs example out * docs * typo * update docstrings * Update docs/utilities/parser.md Co-authored-by: Leandro Damascena <lcdama@amazon.pt> Signed-off-by: Simon Thulbourn <sthulb@users.noreply.github.com> * update doc string * add cache to parser to improve perf * fix types in the test * Final adjusts * Making mypy happy * Making pytest happy * Addressing Heitor's feedback --------- Signed-off-by: Simon Thulbourn <sthulb@users.noreply.github.com> Co-authored-by: Leandro Damascena <lcdama@amazon.pt>
1 parent 0b29cef commit 16efb64

21 files changed

+410
-94
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""Advanced event_parser utility
22
"""
33

4-
from . import envelopes
5-
from .envelopes import BaseEnvelope
6-
from .parser import event_parser, parse
7-
from .pydantic import BaseModel, Field, ValidationError, root_validator, validator
4+
from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
5+
6+
from aws_lambda_powertools.utilities.parser import envelopes
7+
from aws_lambda_powertools.utilities.parser.envelopes import BaseEnvelope
8+
from aws_lambda_powertools.utilities.parser.parser import event_parser, parse
89

910
__all__ = [
1011
"event_parser",
@@ -13,7 +14,7 @@
1314
"BaseEnvelope",
1415
"BaseModel",
1516
"Field",
16-
"validator",
17-
"root_validator",
17+
"field_validator",
18+
"model_validator",
1819
"ValidationError",
1920
]

aws_lambda_powertools/utilities/parser/compat.py

-34
This file was deleted.

aws_lambda_powertools/utilities/parser/envelopes/base.py

+12-7
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
from __future__ import annotations
2+
13
import logging
24
from abc import ABC, abstractmethod
3-
from typing import Any, Dict, Optional, Type, TypeVar, Union
5+
from typing import Any, Dict, Optional, TypeVar, Union
46

5-
from aws_lambda_powertools.utilities.parser.types import Model
7+
from aws_lambda_powertools.utilities.parser.functions import _retrieve_or_set_model_from_cache
8+
from aws_lambda_powertools.utilities.parser.types import T
69

710
logger = logging.getLogger(__name__)
811

@@ -11,14 +14,14 @@ class BaseEnvelope(ABC):
1114
"""ABC implementation for creating a supported Envelope"""
1215

1316
@staticmethod
14-
def _parse(data: Optional[Union[Dict[str, Any], Any]], model: Type[Model]) -> Union[Model, None]:
17+
def _parse(data: Optional[Union[Dict[str, Any], Any]], model: type[T]) -> Union[T, None]:
1518
"""Parses envelope data against model provided
1619
1720
Parameters
1821
----------
1922
data : Dict
2023
Data to be parsed and validated
21-
model : Type[Model]
24+
model : type[T]
2225
Data model to parse and validate data against
2326
2427
Returns
@@ -30,15 +33,17 @@ def _parse(data: Optional[Union[Dict[str, Any], Any]], model: Type[Model]) -> Un
3033
logger.debug("Skipping parsing as event is None")
3134
return data
3235

36+
adapter = _retrieve_or_set_model_from_cache(model=model)
37+
3338
logger.debug("parsing event against model")
3439
if isinstance(data, str):
3540
logger.debug("parsing event as string")
36-
return model.model_validate_json(data)
41+
return adapter.validate_json(data)
3742

38-
return model.model_validate(data)
43+
return adapter.validate_python(data)
3944

4045
@abstractmethod
41-
def parse(self, data: Optional[Union[Dict[str, Any], Any]], model: Type[Model]):
46+
def parse(self, data: Optional[Union[Dict[str, Any], Any]], model: type[T]):
4247
"""Implementation to parse data against envelope model, then against the data model
4348
4449
NOTE: Call `_parse` method to fully parse data with model provided.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from __future__ import annotations
2+
3+
from pydantic import TypeAdapter
4+
5+
from aws_lambda_powertools.shared.cache_dict import LRUDict
6+
from aws_lambda_powertools.utilities.parser.types import T
7+
8+
CACHE_TYPE_ADAPTER = LRUDict(max_items=1024)
9+
10+
11+
def _retrieve_or_set_model_from_cache(model: type[T]) -> TypeAdapter:
12+
"""
13+
Retrieves or sets a TypeAdapter instance from the cache for the given model.
14+
15+
If the model is already present in the cache, the corresponding TypeAdapter
16+
instance is returned. Otherwise, a new TypeAdapter instance is created,
17+
stored in the cache, and returned.
18+
19+
Parameters
20+
----------
21+
model: type[T]
22+
The model type for which the TypeAdapter instance should be retrieved or set.
23+
24+
Returns
25+
-------
26+
TypeAdapter
27+
The TypeAdapter instance for the given model,
28+
either retrieved from the cache or newly created and stored in the cache.
29+
"""
30+
id_model = id(model)
31+
32+
if id_model in CACHE_TYPE_ADAPTER:
33+
return CACHE_TYPE_ADAPTER[id_model]
34+
35+
CACHE_TYPE_ADAPTER[id_model] = TypeAdapter(model)
36+
return CACHE_TYPE_ADAPTER[id_model]

aws_lambda_powertools/utilities/parser/parser.py

+32-16
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
from __future__ import annotations
2+
13
import logging
24
import typing
35
from typing import Any, Callable, Dict, Optional, Type, overload
46

7+
from pydantic import PydanticSchemaGenerationError, ValidationError
8+
59
from aws_lambda_powertools.middleware_factory import lambda_handler_decorator
610
from aws_lambda_powertools.utilities.parser.envelopes.base import Envelope
711
from aws_lambda_powertools.utilities.parser.exceptions import InvalidEnvelopeError, InvalidModelTypeError
8-
from aws_lambda_powertools.utilities.parser.types import EventParserReturnType, Model
12+
from aws_lambda_powertools.utilities.parser.functions import _retrieve_or_set_model_from_cache
13+
from aws_lambda_powertools.utilities.parser.types import EventParserReturnType, T
914
from aws_lambda_powertools.utilities.typing import LambdaContext
1015

1116
logger = logging.getLogger(__name__)
@@ -16,7 +21,7 @@ def event_parser(
1621
handler: Callable[..., EventParserReturnType],
1722
event: Dict[str, Any],
1823
context: LambdaContext,
19-
model: Optional[Type[Model]] = None,
24+
model: Optional[type[T]] = None,
2025
envelope: Optional[Type[Envelope]] = None,
2126
**kwargs: Any,
2227
) -> EventParserReturnType:
@@ -32,7 +37,7 @@ def event_parser(
3237
This is useful when you need to confirm event wrapper structure, and
3338
b) selectively extract a portion of your payload for parsing & validation.
3439
35-
NOTE: If envelope is omitted, the complete event is parsed to match the model parameter BaseModel definition.
40+
NOTE: If envelope is omitted, the complete event is parsed to match the model parameter definition.
3641
3742
Example
3843
-------
@@ -66,7 +71,7 @@ def handler(event: Order, context: LambdaContext):
6671
Lambda event to be parsed & validated
6772
context: LambdaContext
6873
Lambda context object
69-
model: Model
74+
model: Optional[type[T]]
7075
Your data model that will replace the event.
7176
envelope: Envelope
7277
Optional envelope to extract the model from
@@ -93,24 +98,27 @@ def handler(event: Order, context: LambdaContext):
9398
"or as the type hint of `event` in the handler that it wraps",
9499
)
95100

96-
if envelope:
97-
parsed_event = parse(event=event, model=model, envelope=envelope)
98-
else:
99-
parsed_event = parse(event=event, model=model)
101+
try:
102+
if envelope:
103+
parsed_event = parse(event=event, model=model, envelope=envelope)
104+
else:
105+
parsed_event = parse(event=event, model=model)
100106

101-
logger.debug(f"Calling handler {handler.__name__}")
102-
return handler(parsed_event, context, **kwargs)
107+
logger.debug(f"Calling handler {handler.__name__}")
108+
return handler(parsed_event, context, **kwargs)
109+
except (ValidationError, AttributeError) as exc:
110+
raise InvalidModelTypeError(f"Error: {str(exc)}. Please ensure the type you're trying to parse into is correct")
103111

104112

105113
@overload
106-
def parse(event: Dict[str, Any], model: Type[Model]) -> Model: ... # pragma: no cover
114+
def parse(event: Dict[str, Any], model: type[T]) -> T: ... # pragma: no cover
107115

108116

109117
@overload
110-
def parse(event: Dict[str, Any], model: Type[Model], envelope: Type[Envelope]) -> Model: ... # pragma: no cover
118+
def parse(event: Dict[str, Any], model: type[T], envelope: Type[Envelope]) -> T: ... # pragma: no cover
111119

112120

113-
def parse(event: Dict[str, Any], model: Type[Model], envelope: Optional[Type[Envelope]] = None):
121+
def parse(event: Dict[str, Any], model: type[T], envelope: Optional[Type[Envelope]] = None):
114122
"""Standalone function to parse & validate events using Pydantic models
115123
116124
Typically used when you need fine-grained control over error handling compared to event_parser decorator.
@@ -176,12 +184,20 @@ def handler(event: Order, context: LambdaContext):
176184
) from exc
177185

178186
try:
187+
adapter = _retrieve_or_set_model_from_cache(model=model)
188+
179189
logger.debug("Parsing and validating event model; no envelope used")
180190
if isinstance(event, str):
181-
return model.model_validate_json(event)
191+
return adapter.validate_json(event)
192+
193+
return adapter.validate_python(event)
182194

183-
return model.model_validate(event)
184-
except AttributeError as exc:
195+
# Pydantic raises PydanticSchemaGenerationError when the model is not a Pydantic model
196+
# This is seen in the tests where we pass a non-Pydantic model type to the parser or
197+
# when we pass a data structure that does not match the model (trying to parse a true/false/etc into a model)
198+
except PydanticSchemaGenerationError as exc:
199+
raise InvalidModelTypeError(f"The event supplied is unable to be validated into {type(model)}") from exc
200+
except ValidationError as exc:
185201
raise InvalidModelTypeError(
186202
f"Error: {str(exc)}. Please ensure the Input model inherits from BaseModel,\n"
187203
"and your payload adheres to the specified Input model structure.\n"

aws_lambda_powertools/utilities/parser/pydantic.py

-9
This file was deleted.

aws_lambda_powertools/utilities/parser/types.py

+1
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@
1111
EventParserReturnType = TypeVar("EventParserReturnType")
1212
AnyInheritedModel = Union[Type[BaseModel], BaseModel]
1313
RawDictOrModel = Union[Dict[str, Any], AnyInheritedModel]
14+
T = TypeVar("T")
1415

1516
__all__ = ["Json", "Literal"]

docs/utilities/parser.md

+10-16
Original file line numberDiff line numberDiff line change
@@ -11,27 +11,13 @@ This utility provides data parsing and deep validation using [Pydantic](https://
1111
* Defines data in pure Python classes, then parse, validate and extract only what you want
1212
* Built-in envelopes to unwrap, extend, and validate popular event sources payloads
1313
* Enforces type hints at runtime with user-friendly errors
14-
* Support for Pydantic v1 and v2
14+
* Support for Pydantic v2
1515

1616
## Getting started
1717

1818
### Install
1919

20-
Powertools for AWS Lambda (Python) supports Pydantic v1 and v2. Each Pydantic version requires different dependencies before you can use Parser.
21-
22-
#### Using Pydantic v1
23-
24-
!!! info "This is not necessary if you're installing Powertools for AWS Lambda (Python) via [Lambda Layer/SAR](../index.md#lambda-layer){target="_blank"}"
25-
26-
Add `aws-lambda-powertools[parser]` as a dependency in your preferred tool: _e.g._, _requirements.txt_, _pyproject.toml_.
27-
28-
???+ warning
29-
This will increase the compressed package size by >10MB due to the Pydantic dependency.
30-
31-
To reduce the impact on the package size at the expense of 30%-50% of its performance [Pydantic can also be
32-
installed without binary files](https://pydantic-docs.helpmanual.io/install/#performance-vs-package-size-trade-off){target="_blank" rel="nofollow"}:
33-
34-
Pip example: `SKIP_CYTHON=1 pip install --no-binary pydantic aws-lambda-powertools[parser]`
20+
Powertools for AWS Lambda (Python) supports Pydantic v2.
3521

3622
#### Using Pydantic v2
3723

@@ -169,6 +155,14 @@ def my_function():
169155
}
170156
```
171157

158+
#### Primitive data model parsing
159+
160+
The parser allows you parse events into primitive data types, such as `dict` or classes that don't inherit from `BaseModel`. The following example shows you how to parse a [`Union`](https://docs.pydantic.dev/latest/api/standard_library_types/#union):
161+
162+
```python
163+
--8<-- "examples/parser/src/multiple_model_parsing.py"
164+
```
165+
172166
### Built-in models
173167

174168
Parser comes with the following built-in models:

examples/batch_processing/src/pydantic_dynamodb.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
EventType,
1010
process_partial_response,
1111
)
12-
from aws_lambda_powertools.utilities.parser import BaseModel, validator
12+
from aws_lambda_powertools.utilities.parser import BaseModel, field_validator
1313
from aws_lambda_powertools.utilities.parser.models import (
1414
DynamoDBStreamChangedRecordModel,
1515
DynamoDBStreamRecordModel,
@@ -26,7 +26,7 @@ class OrderDynamoDB(BaseModel):
2626

2727
# auto transform json string
2828
# so Pydantic can auto-initialize nested Order model
29-
@validator("Message", pre=True)
29+
@field_validator("Message", mode="before")
3030
def transform_message_to_dict(cls, value: Dict[Literal["S"], str]):
3131
return json.loads(value["S"])
3232

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from typing import Any, Literal, Union
2+
3+
from pydantic import BaseModel, Field
4+
5+
from aws_lambda_powertools.shared.types import Annotated
6+
from aws_lambda_powertools.utilities.parser import event_parser
7+
8+
9+
class Cat(BaseModel):
10+
animal: Literal["cat"]
11+
name: str
12+
meow: int
13+
14+
15+
class Dog(BaseModel):
16+
animal: Literal["dog"]
17+
name: str
18+
bark: int
19+
20+
21+
Animal = Annotated[
22+
Union[Cat, Dog],
23+
Field(discriminator="animal"),
24+
]
25+
26+
27+
@event_parser(model=Animal)
28+
def lambda_handler(event: Animal, _: Any) -> str:
29+
if isinstance(event, Cat):
30+
# we have a cat!
31+
return f"🐈: {event.name}"
32+
33+
return f"🐶: {event.name}"

tests/e2e/parser/__init__.py

Whitespace-only changes.

tests/e2e/parser/conftest.py

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import pytest
2+
3+
from tests.e2e.parser.infrastructure import ParserStack
4+
5+
6+
@pytest.fixture(autouse=True, scope="package")
7+
def infrastructure():
8+
"""Setup and teardown logic for E2E test infrastructure
9+
10+
Yields
11+
------
12+
Dict[str, str]
13+
CloudFormation Outputs from deployed infrastructure
14+
"""
15+
stack = ParserStack()
16+
try:
17+
yield stack.deploy()
18+
finally:
19+
stack.delete()

0 commit comments

Comments
 (0)