Source code for dmr.internal.context

from collections import defaultdict
from collections.abc import Callable
from http import HTTPStatus
from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias

from dmr.components import ComponentParser, ComponentParserBuilder
from dmr.exceptions import ValidationError
from dmr.serializer import ContextField, ContextModel

if TYPE_CHECKING:
    from dmr.controller import Controller
    from dmr.endpoint import Endpoint
    from dmr.serializer import BaseSerializer

_ContextFields: TypeAlias = dict[str, ContextField]
_ContentTypeOverrides: TypeAlias = dict[str, dict[str, Any]]
#: Component name, component, its model, and its default.
_CollectPlan: TypeAlias = tuple[tuple[str, ComponentParser, Any, Any], ...]


[docs] class SerializerContext: """ Parse and bind request components for a controller. This context collects raw data for all registered components, validates the combined payload in a single call using a cached model that the serializer builds, and then binds the parsed values back to the controller. Components with default values return them when a request has no data for them, so the endpoint receives their real python defaults. Attributes: strict_validation: Whether or not to validate payloads in strict mode. Strict mode in some serializers does not allow implicit type conversions. Defaults to ``None``, which means that we decide on a per-field basis if it is set, if not then on a per-model basis. .. versionchanged:: 0.16.0 Models are now built by ``serializer.build_context_model``, defaults of component parameters are now supported. """ # Public API: strict_validation: ClassVar[bool | None] = None #: Type that will be used to build parsing components. component_builder_cls: ClassVar[type[ComponentParserBuilder]] = ( ComponentParserBuilder ) # Protected API: _collect_plan: _CollectPlan _default_model: ContextModel _conditional_models: dict[str, ContextModel] __slots__ = ( '_collect_plan', '_conditional_models', '_default_model', 'component_parsers', ) def __init__( self, func: Callable[..., Any], controller_cls: type['Controller[BaseSerializer]'], type_annotations: dict[str, Any], ) -> None: """Eagerly build context for a given controller and serializer.""" # Build component parsers: self.component_parsers = self.component_builder_cls( func, controller_cls, )(type_annotations) # Build models and conditional models: fields, content_type_overrides = self._build_fields() default_model, conditional_models = self._build_models( controller_cls, fields, content_type_overrides, ) self._default_model = default_model self._conditional_models = conditional_models # Prepare values to collect the context from: self._collect_plan = tuple( ( spec.parser.context_name, spec.parser, spec.model, spec.default, ) for spec in self.component_parsers )
[docs] def __call__( self, endpoint: 'Endpoint', controller: 'Controller[BaseSerializer]', ) -> dict[str, Any]: """ Collect, validate, and bind component data to the controller. Raises ``serializer.validation_error`` when provided data does not match the expected model. """ if not self._collect_plan: return {} context = self._collect_context(endpoint, controller) return self._validate_context(context, controller)
# Import time methods: def _build_fields( self, ) -> tuple[_ContextFields, _ContentTypeOverrides]: """ Build the type parsing spec. Called during import-time. """ fields: _ContextFields = {} content_type_overrides: _ContentTypeOverrides = defaultdict(dict) for spec in self.component_parsers: fields[spec.parser.context_name] = ContextField( spec.model, spec.default, ) for content_type, model in spec.parser.conditional_types( spec.model, spec.model_meta, ).items(): content_type_overrides[content_type].update({ spec.parser.context_name: model, }) return fields, content_type_overrides def _build_models( self, controller_cls: type['Controller[BaseSerializer]'], fields: _ContextFields, content_type_overrides: _ContentTypeOverrides, ) -> tuple[ContextModel, dict[str, ContextModel]]: # Name is not really important, # we use `@` to identify that it is generated: name_prefix = controller_cls.__qualname__ default_model = controller_cls.serializer.build_context_model( f'_{name_prefix}@ContextModel', fields, ) return default_model, { content_type: controller_cls.serializer.build_context_model( f'_{name_prefix}@ContextModel_{content_type}', self._override_fields(fields, overrides), ) for content_type, overrides in content_type_overrides.items() } def _override_fields( self, fields: _ContextFields, overrides: dict[str, Any], ) -> _ContextFields: """Replace annotations of some fields, but keep their defaults.""" return { **fields, **{ field_name: ContextField(model, fields[field_name].default) for field_name, model in overrides.items() }, } # Runtime methods: def _collect_context( self, endpoint: 'Endpoint', controller: 'Controller[BaseSerializer]', ) -> dict[str, Any]: """Collect raw data for all components into a mapping.""" context: dict[str, Any] = {} for context_name, component, submodel, default in self._collect_plan: context[context_name] = component.provide_context_data( endpoint, controller, field_model=submodel, # just the exact field for the exact key default=default, ) return context def _validate_context( self, context: dict[str, Any], controller: 'Controller[BaseSerializer]', ) -> dict[str, Any]: """Validate the combined payload using the cached model.""" serializer = controller.serializer context_model = self._default_model if self._conditional_models: context_model = self._conditional_models.get( # pyrefly: ignore[no-matching-overload] # pyright: ignore[reportUnknownVariableType] controller.request.content_type, # type: ignore[arg-type] context_model, ) try: parsed = serializer.from_python( context, context_model.model, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] strict=self.strict_validation, ) except serializer.validation_error as exc: raise ValidationError( serializer.serialize_validation_error(exc), status_code=HTTPStatus.BAD_REQUEST, ) from None if context_model.to_kwargs is None: # pyright: ignore[reportUnknownMemberType] return parsed # type: ignore[no-any-return] return context_model.to_kwargs(parsed) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]