Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion src/drf_pydantic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 6 additions & 5 deletions src/drf_pydantic/base_model.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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(
(
Expand All @@ -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]
36 changes: 29 additions & 7 deletions src/drf_pydantic/base_serializer.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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
107 changes: 62 additions & 45 deletions src/drf_pydantic/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.

Expand All @@ -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)
Expand All @@ -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(
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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__", []):
Expand All @@ -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,
)
Expand All @@ -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, <annotation>]."
)
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."
)
Loading
Loading