Skip to content

feat(parser): add support for Pydantic v2 #2733

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 31 commits into from
Jul 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
8daf90c
pydantic v2: initial tests
leandrodamascena Jul 8, 2023
de53dd6
pydantic v2: comment
leandrodamascena Jul 8, 2023
6f8c52b
pydantic v2: new workflow
leandrodamascena Jul 8, 2023
82b166c
pydantic v2: comment
leandrodamascena Jul 8, 2023
7295798
pydantic v2: mypy fix
leandrodamascena Jul 8, 2023
201d877
pydantic v2: fix v2 compability
leandrodamascena Jul 8, 2023
bf6b31a
pydantic v2: fix last things
leandrodamascena Jul 10, 2023
ef98e88
pydantic v2: improving comments
leandrodamascena Jul 10, 2023
f39ea89
pydantic v2: addressing Heitor's feedback
leandrodamascena Jul 10, 2023
15fab06
pydantic v2: creating pydantic v2 specific test
leandrodamascena Jul 10, 2023
e5d6318
Merge branch 'develop' into poc/pydanticv2
leandrodamascena Jul 10, 2023
b0f5fb3
Merge branch 'develop' into poc/pydanticv2
leandrodamascena Jul 10, 2023
6f30f08
pydantic v2: using fixture to clean the code
leandrodamascena Jul 11, 2023
cec6630
Merge branch 'develop' into poc/pydanticv2
leandrodamascena Jul 12, 2023
3d5c9b2
pydanticv2: reverting Optional fields
leandrodamascena Jul 12, 2023
3ee041b
Merge branch 'develop' into poc/pydanticv2
leandrodamascena Jul 12, 2023
7279137
Merge branch 'develop' into poc/pydanticv2
leandrodamascena Jul 13, 2023
07e483d
Merge branch 'develop' into poc/pydanticv2
leandrodamascena Jul 13, 2023
e6abd65
Merge branch 'develop' into poc/pydanticv2
leandrodamascena Jul 17, 2023
ce15df0
Removing the validators. Pydantic bug was fixed
Jul 17, 2023
d4f8171
Merge branch 'develop' into poc/pydanticv2
leandrodamascena Jul 17, 2023
f73a222
Adding pytest ignore messages for Pydantic v2
Jul 17, 2023
1774a1c
Adding pytest ignore messages for Pydantic v2
Jul 17, 2023
e7c1c34
Merge branch 'develop' into poc/pydanticv2
leandrodamascena Jul 18, 2023
f1bb815
Merge branch 'develop' into poc/pydanticv2
leandrodamascena Jul 20, 2023
3a5d26f
pydanticv2: removing duplicated workflow + disabling warning
leandrodamascena Jul 20, 2023
53e4e98
pydanticv2: adding documentation
leandrodamascena Jul 21, 2023
49561b2
Adding cache to disable pydantic warnings
Jul 21, 2023
eef0dc1
Adjusting workflow
Jul 21, 2023
f8470f5
Addressing Heitor's feedback
Jul 21, 2023
fa298d2
Removed codecov upload
Jul 21, 2023
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
76 changes: 76 additions & 0 deletions .github/workflows/quality_check_pydanticv2.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
name: Code quality - Pydanticv2

# PROCESS
#
# 1. Install all dependencies and spin off containers for all supported Python versions
# 2. Run code formatters and linters (various checks) for code standard
# 3. Run static typing checker for potential bugs
# 4. Run entire test suite for regressions except end-to-end (unit, functional, performance)
# 5. Run static analysis (in addition to CodeQL) for common insecure code practices
# 6. Run complexity baseline to avoid error-prone bugs and keep maintenance lower
# 7. Collect and report on test coverage

# USAGE
#
# Always triggered on new PRs, PR changes and PR merge.


on:
pull_request:
paths:
- "aws_lambda_powertools/**"
- "tests/**"
- "pyproject.toml"
- "poetry.lock"
- "mypy.ini"
branches:
- develop
push:
paths:
- "aws_lambda_powertools/**"
- "tests/**"
- "pyproject.toml"
- "poetry.lock"
- "mypy.ini"
branches:
- develop

permissions:
contents: read

jobs:
quality_check:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: ["3.7", "3.8", "3.9", "3.10"]
env:
PYTHON: "${{ matrix.python-version }}"
permissions:
contents: read # checkout code only
steps:
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- name: Install poetry
run: pipx install poetry
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@bd6b4b6205c4dbad673328db7b31b7fab9e241c0 # v4.6.1
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Removing dev dependencies locked to Pydantic v1
run: poetry remove cfn-lint
- name: Replacing Pydantic v1 with v2 > 2.0.3
run: poetry add "pydantic=^2.0.3"
- name: Install dependencies
run: make dev
- name: Formatting and Linting
run: make lint
- name: Static type checking
run: make mypy
- name: Test with pytest
run: make test
- name: Security baseline
run: make security-baseline
- name: Complexity baseline
run: make complexity-baseline
23 changes: 19 additions & 4 deletions aws_lambda_powertools/utilities/batch/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,11 @@ def _to_batch_type(self, record: dict, event_type: EventType) -> EventSourceData

def _to_batch_type(self, record: dict, event_type: EventType, model: Optional["BatchTypeModels"] = None):
if model is not None:
# If a model is provided, we assume Pydantic is installed and we need to disable v2 warnings
from aws_lambda_powertools.utilities.parser.compat import disable_pydantic_v2_warning

disable_pydantic_v2_warning()

return model.parse_obj(record)
return self._DATA_CLASS_MAPPING[event_type](record)

Expand Down Expand Up @@ -500,8 +505,13 @@ def _process_record(self, record: dict) -> Union[SuccessResponse, FailureRespons
# we need to handle that exception differently.
# We check for a public attr in validation errors coming from Pydantic exceptions (subclass or not)
# and we compare if it's coming from the same model that trigger the exception in the first place
model = getattr(exc, "model", None)
if model == self.model:

# Pydantic v1 raises a ValidationError with ErrorWrappers and store the model instance in a class variable.
# Pydantic v2 simplifies this by adding a title variable to store the model name directly.
model = getattr(exc, "model", None) or getattr(exc, "title", None)
model_name = getattr(self.model, "__name__", None)

if model == self.model or model == model_name:
return self._register_model_validation_error_record(record)

return self.failure_handler(record=data, exception=sys.exc_info())
Expand Down Expand Up @@ -644,8 +654,13 @@ async def _async_process_record(self, record: dict) -> Union[SuccessResponse, Fa
# we need to handle that exception differently.
# We check for a public attr in validation errors coming from Pydantic exceptions (subclass or not)
# and we compare if it's coming from the same model that trigger the exception in the first place
model = getattr(exc, "model", None)
if model == self.model:

# Pydantic v1 raises a ValidationError with ErrorWrappers and store the model instance in a class variable.
# Pydantic v2 simplifies this by adding a title variable to store the model name directly.
model = getattr(exc, "model", None) or getattr(exc, "title", None)
model_name = getattr(self.model, "__name__", None)

if model == self.model or model == model_name:
return self._register_model_validation_error_record(record)

return self.failure_handler(record=data, exception=sys.exc_info())
34 changes: 34 additions & 0 deletions aws_lambda_powertools/utilities/parser/compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import functools


@functools.lru_cache(maxsize=None)
def disable_pydantic_v2_warning():
"""
Disables the Pydantic version 2 warning by filtering out the related warnings.

This function checks the version of Pydantic currently installed and if it is version 2,
it filters out the PydanticDeprecationWarning and PydanticDeprecatedSince20 warnings
to suppress them.

Since we only need to run the code once, we are using lru_cache to improve performance.

Note: This function assumes that Pydantic is installed.

Usage:
disable_pydantic_v2_warning()
"""
try:
from pydantic import __version__

version = __version__.split(".")

if int(version[0]) == 2:
import warnings

from pydantic import PydanticDeprecatedSince20, PydanticDeprecationWarning

warnings.filterwarnings("ignore", category=PydanticDeprecationWarning)
warnings.filterwarnings("ignore", category=PydanticDeprecatedSince20)

except ImportError:
pass
3 changes: 3 additions & 0 deletions aws_lambda_powertools/utilities/parser/envelopes/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional, Type, TypeVar, Union

from aws_lambda_powertools.utilities.parser.compat import disable_pydantic_v2_warning
from aws_lambda_powertools.utilities.parser.types import Model

logger = logging.getLogger(__name__)
Expand All @@ -26,6 +27,8 @@ def _parse(data: Optional[Union[Dict[str, Any], Any]], model: Type[Model]) -> Un
Any
Parsed data
"""
disable_pydantic_v2_warning()

if data is None:
logger.debug("Skipping parsing as event is None")
return data
Expand Down
4 changes: 4 additions & 0 deletions aws_lambda_powertools/utilities/parser/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
from aws_lambda_powertools.utilities.parser.compat import disable_pydantic_v2_warning

disable_pydantic_v2_warning()

from .alb import AlbModel, AlbRequestContext, AlbRequestContextData
from .apigw import (
APIGatewayEventAuthorizer,
Expand Down
72 changes: 36 additions & 36 deletions aws_lambda_powertools/utilities/parser/models/apigw.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,74 +21,74 @@ class ApiGatewayUserCert(BaseModel):


class APIGatewayEventIdentity(BaseModel):
accessKey: Optional[str]
accountId: Optional[str]
apiKey: Optional[str]
apiKeyId: Optional[str]
caller: Optional[str]
cognitoAuthenticationProvider: Optional[str]
cognitoAuthenticationType: Optional[str]
cognitoIdentityId: Optional[str]
cognitoIdentityPoolId: Optional[str]
principalOrgId: Optional[str]
accessKey: Optional[str] = None
accountId: Optional[str] = None
apiKey: Optional[str] = None
apiKeyId: Optional[str] = None
caller: Optional[str] = None
cognitoAuthenticationProvider: Optional[str] = None
cognitoAuthenticationType: Optional[str] = None
cognitoIdentityId: Optional[str] = None
cognitoIdentityPoolId: Optional[str] = None
principalOrgId: Optional[str] = None
# see #1562, temp workaround until API Gateway fixes it the Test button payload
# removing it will not be considered a regression in the future
sourceIp: Union[IPvAnyNetwork, Literal["test-invoke-source-ip"]]
user: Optional[str]
userAgent: Optional[str]
userArn: Optional[str]
clientCert: Optional[ApiGatewayUserCert]
user: Optional[str] = None
userAgent: Optional[str] = None
userArn: Optional[str] = None
clientCert: Optional[ApiGatewayUserCert] = None


class APIGatewayEventAuthorizer(BaseModel):
claims: Optional[Dict[str, Any]]
scopes: Optional[List[str]]
claims: Optional[Dict[str, Any]] = None
scopes: Optional[List[str]] = None


class APIGatewayEventRequestContext(BaseModel):
accountId: str
apiId: str
authorizer: Optional[APIGatewayEventAuthorizer]
authorizer: Optional[APIGatewayEventAuthorizer] = None
stage: str
protocol: str
identity: APIGatewayEventIdentity
requestId: str
requestTime: str
requestTimeEpoch: datetime
resourceId: Optional[str]
resourceId: Optional[str] = None
resourcePath: str
domainName: Optional[str]
domainPrefix: Optional[str]
extendedRequestId: Optional[str]
domainName: Optional[str] = None
domainPrefix: Optional[str] = None
extendedRequestId: Optional[str] = None
httpMethod: Literal["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
path: str
connectedAt: Optional[datetime]
connectionId: Optional[str]
eventType: Optional[Literal["CONNECT", "MESSAGE", "DISCONNECT"]]
messageDirection: Optional[str]
messageId: Optional[str]
routeKey: Optional[str]
operationName: Optional[str]
connectedAt: Optional[datetime] = None
connectionId: Optional[str] = None
eventType: Optional[Literal["CONNECT", "MESSAGE", "DISCONNECT"]] = None
messageDirection: Optional[str] = None
messageId: Optional[str] = None
routeKey: Optional[str] = None
operationName: Optional[str] = None

@root_validator(allow_reuse=True)
@root_validator(allow_reuse=True, skip_on_failure=True)
def check_message_id(cls, values):
message_id, event_type = values.get("messageId"), values.get("eventType")
if message_id is not None and event_type != "MESSAGE":
raise TypeError("messageId is available only when the `eventType` is `MESSAGE`")
raise ValueError("messageId is available only when the `eventType` is `MESSAGE`")
return values


class APIGatewayProxyEventModel(BaseModel):
version: Optional[str]
version: Optional[str] = None
resource: str
path: str
httpMethod: Literal["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
headers: Dict[str, str]
multiValueHeaders: Dict[str, List[str]]
queryStringParameters: Optional[Dict[str, str]]
multiValueQueryStringParameters: Optional[Dict[str, List[str]]]
queryStringParameters: Optional[Dict[str, str]] = None
multiValueQueryStringParameters: Optional[Dict[str, List[str]]] = None
requestContext: APIGatewayEventRequestContext
pathParameters: Optional[Dict[str, str]]
stageVariables: Optional[Dict[str, str]]
pathParameters: Optional[Dict[str, str]] = None
stageVariables: Optional[Dict[str, str]] = None
isBase64Encoded: bool
body: Optional[Union[str, Type[BaseModel]]]
body: Optional[Union[str, Type[BaseModel]]] = None
30 changes: 15 additions & 15 deletions aws_lambda_powertools/utilities/parser/models/apigwv2.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ class RequestContextV2AuthorizerIamCognito(BaseModel):


class RequestContextV2AuthorizerIam(BaseModel):
accessKey: Optional[str]
accountId: Optional[str]
callerId: Optional[str]
principalOrgId: Optional[str]
userArn: Optional[str]
userId: Optional[str]
cognitoIdentity: Optional[RequestContextV2AuthorizerIamCognito]
accessKey: Optional[str] = None
accountId: Optional[str] = None
callerId: Optional[str] = None
principalOrgId: Optional[str] = None
userArn: Optional[str] = None
userId: Optional[str] = None
cognitoIdentity: Optional[RequestContextV2AuthorizerIamCognito] = None


class RequestContextV2AuthorizerJwt(BaseModel):
Expand All @@ -29,8 +29,8 @@ class RequestContextV2AuthorizerJwt(BaseModel):


class RequestContextV2Authorizer(BaseModel):
jwt: Optional[RequestContextV2AuthorizerJwt]
iam: Optional[RequestContextV2AuthorizerIam]
jwt: Optional[RequestContextV2AuthorizerJwt] = None
iam: Optional[RequestContextV2AuthorizerIam] = None
lambda_value: Optional[Dict[str, Any]] = Field(None, alias="lambda")


Expand All @@ -45,7 +45,7 @@ class RequestContextV2Http(BaseModel):
class RequestContextV2(BaseModel):
accountId: str
apiId: str
authorizer: Optional[RequestContextV2Authorizer]
authorizer: Optional[RequestContextV2Authorizer] = None
domainName: str
domainPrefix: str
requestId: str
Expand All @@ -61,11 +61,11 @@ class APIGatewayProxyEventV2Model(BaseModel):
routeKey: str
rawPath: str
rawQueryString: str
cookies: Optional[List[str]]
cookies: Optional[List[str]] = None
headers: Dict[str, str]
queryStringParameters: Optional[Dict[str, str]]
pathParameters: Optional[Dict[str, str]]
stageVariables: Optional[Dict[str, str]]
queryStringParameters: Optional[Dict[str, str]] = None
pathParameters: Optional[Dict[str, str]] = None
stageVariables: Optional[Dict[str, str]] = None
requestContext: RequestContextV2
body: Optional[Union[str, Type[BaseModel]]]
body: Optional[Union[str, Type[BaseModel]]] = None
isBase64Encoded: bool
8 changes: 4 additions & 4 deletions aws_lambda_powertools/utilities/parser/models/dynamodb.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@


class DynamoDBStreamChangedRecordModel(BaseModel):
ApproximateCreationDateTime: Optional[date]
ApproximateCreationDateTime: Optional[date] = None
Keys: Dict[str, Dict[str, Any]]
NewImage: Optional[Union[Dict[str, Any], Type[BaseModel], BaseModel]]
OldImage: Optional[Union[Dict[str, Any], Type[BaseModel], BaseModel]]
NewImage: Optional[Union[Dict[str, Any], Type[BaseModel], BaseModel]] = None
OldImage: Optional[Union[Dict[str, Any], Type[BaseModel], BaseModel]] = None
SequenceNumber: str
SizeBytes: int
StreamViewType: Literal["NEW_AND_OLD_IMAGES", "KEYS_ONLY", "NEW_IMAGE", "OLD_IMAGE"]
Expand Down Expand Up @@ -40,7 +40,7 @@ class DynamoDBStreamRecordModel(BaseModel):
awsRegion: str
eventSourceARN: str
dynamodb: DynamoDBStreamChangedRecordModel
userIdentity: Optional[UserIdentity]
userIdentity: Optional[UserIdentity] = None


class DynamoDBStreamModel(BaseModel):
Expand Down
4 changes: 2 additions & 2 deletions aws_lambda_powertools/utilities/parser/models/kafka.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ class KafkaRecordModel(BaseModel):
value: Union[str, Type[BaseModel]]
headers: List[Dict[str, bytes]]

# validators
_decode_key = validator("key", allow_reuse=True)(base64_decode)
# Added type ignore to keep compatibility between Pydantic v1 and v2
_decode_key = validator("key", allow_reuse=True)(base64_decode) # type: ignore[type-var, unused-ignore]

@validator("value", pre=True, allow_reuse=True)
def data_base64_decode(cls, value):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class KinesisFirehoseRecord(BaseModel):
data: Union[bytes, Type[BaseModel]] # base64 encoded str is parsed into bytes
recordId: str
approximateArrivalTimestamp: PositiveInt
kinesisRecordMetadata: Optional[KinesisFirehoseRecordMetadata]
kinesisRecordMetadata: Optional[KinesisFirehoseRecordMetadata] = None

@validator("data", pre=True, allow_reuse=True)
def data_base64_decode(cls, value):
Expand All @@ -28,5 +28,5 @@ class KinesisFirehoseModel(BaseModel):
invocationId: str
deliveryStreamArn: str
region: str
sourceKinesisStreamArn: Optional[str]
sourceKinesisStreamArn: Optional[str] = None
records: List[KinesisFirehoseRecord]
Loading