Source code for dmr.plugins.pydantic.serializer

import dataclasses
from collections.abc import Callable, Mapping
from functools import lru_cache
from typing import (
    TYPE_CHECKING,
    Any,
    ClassVar,
    Final,
    Literal,
    TypeAlias,
    TypeVar,
    final,
)

import pydantic
import pydantic_core
from django.http import HttpRequest
from pydantic.config import ExtraValues
from typing_extensions import TypedDict, TypeForm, override

from dmr.envs import MAX_CACHE_SIZE
from dmr.errors import ErrorDetail, ErrorType
from dmr.exceptions import (
    DataParsingError,
    DataRenderingError,
    EndpointMetadataError,
)
from dmr.internal.json import NativeJson, json_loads
from dmr.parsers import Parser, Raw
from dmr.plugins.pydantic.schema import PydanticSchemaGenerator
from dmr.renderers import Renderer
from dmr.serializer import (
    BaseEndpointOptimizer,
    BaseSerializer,
    ContextField,
    ContextModel,
    context_field_tuples,
)
from dmr.types import EMPTY

if TYPE_CHECKING:
    from dmr.controller import Controller
    from dmr.metadata import EndpointMetadata

#: Mode that we use for default serialization.
_JSON_MODE: Final = 'json'

# pydantic does not allow to import this,
# so we have to duplicate this type.
_IncEx: TypeAlias = (
    set[int]
    | set[str]
    | Mapping[int, '_IncEx | bool']
    | Mapping[str, '_IncEx | bool']
)


[docs] @final class ToJsonKwargs(TypedDict, total=False, closed=True): """Keyword arguments for pydantic's model dump method.""" # `mode` is explicitly left out. It is always `json`. include: _IncEx | None exclude: _IncEx | None context: Any | None by_alias: bool | None exclude_unset: bool exclude_defaults: bool exclude_none: bool exclude_computed_fields: bool round_trip: bool warnings: bool | Literal['none', 'warn', 'error'] fallback: Callable[[Any], Any] | None serialize_as_any: bool
[docs] @final class ToModelKwargs(TypedDict, total=False, closed=True): """Keyword arguments for pydantic's python object validation method.""" # `from_attributes` is explicitly left out. It is always `False`. extra: ExtraValues | None context: Any | None experimental_allow_partial: bool | Literal['off', 'on', 'trailing-strings'] by_alias: bool | None by_name: bool | None
[docs] class PydanticEndpointOptimizer(BaseEndpointOptimizer): """Optimize endpoints that are parsed with pydantic.""" __slots__ = ()
[docs] @override @classmethod def optimize_endpoint(cls, metadata: 'EndpointMetadata') -> None: """Create models for return types for validation.""" # Just build all `TypeAdapter` instances # during import time and cache them for later use in runtime. for response in metadata.responses.values(): _get_cached_type_adapter(response.return_type) # type: ignore[arg-type] # It is used in many places: _get_cached_type_adapter(Any)
[docs] class PydanticSerializer(BaseSerializer): """ Serialize and deserialize objects using pydantic. Pydantic support is optional. To install it run: .. code:: bash pip install 'django-modern-rest[pydantic]' Attributes: to_json_kwargs: Dictionary of kwargs that will be passed to model serialization callbacks. to_model_kwargs: Dictionary of kwargs that will be passed to model deserialization callbacks. """ __slots__ = () # Required API: validation_error = pydantic_core.ValidationError optimizer = PydanticEndpointOptimizer schema_generator = PydanticSchemaGenerator # Custom API: to_json_kwargs: ClassVar[ToJsonKwargs] = { 'by_alias': True, } to_model_kwargs: ClassVar[ToModelKwargs] = { 'by_alias': True, }
[docs] @override @classmethod def serialize( cls, structure: Any, *, renderer: Renderer, ) -> bytes: """Convert any object to raw bytestring.""" try: return renderer.render( structure, cls.serialize_hook, ) except pydantic_core.PydanticSerializationError as exc: raise DataRenderingError(str(exc)) from None
[docs] @override @classmethod def serialize_hook(cls, to_serialize: Any) -> Any: """Customize how some objects are serialized into simple objects.""" if isinstance(to_serialize, pydantic.BaseModel): return to_serialize.__pydantic_serializer__.to_python( to_serialize, mode=_JSON_MODE, **cls.to_json_kwargs, ) # We support dataclasses here, because raw `JsonRenderer` # does not support them, however, we use them in multiple places inside. # Or this is a pydantic field inside a `TypedDict`, `@dataclass`, etc: if dataclasses.is_dataclass(to_serialize) or hasattr( to_serialize, '__get_pydantic_core_schema__', ): return _get_cached_type_adapter( type(to_serialize), # type: ignore[arg-type] ).dump_python( to_serialize, mode=_JSON_MODE, **cls.to_json_kwargs, ) return super().serialize_hook(to_serialize)
[docs] @override @classmethod def deserialize( cls, buffer: Raw, *, parser: Parser, request: HttpRequest, model: TypeForm[Any], ) -> Any: """Convert string or bytestring to simple python object.""" return parser.parse( buffer, cls.deserialize_hook, request=request, # Note: passing real `pydantic` model to `msgspec` parser # is not supported, because it is SLOWER than using 2-phase parsing. model=model, )
[docs] @override @classmethod def from_python( cls, unstructured: Any, model: TypeForm[Any], *, strict: bool | None, extra_namespace: Mapping[str, Any] | None = None, ) -> Any: """ Parse *unstructured* data from python primitives into *model*. Args: unstructured: Python objects to be parsed / validated. model: Python type to serve as a model. Can be any type that ``pydantic`` supports. Examples: ``dict[str, int]`` and ``BaseModel`` subtypes. strict: Whether we use more strict validation rules. For example, it is fine for a request validation to be less strict in some cases and allow type coercition. But, response types need to be strongly validated. extra_namespace: Optional namespace to rebuild the type adapter. Should be used when there are forward references that pydantic cannot solve by itself. Returns: Structured and validated data. Raises: pydantic_core.ValidationError: When parsing can't be done. .. versionchanged:: 0.13.0 Added *rebuild_namespace* parameter was renamed to be *extra_namespace*. """ # At this point `_get_cached_type_adapter(model)` was already called # during the optimizer stage, so it will be very fast to use in runtime. adapter = _get_cached_type_adapter(model) # type: ignore[arg-type] if extra_namespace is not None: adapter.rebuild(_types_namespace=extra_namespace) return adapter.validate_python( unstructured, strict=strict, **cls.to_model_kwargs, )
[docs] @override @classmethod def build_context_model( cls, name: str, fields: Mapping[str, ContextField], ) -> ContextModel: """ Build the model to parse the whole request context at once. We build a :class:`typing.TypedDict` when there are no defaults, because it is the fastest thing that ``pydantic`` can validate and it does not need any conversion into keyword arguments. When some fields have defaults, we build a regular :func:`dataclasses.dataclass`, since ``TypedDict`` cannot have them. Defaults are passed as-is, so :mod:`dataclasses` rules apply: mutable defaults like ``[]`` or non-frozen models are not allowed. .. versionadded:: 0.16.0 """ if all(field.default is EMPTY for field in fields.values()): annotations = { field_name: field.annotation for field_name, field in fields.items() } typed_dict = TypedDict( # type: ignore[misc] name, # pyright: ignore[reportArgumentType] # pyrefly: ignore[name-mismatch] annotations, # pyright: ignore[reportArgumentType] total=True, closed=True, ) _get_cached_type_adapter(typed_dict) # prepare during import time return ContextModel(typed_dict) # We don't use `slots=True` here on purpose: # `vars()` is the fastest way to unpack a dataclass instance, # it requires an instance `__dict__` to exist: dataclass = dataclasses.make_dataclass( name, context_field_tuples(fields), ) _get_cached_type_adapter(dataclass) # prepare during import time return ContextModel(dataclass, to_kwargs=vars)
[docs] @override @classmethod def to_python( cls, structured: Any, ) -> Any: """ Unparse *structured* data from a model into Python primitives. Args: structured: Model instance. Returns: Unstructured data. """ return _get_cached_type_adapter(Any).dump_python( structured, mode=_JSON_MODE, **cls.to_json_kwargs, )
[docs] @override @classmethod def serialize_validation_error( cls, exc: Exception, ) -> list[ErrorDetail]: """Serialize validation error.""" if isinstance(exc, pydantic.ValidationError): return [ { 'msg': error['msg'], 'loc': [str(loc) for loc in error['loc']], 'type': str(ErrorType.value_error), } for error in exc.errors( include_url=False, include_context=False, include_input=False, ) ] raise NotImplementedError( f'Cannot serialize exception {exc!r} of type {type(exc)} safely', )
[docs] class PydanticFastSerializer(PydanticSerializer): """ Fast pydantic serializer for cases when you only work with json. Does not use ``parser`` and ``renderer`` passed objects, does not use ``dmr.plugins.pyndatic.PydanticSerializer.serialize_hook`` and ``dmr.plugins.pyndatic.PydanticSerializer.deserialize_hook`` method. Is built for optimizations only, use with caution. Only works with ``application/json`` content type. .. versionadded:: 0.6.0 See :issue:`830`. """ __slots__ = ()
[docs] @classmethod @override def serialize(cls, structure: Any, *, renderer: Renderer) -> bytes: """ Fast way to serializer pyndatic models into json bytestring. *renderer* parameter is always ignored. """ try: return _get_cached_type_adapter( type(structure), # type: ignore[arg-type] ).dump_json( structure, fallback=cls.serialize_hook, **cls.to_json_kwargs, # type: ignore[misc] ) except pydantic_core.PydanticSerializationError as exc: raise DataRenderingError(str(exc)) from exc
[docs] @classmethod @override def deserialize( cls, buffer: Raw, *, parser: Parser, request: HttpRequest, model: TypeForm[Any], ) -> Any: """ Fast way to serializer pyndatic models into json bytestring. *parser* parameter is always ignored. .. versionchanged:: 0.16.0 Now uses faster ``json_loads`` when it is available. """ try: return _json_loads(buffer) except pydantic_core.ValidationError as exc: # Corner case: an empty body is `None` for us, # just like `JsonParser` treats it. Happens for `204` responses. # We do this here, because we don't want # a penalty for all positive cases. if buffer == b'': return None raise DataParsingError(exc.errors()[0]['msg']) from exc except Exception as exc: # which can be raise for non-pydantic loads if buffer == b'': return None raise DataParsingError(str(exc)) from exc
[docs] @classmethod @override def validate( cls, controller_cls: type['Controller[BaseSerializer]'], metadata: 'EndpointMetadata', ) -> None: """ Validate that only ``json`` parsers and renderers are used. .. versionchanged:: 0.16.0 Replaces ``is_supported`` method. """ pluggables: list[Parser | Renderer] = [ *metadata.parsers.values(), *metadata.renderers.values(), ] for pluggable in pluggables: if pluggable.content_type != 'application/json': raise EndpointMetadataError( f'{metadata.endpoint_name!r} serializer ' f'does not support {pluggable!r}, only json is supported', )
_ModelT = TypeVar('_ModelT') @lru_cache(maxsize=MAX_CACHE_SIZE) def _get_cached_type_adapter(model: _ModelT) -> pydantic.TypeAdapter[_ModelT]: """ It is expensive to create, reuse existing ones. If you want to clear this cache run: .. code:: python >>> _get_cached_type_adapter.cache_clear() Or use :func:`dmr.settings.clear_settings_cache`. """ # This is a function not to cache `self` or `cls` params. return pydantic.TypeAdapter(model, _parent_depth=4) #: Internal helper to load json for fast serializer. #: If `msgspec` is missing and the default `json` module is used, we fallback #: to the `pydantic` one, which is also rather slow. _json_loads: Final = ( _get_cached_type_adapter(Any).validate_json if json_loads is NativeJson.loads else json_loads )