Skip to content

The pydantic adapter

PydanticValidator and PydanticPresenter let a pydantic model declare an operation's input and its output. The model does what it does for any other caller - builds the instance, runs its own validators, dumps itself as JSON - and the adapter reads the model's fields into the kernel's Parameters and Output, so a transport describes the operation exactly as it describes one declared with the dataclass adapter.

Installing

pydantic is an optional extra:

pip install "django-service-specs[pydantic]"

The package root never imports pydantic, so nothing changes for a project that does not install it. The adapter is imported from its own subpackage, and is the only thing that needs the extra:

from django_service_specs.adapters.pydantic import PydanticPresenter, PydanticValidator

Declaring with models

The nested write from Relations, declared as models:

from datetime import date
from decimal import Decimal
from typing import Annotated, Any

from pydantic import BaseModel, Field, model_validator

from django_service_specs import (
    ChangeResult,
    ChildSpec,
    FieldMarking,
    SelectorKind,
    SelectorSpec,
    ServiceSpec,
    create_from_input,
)
from django_service_specs.adapters.pydantic import PydanticPresenter, PydanticValidator
from docs.examples.declaring import IsSignedIn
from tests.adapter_app.models import Author, Book, Status


class BookIn(BaseModel):
    title: str = Field(max_length=100)
    price: Decimal = Field(gt=0, description="In euros.")
    status: Status = Status.DRAFT
    published_on: date | None = None
    # The key a relation write matches an incoming row by.
    pk: int | None = None

    @model_validator(mode="after")
    def _dated_when_published(self) -> BookIn:
        if self.status == Status.PUBLISHED and self.published_on is None:
            raise ValueError("A published book has a publication date.")
        return self


class AuthorIn(BaseModel):
    name: str
    books: list[BookIn] = Field(default_factory=list)


class BookOut(BaseModel):
    id: Annotated[int, FieldMarking.handle()]
    title: Annotated[str, FieldMarking.label()]
    price: Decimal
    status: Status


class AuthorOut(BaseModel):
    id: Annotated[int, FieldMarking.handle()]
    name: str
    books: list[BookOut]


def create_author(*, data: dict[str, Any]) -> ChangeResult[Author]:
    return create_from_input(Author, data, relations={"books": ChildSpec(model=Book, fk="author")})


create_author_spec = ServiceSpec(
    service=create_author,
    permissions=[IsSignedIn()],
    validator=PydanticValidator(AuthorIn),
    output_selector_spec=SelectorSpec(
        kind=SelectorKind.RETRIEVE,
        selector=lambda *, result: Author.objects.filter(pk=result.instance.pk),
        prefetch_related=["books"],
        presenter=PydanticPresenter(AuthorOut),
    ),
)

validate() returns the values keyed by field name, with nested values left as model instances, which is what create_from_input reads a row from. The presenter reads the author and its prefetched books through the related manager, validates what it read with AuthorOut, and returns model_dump(mode="json", by_alias=True).

What a model declares

A model's fields are read as the dataclass adapter reads a dataclass's, from the same table, so a model and a dataclass declaring the same field declare the same parameter:

Annotation Declares
str, int, float, bool Their JSON types.
Decimal, datetime, date A string with the format it decodes from.
X \| None Nullable. Inside a list, list[X \| None], the element is: items_nullable.
Literal[...], an Enum (Django's TextChoices and IntegerChoices included) Choices.
list[X] An array. list[SomeModel] is an array of rows, each declared as nested Parameters.
a model An object, with its fields as nested Parameters.
dict[str, X] An object with no declared fields. Its values are the model's to validate.

Annotated is looked through, so Annotated[str, Field(max_length=100)] declares a string. Anything else - Any, a union of two types, UnsetType, a RootModel, set, tuple, a dict not keyed by str - is refused with ImproperlyConfigured naming the model and the field, when the validator or presenter is constructed.

A field is required when pydantic says so. Its default is declared when JSON carries it as itself: 3, "draft", None, []. A default_factory, or a default such as a Decimal or a date, is not declared, and the field is optional all the same. The field's description is the parameter's help.

What PydanticValidator(AuthorIn) declares, then, is what DataclassValidator declares for the dataclass AuthorIn in Declaring an operation, with the price's description as its help: name, and an optional array of books whose rows take a title, a price in the decimal format, a status with its choices and default, a nullable published_on in the date format, and the nullable pk that lets an update match a row. The rule stated there holds here too: a row that updates must declare its key. The title's max_length and the price's gt are not declared; see the last section for why.

Names

A parameter is named for the key pydantic validates by: the field's validation_alias when it is a string, else its alias, else its name. An output field is named for the key model_dump(by_alias=True) emits: the serialization_alias, else the alias, else the name.

class Contact(BaseModel):
    # Validated from "fullName" and dumped as "fullName".
    full_name: str = Field(alias="fullName")
    # Validated from "mail" and dumped as "email".
    email: str = Field(validation_alias="mail", serialization_alias="email")


contact_validator = PydanticValidator(Contact)
contact_presenter = PydanticPresenter(Contact)

Contact takes fullName and mail, and outputs fullName and email. validate() returns {"full_name": ..., "email": ...}: the field names, whatever the wire called them, because those are the keywords the service is called with. A model that sets validate_by_alias=False takes its field names.

An AliasPath or AliasChoices is refused: a parameter has one name, and a transport must be able to say which.

A model that contains itself

Category.children is a list of Category, which has no end. The adapter describes a model at most four times on one path, the top counting as the first, and the level past that as an object with no declared fields:

class Category(BaseModel):
    name: str
    children: list[Category] = []


category_validator = PydanticValidator(Category)

The bound is on the description, not on the data. The shape check stops looking at the fourth level, and pydantic validates every level below it, so a tree ten levels deep is built in full and a bad name ten levels down is refused at its path. A model that appears twice side by side is described in full both times: the count is per path.

Refusals

A refusal keeps pydantic's words, addressed into the kernel's refusal tree, as Arguments and refusals describes it:

  • a field is addressed by its parameter name, and a row by its index as an int: {"books": {0: {"price": ["Input should be greater than 0"]}}};
  • a model refusing itself - a model_validator raising - is addressed at non_field_errors inside that model, or at the top for the model being validated: {"books": {1: {"non_field_errors": ["Value error, A published book has a publication date."]}}}.

The kernel's shape check runs first, so a wrong JSON type is refused in the kernel's words and never reaches the model. pydantic's words are what a caller reads for the model's own rules: bounds, patterns, validators.

The context reaches pydantic's validators as info.context, a dict with "principal" and "target", so a field_validator can check a value against the caller or the row being updated.

Output

PydanticPresenter declares the model's fields in order, then its computed_fields. A field declared exclude=True is not output, and a field with an exclude_if is declared as not always present. A field's title is its label, and a FieldMarking is declared in its Annotated metadata, as BookOut does above.

present(value) takes an instance of the model, a model row, any object carrying the fields' names, or a mapping keyed by them. Anything but an instance is read one field at a time and validated by the model; a related collection is read through .all(), so a prefetch is used, and a field the value does not carry takes the model's default. A value the model refuses raises pydantic's ValidationError as it is: the value is the operation's own, so a mismatch is a defect, not a refusal to report.

Where it differs from the dataclass adapter

  • Nothing is omittable. validate() returns every field, a left-out one at its default, and UnsetType is refused, because a pydantic model has no value that means "not sent". An update whose fields may be left out is declared with the dataclass adapter's X | UnsetType = UNSET, as AuthorPatch is.
  • Time zones are pydantic's. An offset-less date-time stays naive, where the dataclass adapter makes it aware as Django's forms do. Declare the offset, or convert in the service.

What model_json_schema() is not used for

A transport describes an operation's input from parameters(), whichever adapter declared it, and the model's own JSON Schema is not consulted. There is one dialect: a Decimal is a string in the decimal format for a model as for a dataclass, where pydantic's schema describes it as a number or a patterned string. The price of that is that what Parameters cannot carry - max_length, gt, a pattern - is not described. It is still enforced, by the model, in validate().