Skip to content

Commit

Permalink
feat(parser): add support for Pydantic v2 (#2733)
Browse files Browse the repository at this point in the history
* pydantic v2: initial tests

* pydantic v2: comment

* pydantic v2: new workflow

* pydantic v2: comment

* pydantic v2: mypy fix

* pydantic v2: fix v2 compability

* pydantic v2: fix last things

* pydantic v2: improving comments

* pydantic v2: addressing Heitor's feedback

* pydantic v2: creating pydantic v2 specific test

* pydantic v2: using fixture to clean the code

* pydanticv2: reverting Optional fields

* Removing the validators. Pydantic bug was fixed

Signed-off-by: Cavalcante Damascena <lcdama@b0be8355743f.ant.amazon.com>

* Adding pytest ignore messages for Pydantic v2

Signed-off-by: Cavalcante Damascena <lcdama@b0be8355743f.ant.amazon.com>

* Adding pytest ignore messages for Pydantic v2

Signed-off-by: Cavalcante Damascena <lcdama@b0be8355743f.ant.amazon.com>

* pydanticv2: removing duplicated workflow + disabling warning

* pydanticv2: adding documentation

* Adding cache to disable pydantic warnings

Signed-off-by: Cavalcante Damascena <lcdama@b0be8355743f.ant.amazon.com>

* Adjusting workflow

Signed-off-by: Cavalcante Damascena <lcdama@b0be8355743f.ant.amazon.com>

* Addressing Heitor's feedback

Signed-off-by: Cavalcante Damascena <lcdama@b0be8355743f.ant.amazon.com>

* Removed codecov upload

Signed-off-by: Cavalcante Damascena <lcdama@b0be8355743f.ant.amazon.com>

---------

Signed-off-by: Cavalcante Damascena <lcdama@b0be8355743f.ant.amazon.com>
Co-authored-by: Cavalcante Damascena <lcdama@b0be8355743f.ant.amazon.com>
  • Loading branch information
leandrodamascena and Cavalcante Damascena authored Jul 21, 2023
1 parent 1dd46b6 commit 021076e
Show file tree
Hide file tree
Showing 27 changed files with 299 additions and 106 deletions.
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

0 comments on commit 021076e

Please sign in to comment.