diff --git a/README.md b/README.md index edb706b..0f22f16 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ - [Pydantic Validation](#pydantic-validation) - [Updating Field Values](#updating-field-values) - [Validation Errors](#validation-errors) + - [Accessing the Validated Pydantic Instance](#accessing-the-validated-pydantic-instance) - [Existing Models](#existing-models) - [Nested Models](#nested-models) - [Manual Serializer Configuration](#manual-serializer-configuration) @@ -215,6 +216,26 @@ my_serializer.is_valid() # this will raise pydantic.ValidationError > 2. Calling `.is_valid()` will always raise `pydantic.ValidationError` if the data > is invalid, even without setting `.is_valid(raise_exception=True)`. +### Accessing the Validated Pydantic Instance + +When `validate_pydantic` is enabled and `.is_valid()` has been called successfully, +the generated serializer exposes the fully validated Pydantic model instance via the +`pydantic_instance` property: + +```python +serializer = MyModel.drf_serializer(data={"name": "Van", "addresses": []}) +serializer.is_valid(raise_exception=True) +print(serializer.pydantic_instance) # MyModel(name='Van', addresses=[]) +``` + +This lets you work directly with your original Pydantic model (including any +mutations applied in validators) instead of DRF’s `validated_data` dictionary. + +> [!WARNING] +> +> - Accessing `pydantic_instance` before calling `.is_valid()` will raise an error. +> - If `validate_pydantic` is disabled, accessing it will also raise an error. + ## Existing Models If you have an existing code base and want to add the `drf_serializer` diff --git a/src/drf_pydantic/__init__.py b/src/drf_pydantic/__init__.py index 389fa1b..45adbfe 100644 --- a/src/drf_pydantic/__init__.py +++ b/src/drf_pydantic/__init__.py @@ -2,7 +2,7 @@ "BaseModel", "DrfPydanticSerializer", ] -__version__ = "2.8.0" +__version__ = "2.9.0" from drf_pydantic.base_model import BaseModel from drf_pydantic.base_serializer import DrfPydanticSerializer diff --git a/src/drf_pydantic/base_model.py b/src/drf_pydantic/base_model.py index da5c045..24804d8 100644 --- a/src/drf_pydantic/base_model.py +++ b/src/drf_pydantic/base_model.py @@ -1,7 +1,7 @@ import inspect import warnings -from typing import Any, ClassVar, Optional +from typing import Any, ClassVar, Dict, Optional, Tuple, Type, cast import pydantic @@ -12,7 +12,7 @@ PydanticGenericMetadata, # type: ignore ) from rest_framework import serializers # type: ignore -from typing_extensions import dataclass_transform +from typing_extensions import Self, dataclass_transform from drf_pydantic.base_serializer import DrfPydanticSerializer from drf_pydantic.config import DrfConfigDict @@ -25,8 +25,8 @@ class ModelMetaclass(PydanticModelMetaclass, type): def __new__( mcs, cls_name: str, - bases: tuple[type[Any], ...], - namespace: dict[str, Any], + bases: Tuple[Type[Any], ...], + namespace: Dict[str, Any], __pydantic_generic_metadata__: Optional[PydanticGenericMetadata] = None, __pydantic_reset_parent_namespace__: bool = True, _create_model_module: Optional[str] = None, @@ -80,6 +80,7 @@ def __new__( ), UserWarning, ) + drf_serializer = cast(Type[DrfPydanticSerializer[Any]], drf_serializer) if getattr(drf_serializer, "_pydantic_model", cls) is not cls: warnings.warn( ( @@ -105,5 +106,5 @@ def __new__( class BaseModel(pydantic.BaseModel, metaclass=ModelMetaclass): # Populated by the metaclass or manually set by the user - drf_serializer: ClassVar[type[DrfPydanticSerializer]] + drf_serializer: ClassVar[Type[DrfPydanticSerializer[Self]]] drf_config: ClassVar[DrfConfigDict] diff --git a/src/drf_pydantic/base_serializer.py b/src/drf_pydantic/base_serializer.py index e1265a3..1c87048 100644 --- a/src/drf_pydantic/base_serializer.py +++ b/src/drf_pydantic/base_serializer.py @@ -1,6 +1,7 @@ import json +import warnings -from typing import Any, ClassVar, Type, TypeVar +from typing import Any, ClassVar, Dict, Generic, List, Optional, Type, TypeVar import pydantic @@ -9,15 +10,30 @@ from drf_pydantic.config import DrfConfigDict -T = TypeVar("T", bound=dict[str, Any]) +T = TypeVar("T", bound=Dict[str, Any]) +P = TypeVar("P", bound=pydantic.BaseModel) -class DrfPydanticSerializer(serializers.Serializer): - _pydantic_model: ClassVar[Type[pydantic.BaseModel]] +class DrfPydanticSerializer(serializers.Serializer, Generic[P]): + _pydantic_model: Type[P] _drf_config: ClassVar[DrfConfigDict] def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) # type: ignore + self.__pydantic_instance: Optional[P] = None + + @property + def pydantic_instance(self) -> P: + if not self._drf_config.get("validate_pydantic"): + raise AssertionError( + "You must enable pydantic validation with `validate_pydantic` " + "before accessing `.pydantic_instance`." + ) + if self.__pydantic_instance is None: + raise AssertionError( + "You must call `.is_valid()` before accessing `.pydantic_instance`." + ) + return self.__pydantic_instance def validate(self, attrs: T) -> T: return_value = super().validate(attrs) # type: ignore @@ -31,8 +47,8 @@ def validate(self, attrs: T) -> T: raise exc assert self._drf_config.get("validation_error") == "drf" - field_errors: dict[str, list[str]] = {} - non_field_errors: list[str] = [] + field_errors: Dict[str, List[str]] = {} + non_field_errors: List[str] = [] for error in exc.errors(): try: message = json.dumps( @@ -42,7 +58,7 @@ def validate(self, attrs: T) -> T: "type": error["type"], } ) - except: # noqa pragma: no cover + except Exception: # pragma: no cover message = f"{error['msg']} (type={error['type']})" if ( len(error["loc"]) == 0 @@ -72,6 +88,12 @@ def validate(self, attrs: T) -> T: pydantic_value = pydantic_value.model_dump() return_value[key] = pydantic_value except AttributeError: # pragma: no cover + warnings.warn( + f"Failed to set attribute `{key}` when validating instance " + f"of type `{self._pydantic_model.__name__}`" + ) continue + self.__pydantic_instance = validated_pydantic_model + return return_value diff --git a/src/drf_pydantic/parse.py b/src/drf_pydantic/parse.py index 66d627b..a124b1f 100644 --- a/src/drf_pydantic/parse.py +++ b/src/drf_pydantic/parse.py @@ -3,10 +3,23 @@ import enum import inspect import re -import typing import uuid import warnings +from typing import ( + Any, + Dict, + List, + Literal, + Optional, + Type, + TypeVar, + Union, + cast, + get_args, + get_origin, +) + import annotated_types import pydantic import pydantic.fields @@ -27,12 +40,12 @@ # Cache Serializer classes to ensure that there is a one-to-one relationship # between pydantic models and DRF Serializer classes # Example: reuse Serializer for nested models -SERIALIZER_REGISTRY: dict[type, type[DrfPydanticSerializer]] = {} +SERIALIZER_REGISTRY: Dict[type, Type[DrfPydanticSerializer[Any]]] = {} # https://pydantic-docs.helpmanual.io/usage/types # https://www.django-rest-framework.org/api-guide/fields -# Maps python types supported by padantic to DRF serializer Fields -FIELD_MAP: dict[type, type[serializers.Field]] = { +# Maps python types supported by pydantic to DRF serializer Fields +FIELD_MAP: Dict[type, Type[serializers.Field]] = { # Boolean fields bool: serializers.BooleanField, # String fields @@ -58,11 +71,13 @@ dict: serializers.DictField, } +P = TypeVar("P", bound=pydantic.BaseModel) + def create_serializer_from_model( - pydantic_model: typing.Type[pydantic.BaseModel], - drf_config: typing.Optional[DrfConfigDict] = None, -) -> type[DrfPydanticSerializer]: + pydantic_model: Type[P], + drf_config: Optional[DrfConfigDict] = None, +) -> Type[DrfPydanticSerializer[P]]: """ Create DRF Serializer from a pydantic model. @@ -84,8 +99,8 @@ def create_serializer_from_model( drf_config = drf_config or getattr(pydantic_model, "drf_config") assert drf_config is not None - errors: dict[str, str] = {} - fields: dict[str, serializers.Field] = {} + errors: Dict[str, str] = {} + fields: Dict[str, serializers.Field] = {} for field_name, field in pydantic_model.model_fields.items(): try: fields[field_name] = _convert_field(field, drf_config=drf_config) @@ -109,16 +124,19 @@ def create_serializer_from_model( ) ) assert len(fields) == len(pydantic_model.model_fields) - SERIALIZER_REGISTRY[pydantic_model] = type( - f"{pydantic_model.__name__}Serializer", - (DrfPydanticSerializer,), - { - "_pydantic_model": pydantic_model, - "_drf_config": drf_config, - **fields, - }, + SERIALIZER_REGISTRY[pydantic_model] = cast( + Type[DrfPydanticSerializer[P]], + type( + f"{pydantic_model.__name__}Serializer", + (DrfPydanticSerializer,), + { + "_pydantic_model": pydantic_model, + "_drf_config": drf_config, + **fields, + }, + ), ) - return SERIALIZER_REGISTRY[pydantic_model] + return cast(Type[DrfPydanticSerializer[P]], SERIALIZER_REGISTRY[pydantic_model]) def _convert_field( @@ -143,7 +161,7 @@ def _convert_field( """ # Check if DRF field was explicitly set - manual_drf_fields: list[serializers.Field] = [] + manual_drf_fields: List[serializers.Field] = [] for item in field.metadata: if isinstance(item, serializers.Field): manual_drf_fields.append(item) @@ -156,7 +174,7 @@ def _convert_field( ) assert field.annotation is not None - drf_field_kwargs: dict[str, typing.Any] = { + drf_field_kwargs: Dict[str, Any] = { "required": field.is_required(), } _default_value = field.get_default(call_default_factory=True) @@ -276,10 +294,10 @@ def _convert_field( def _convert_type( # noqa: PLR0911 - type_: typing.Union[typing.Type[typing.Any], TypeAliasType], + type_: Union[Type[Any], TypeAliasType], drf_config: DrfConfigDict, - field: typing.Optional[pydantic.fields.FieldInfo] = None, - **kwargs: typing.Any, + field: Optional[pydantic.fields.FieldInfo] = None, + **kwargs: Any, ) -> serializers.Field: """ Convert type or type alias to DRF serializer Field. @@ -352,7 +370,7 @@ def _convert_type( # noqa: PLR0911 isinstance(item, pydantic.StringConstraints) and item.pattern is not None for item in field.metadata ): - regex_patterns: list[typing.Union[str, re.Pattern[str]]] = [] + regex_patterns: List[Union[str, re.Pattern[str]]] = [] for item in field.metadata: if ( isinstance(item, pydantic.StringConstraints) @@ -368,7 +386,7 @@ def _convert_type( # noqa: PLR0911 # Enum elif issubclass(type_, enum.Enum): return serializers.ChoiceField( - choices=[item.value for item in type_], **kwargs + choices=[(item.value, item.value) for item in type_], **kwargs ) # Known mapped scalar field for key in getattr(type_, "__mro__", []): @@ -379,30 +397,29 @@ def _convert_type( # noqa: PLR0911 raise FieldConversionError(f"{type_.__name__} is not a supported scalar type.") # Composite field - if type_.__origin__ is list: + type_origin = get_origin(type_) or type_ + type_args = get_args(type_) + if type_origin is list: # Enforced by pydantic, check just in case - assert len(type_.__args__) == 1 + assert len(type_args) == 1 return serializers.ListField( - child=_convert_type(type_.__args__[0], drf_config=drf_config), + child=_convert_type(type_args[0], drf_config=drf_config), allow_empty=True, **kwargs, ) - elif type_.__origin__ is tuple: + elif type_origin is tuple: if ( - len(type_.__args__) == 2 - and (is_scalar(type_.__args__[0]) and type_.__args__[1] is Ellipsis) - ) or (type_.__args__[0] is Ellipsis and is_scalar(type_.__args__[1])): + len(type_args) == 2 + and (is_scalar(type_args[0]) and type_args[1] is Ellipsis) + ) or (type_args[0] is Ellipsis and is_scalar(type_args[1])): return serializers.ListField( - child=_convert_type(type_.__args__[0], drf_config=drf_config), + child=_convert_type(type_args[0], drf_config=drf_config), allow_empty=True, **kwargs, ) - elif ( - all(is_scalar(arg) for arg in type_.__args__) - and len(set(type_.__args__)) == 1 - ): + elif all(is_scalar(arg) for arg in type_args) and len(set(type_args)) == 1: return serializers.ListField( - child=_convert_type(type_.__args__[0], drf_config=drf_config), + child=_convert_type(type_args[0], drf_config=drf_config), allow_empty=True, **kwargs, ) @@ -413,21 +430,21 @@ def _convert_type( # noqa: PLR0911 f"(a) single scalar and single Ellipsis (e.g., tuple[int, ...]) or " "(b) multiple scalars of the same type (e.g., tuple[int, int])." ) - elif type_.__origin__ is dict: + elif type_origin is dict: # Enforced by pydantic, check just in case - assert len(type_.__args__) == 2 - if type_.__args__[0] is not str: + assert len(type_args) == 2 + if type_args[0] is not str: raise FieldConversionError( f"{type_} is not a supported dict type. " f"Dict annotation must look like dict[str, ]." ) return serializers.DictField( - child=_convert_type(type_.__args__[1], drf_config=drf_config), + child=_convert_type(type_args[1], drf_config=drf_config), allow_empty=True, **kwargs, ) - elif type_.__origin__ is typing.Literal: - return serializers.ChoiceField(choices=type_.__args__, **kwargs) + elif type_origin is Literal: + return serializers.ChoiceField(choices=type_args, **kwargs) raise FieldConversionError( - f"{type_.__origin__.__name__} is not a supported composite type." + f"{type_origin.__name__} is not a supported composite type." ) diff --git a/src/drf_pydantic/utils.py b/src/drf_pydantic/utils.py index f01b5c5..afa9980 100644 --- a/src/drf_pydantic/utils.py +++ b/src/drf_pydantic/utils.py @@ -1,7 +1,7 @@ import inspect from types import GenericAlias -from typing import TYPE_CHECKING, Any, Optional, Type +from typing import TYPE_CHECKING, Any, Optional, Tuple, Type if TYPE_CHECKING: from types import UnionType @@ -18,7 +18,7 @@ UnionType = None -def get_union_members(type_: Any) -> Optional[tuple[Type[Any], ...]]: +def get_union_members(type_: Any) -> Optional[Tuple[Type[Any], ...]]: """ Get union members from a union type. diff --git a/tests/test_validation.py b/tests/test_validation.py index bb00ee9..f4a4e9d 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -4,7 +4,7 @@ import pydantic import pytest -from rest_framework import serializers +from rest_framework import serializers # type: ignore from drf_pydantic import BaseModel, DrfPydanticSerializer @@ -43,6 +43,16 @@ class Person(BaseModel): valid_serializer = Person.drf_serializer(data={"name": "Van", "age": 69}) assert valid_serializer.is_valid(raise_exception=True) + assert valid_serializer._pydantic_model is Person + if validate_pydantic: + assert isinstance(valid_serializer.pydantic_instance, Person) + else: + with pytest.raises( + AssertionError, + match=r"You must enable pydantic validation", + ): + valid_serializer.pydantic_instance + invalid_serializer = Person.drf_serializer(data={"name": 69, "age": "Van"}) assert not invalid_serializer.is_valid() with pytest.raises(serializers.ValidationError): @@ -71,6 +81,7 @@ def validate_name(cls, v: typing.Any) -> str: } maybe_valid_serializer = Person.drf_serializer(data={"name": "Van", "age": 69}) + assert maybe_valid_serializer._pydantic_model is Person if validate_pydantic: if raise_pydantic_error: with pytest.raises(pydantic.ValidationError): @@ -81,10 +92,20 @@ def validate_name(cls, v: typing.Any) -> str: assert not maybe_valid_serializer.is_valid() with pytest.raises(serializers.ValidationError): maybe_valid_serializer.is_valid(raise_exception=True) + with pytest.raises( + AssertionError, + match=r"You must call `.is_valid\(\)` before", + ): + maybe_valid_serializer.pydantic_instance else: # Without pydantic DRF doesn't know about field_validator assert maybe_valid_serializer.is_valid() assert maybe_valid_serializer.is_valid(raise_exception=True) + with pytest.raises( + AssertionError, + match=r"You must enable pydantic validation", + ): + maybe_valid_serializer.pydantic_instance @pytest.mark.parametrize( @@ -122,6 +143,14 @@ class Person(BaseModel): data={"name": "Van", "job": {"title": "DM", "salary": 9000}}, ) assert valid_serializer.is_valid(raise_exception=True) + if validate_pydantic: + assert isinstance(valid_serializer.pydantic_instance, Person) + else: + with pytest.raises( + AssertionError, + match=r"You must enable pydantic validation", + ): + valid_serializer.pydantic_instance maybe_valid_serializer = Person.drf_serializer( data={"name": "Van", "job": {"title": "DM", "salary": 300}}, @@ -136,10 +165,20 @@ class Person(BaseModel): assert not maybe_valid_serializer.is_valid() with pytest.raises(serializers.ValidationError): maybe_valid_serializer.is_valid(raise_exception=True) + with pytest.raises( + AssertionError, + match=r"You must call `.is_valid\(\)` before", + ): + maybe_valid_serializer.pydantic_instance else: # Without pydantic DRF doesn't know about field_validator assert maybe_valid_serializer.is_valid() assert maybe_valid_serializer.is_valid(raise_exception=True) + with pytest.raises( + AssertionError, + match=r"You must enable pydantic validation", + ): + maybe_valid_serializer.pydantic_instance @pytest.mark.parametrize( @@ -177,6 +216,14 @@ class Person(BaseModel): data={"name": "Van", "jobs": [{"title": "DM", "salary": 9000}]}, ) assert valid_serializer.is_valid(raise_exception=True) + if validate_pydantic: + assert isinstance(valid_serializer.pydantic_instance, Person) + else: + with pytest.raises( + AssertionError, + match=r"You must enable pydantic validation", + ): + valid_serializer.pydantic_instance maybe_valid_serializer = Person.drf_serializer( data={"name": "Van", "jobs": [{"title": "DM", "salary": 300}]}, @@ -191,10 +238,19 @@ class Person(BaseModel): assert not maybe_valid_serializer.is_valid() with pytest.raises(serializers.ValidationError): maybe_valid_serializer.is_valid(raise_exception=True) + # not available when invalid or when is_valid raised + with pytest.raises( + AssertionError, match=r"You must call `.is_valid\(\)` before" + ): + maybe_valid_serializer.pydantic_instance else: # Without pydantic DRF doesn't know about field_validator assert maybe_valid_serializer.is_valid() assert maybe_valid_serializer.is_valid(raise_exception=True) + with pytest.raises( + AssertionError, match=r"You must enable pydantic validation" + ): + maybe_valid_serializer.pydantic_instance def test_inheritance(): @@ -227,15 +283,20 @@ class Grandchild(Child): assert not parent_serializer.is_valid() with pytest.raises(serializers.ValidationError): parent_serializer.is_valid(raise_exception=True) - + with pytest.raises(AssertionError, match=r"You must call `.is_valid\(\)` before"): + parent_serializer.pydantic_instance child_serializer = Child.drf_serializer(data={**data}) with pytest.raises(pydantic.ValidationError): child_serializer.is_valid() with pytest.raises(pydantic.ValidationError): child_serializer.is_valid(raise_exception=True) + with pytest.raises(AssertionError, match=r"You must call `.is_valid\(\)` before"): + child_serializer.pydantic_instance grandchild_serializer = Grandchild.drf_serializer(data={**data}) assert grandchild_serializer.is_valid(raise_exception=True) + with pytest.raises(AssertionError, match=r"You must enable pydantic validation"): + grandchild_serializer.pydantic_instance def test_manual_serializer( @@ -279,10 +340,20 @@ def validate_name(cls, v: typing.Any) -> str: assert not maybe_valid_serializer.is_valid() with pytest.raises(serializers.ValidationError): maybe_valid_serializer.is_valid(raise_exception=True) + with pytest.raises( + AssertionError, + match=r"You must call `.is_valid\(\)` before", + ): + maybe_valid_serializer.pydantic_instance else: # Without pydantic DRF doesn't know about field_validator assert maybe_valid_serializer.is_valid() assert maybe_valid_serializer.is_valid(raise_exception=True) + with pytest.raises( + AssertionError, + match=r"You must enable pydantic validation", + ): + maybe_valid_serializer.pydantic_instance def test_validation_error_translation_field_errors(): @@ -377,6 +448,7 @@ def validate_person(self): serializer = Person.drf_serializer(data={"name": "Van", "age": 68}) assert serializer.is_valid(raise_exception=True) assert serializer.validated_data["name"] == "Jabroni" + assert serializer.pydantic_instance.age == 69 assert serializer.validated_data["age"] == 69 @@ -403,6 +475,7 @@ def validate_person(self): serializer = Person.drf_serializer(data={"name": "Van", "age": 68}) assert serializer.is_valid(raise_exception=True) assert serializer.validated_data["name"] == "Van" + assert serializer.pydantic_instance.age == 69 assert serializer.validated_data["age"] == 68 @@ -441,7 +514,10 @@ class Person(BaseModel): ) assert s.is_valid(raise_exception=True) assert isinstance(s.data["job"], dict) + assert s.pydantic_instance.job.title == "DM" assert s.validated_data["job"]["title"] == "DM" assert isinstance(s.data["job"]["salary"], dict) + assert s.pydantic_instance.job.salary.value == 300 assert s.validated_data["job"]["salary"]["value"] == 300 + assert s.pydantic_instance.job.salary.currency == "bucks" assert s.validated_data["job"]["salary"]["currency"] == "bucks"