Skip to content

Commit

Permalink
Merge pull request #166 from kolonialno/pydantic-v2-attempt-3
Browse files Browse the repository at this point in the history
Support both Pydantic v1 and v2
  • Loading branch information
MasterKale committed Aug 15, 2023
2 parents d8e78f0 + 4bbbbbd commit 731ae63
Show file tree
Hide file tree
Showing 6 changed files with 104 additions and 39 deletions.
4 changes: 3 additions & 1 deletion .github/workflows/build_and_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.8', '3.9', '3.10']
python-version: ['3.8', '3.9', '3.10', '3.11']
pydantic-version: ['>=1.0,<2.0', '>=2.0,<3.0']

steps:
- uses: actions/checkout@v3
Expand All @@ -27,6 +28,7 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install 'pydantic${{ matrix.pydantic-version }}'
- name: Test with unittest
run: |
python -m unittest
Expand Down
10 changes: 6 additions & 4 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
annotated-types==0.5.0
asn1crypto==1.4.0
black==21.9b0
cbor2==5.4.2.post1
cffi==1.15.0
click==8.0.3
cryptography==41.0.1
mccabe==0.6.1
mypy==0.910
mypy-extensions==0.4.3
mypy==1.4.1
mypy-extensions==1.0.0
pathspec==0.9.0
platformdirs==2.4.0
pycodestyle==2.8.0
pycparser==2.20
pydantic==1.10.11
pydantic==2.1.1
pydantic_core==2.4.0
pyflakes==2.4.0
pyOpenSSL==23.2.0
regex==2021.10.8
six==1.16.0
toml==0.10.2
tomli==1.2.1
typing-extensions==4.2.0
typing_extensions==4.7.1
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def find_version(*file_paths):
'asn1crypto>=1.4.0',
'cbor2>=5.4.2.post1',
'cryptography>=41.0.1',
'pydantic>=1.10.11,<2.0a0',
'pydantic>=1.10.11',
'pyOpenSSL>=23.2.0',
]
)
5 changes: 2 additions & 3 deletions tests/test_verify_registration_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from unittest import TestCase

import cbor2
from pydantic import ValidationError
from webauthn.helpers import base64url_to_bytes, bytes_to_base64url
from webauthn.helpers.exceptions import InvalidRegistrationResponse
from webauthn.helpers.known_root_certs import globalsign_r2
Expand Down Expand Up @@ -87,9 +88,7 @@ def test_raises_exception_on_unsupported_attestation_type(self) -> None:
rp_id = "localhost"
expected_origin = "http://localhost:5000"

with self.assertRaisesRegex(
Exception, "value is not a valid enumeration member"
):
with self.assertRaises(ValidationError):
verify_registration_response(
credential=credential,
expected_challenge=challenge,
Expand Down
2 changes: 1 addition & 1 deletion webauthn/helpers/bytes_to_base64url.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ def bytes_to_base64url(val: bytes) -> str:
"""
Base64URL-encode the provided bytes
"""
return urlsafe_b64encode(val).decode("utf-8").replace("=", "")
return urlsafe_b64encode(val).decode("utf-8").rstrip("=")
120 changes: 91 additions & 29 deletions webauthn/helpers/structs.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,48 @@
from enum import Enum
from typing import List, Literal, Optional
from typing import Callable, List, Literal, Optional, Any, Dict

from pydantic import BaseModel, validator
from pydantic.fields import ModelField

try:
from pydantic import ( # type: ignore[attr-defined]
BaseModel,
field_validator,
ConfigDict,
FieldValidationInfo,
model_serializer,
)

PYDANTIC_V2 = True
except ImportError:
from pydantic import BaseModel, validator
from pydantic.fields import ModelField # type: ignore[attr-defined]

PYDANTIC_V2 = False

from .base64url_to_bytes import base64url_to_bytes
from .bytes_to_base64url import bytes_to_base64url
from .cose import COSEAlgorithmIdentifier
from .json_loads_base64url_to_bytes import json_loads_base64url_to_bytes
from .snake_case_to_camel_case import snake_case_to_camel_case


def _to_bytes(v: Any) -> Any:
if isinstance(v, bytes):
"""
Return raw bytes from subclasses as well
`strict_bytes_validator()` performs a similar check to this, but it passes through the
subclass as-is and Pydantic then rejects it. Passing the subclass into `bytes()` lets us
return `bytes` and make Pydantic happy.
"""
return bytes(v)
elif isinstance(v, memoryview):
return v.tobytes()
else:
# Allow Pydantic to validate the field as usual to support the full range of bytes-like
# values
return v


class WebAuthnBaseModel(BaseModel):
"""
A subclass of Pydantic's BaseModel that includes convenient defaults
Expand All @@ -24,37 +57,66 @@ class WebAuthnBaseModel(BaseModel):
- Converts camelCase properties to snake_case
"""

class Config:
json_encoders = {bytes: bytes_to_base64url}
json_loads = json_loads_base64url_to_bytes
alias_generator = snake_case_to_camel_case
allow_population_by_field_name = True
if PYDANTIC_V2:
model_config = ConfigDict( # type: ignore[typeddict-unknown-key]
alias_generator=snake_case_to_camel_case,
populate_by_name=True,
ser_json_bytes="base64",
)

@validator("*", pre=True, allow_reuse=True)
def _validate_bytes_fields(cls, v, field: ModelField):
"""
Allow for Pydantic models to define fields as `bytes`, but allow consuming projects to
specify bytes-adjacent values (bytes subclasses, memoryviews, etc...) that otherwise
function like `bytes`. Keeps the library Pythonic.
"""
if field.type_ != bytes:
return v
@field_validator("*", mode="before")
def _pydantic_v2_validate_bytes_fields(
cls, v: Any, info: FieldValidationInfo
) -> Any:
field = cls.model_fields[info.field_name] # type: ignore[attr-defined]

if field.annotation != bytes:
return v

if isinstance(v, bytes):
if isinstance(v, str):
# NOTE:
# Ideally we should only do this when info.mode == "json", but
# that does not work when using the deprecated parse_raw method
return base64url_to_bytes(v)

return _to_bytes(v)

@model_serializer(mode="wrap", when_used="json")
def _pydantic_v2_serialize_bytes_fields(
self, serializer: Callable[..., Dict[str, Any]]
) -> Dict[str, Any]:
"""
Remove trailing "=" from bytes fields serialized as base64 encoded strings.
"""
Return raw bytes from subclasses as well

`strict_bytes_validator()` performs a similar check to this, but it passes through the
subclass as-is and Pydantic then rejects it. Passing the subclass into `bytes()` lets us
return `bytes` and make Pydantic happy.
serialized = serializer(self)

for name, field_info in self.model_fields.items(): # type: ignore[attr-defined]
value = serialized.get(name)
if field_info.annotation is bytes and isinstance(value, str):
serialized[name] = value.rstrip("=")

return serialized

else:

class Config:
json_encoders = {bytes: bytes_to_base64url}
json_loads = json_loads_base64url_to_bytes
alias_generator = snake_case_to_camel_case
allow_population_by_field_name = True

@validator("*", pre=True, allow_reuse=True) # type: ignore[type-var]
def _pydantic_v1_validate_bytes_fields(cls, v: Any, field: ModelField) -> Any:
"""
return bytes(v)
elif isinstance(v, memoryview):
return v.tobytes()
else:
# Allow Pydantic to validate the field as usual to support the full range of bytes-like
# values
return v
Allow for Pydantic models to define fields as `bytes`, but allow consuming projects to
specify bytes-adjacent values (bytes subclasses, memoryviews, etc...) that otherwise
function like `bytes`. Keeps the library Pythonic.
"""
if field.type_ != bytes:
return v

return _to_bytes(v)


################
Expand Down

0 comments on commit 731ae63

Please sign in to comment.