Skip to content

feat(parser): Allow primitive data types to be parsed using TypeAdapter #4502

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Jun 26, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions aws_lambda_powertools/utilities/parser/envelopes/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional, Type, TypeVar, Union

from pydantic import TypeAdapter

from aws_lambda_powertools.utilities.parser.types import Model

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -30,12 +32,14 @@ def _parse(data: Optional[Union[Dict[str, Any], Any]], model: Type[Model]) -> Un
logger.debug("Skipping parsing as event is None")
return data

adapter = TypeAdapter(model)

logger.debug("parsing event against model")
if isinstance(data, str):
logger.debug("parsing event as string")
return model.model_validate_json(data)
return adapter.validate_json(data)

return model.model_validate(data)
return adapter.validate_python(data)

@abstractmethod
def parse(self, data: Optional[Union[Dict[str, Any], Any]], model: Type[Model]):
Expand Down
26 changes: 17 additions & 9 deletions aws_lambda_powertools/utilities/parser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import typing
from typing import Any, Callable, Dict, Optional, Type, overload

from pydantic import TypeAdapter, ValidationError

from aws_lambda_powertools.middleware_factory import lambda_handler_decorator
from aws_lambda_powertools.utilities.parser.envelopes.base import Envelope
from aws_lambda_powertools.utilities.parser.exceptions import InvalidEnvelopeError, InvalidModelTypeError
Expand Down Expand Up @@ -93,13 +95,16 @@ def handler(event: Order, context: LambdaContext):
"or as the type hint of `event` in the handler that it wraps",
)

if envelope:
parsed_event = parse(event=event, model=model, envelope=envelope)
else:
parsed_event = parse(event=event, model=model)
try:
if envelope:
parsed_event = parse(event=event, model=model, envelope=envelope)
else:
parsed_event = parse(event=event, model=model)

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


@overload
Expand Down Expand Up @@ -176,12 +181,15 @@ def handler(event: Order, context: LambdaContext):
) from exc

try:
adapter = TypeAdapter(model)

logger.debug("Parsing and validating event model; no envelope used")
if isinstance(event, str):
return model.model_validate_json(event)
return adapter.validate_json(event)

return adapter.validate_python(event)

return model.model_validate(event)
except AttributeError as exc:
except Exception as exc:
raise InvalidModelTypeError(
f"Error: {str(exc)}. Please ensure the Input model inherits from BaseModel,\n"
"and your payload adheres to the specified Input model structure.\n"
Expand Down
40 changes: 36 additions & 4 deletions tests/functional/parser/test_parser.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import json
from typing import Dict, Union
from typing import Annotated, Any, Dict, Literal, Union

import pydantic
import pytest

from aws_lambda_powertools.utilities.parser import (
ValidationError,
event_parser,
exceptions,
)
Expand All @@ -18,7 +17,7 @@ def test_parser_unsupported_event(dummy_schema, invalid_value):
def handle_no_envelope(event: Dict, _: LambdaContext):
return event

with pytest.raises(ValidationError):
with pytest.raises(exceptions.InvalidModelTypeError):
handle_no_envelope(event=invalid_value, context=LambdaContext())


Expand Down Expand Up @@ -75,7 +74,7 @@ def validate_field(cls, value):
assert event_parsed.version == int(event_raw["version"])


@pytest.mark.parametrize("invalid_schema", [None, str, bool(), [], (), object])
@pytest.mark.parametrize("invalid_schema", [str, bool(), [], ()])
def test_parser_with_invalid_schema_type(dummy_event, invalid_schema):
@event_parser(model=invalid_schema)
def handle_no_envelope(event: Dict, _: LambdaContext):
Expand Down Expand Up @@ -118,3 +117,36 @@ def handler(evt: dummy_schema, _: LambdaContext):
assert evt.message == "hello world"

handler(dummy_event["payload"], LambdaContext())


@pytest.mark.parametrize(
"test_input,expected",
[
(
{"status": "succeeded", "name": "Clifford", "breed": "Labrador"},
"Successfully retrieved Labrador named Clifford",
),
({"status": "failed", "error": "oh some error"}, "Uh oh. Had a problem: oh some error"),
],
)
def test_parser_unions(test_input, expected):
class SuccessfulCallback(pydantic.BaseModel):
status: Literal["succeeded"]
name: str
breed: Literal["Newfoundland", "Labrador"]

class FailedCallback(pydantic.BaseModel):
status: Literal["failed"]
error: str

DogCallback = Annotated[Union[SuccessfulCallback, FailedCallback], pydantic.Field(discriminator="status")]

@event_parser(model=DogCallback)
def handler(event: test_input, _: Any) -> str:
if isinstance(event, FailedCallback):
return f"Uh oh. Had a problem: {event.error}"

return f"Successfully retrieved {event.breed} named {event.name}"

ret = handler(test_input, None)
assert ret == expected
Loading