Skip to content

API reference

Everything below is exported from django_service_specs directly, and from the subpackage it is listed under. Import from the package root in application code; the subpackage paths are where each name is defined.

The pydantic adapters are the one exception. pydantic is an optional extra, so they are exported from django_service_specs.adapters.pydantic alone, and importing the package root never imports pydantic.

Specs

ServiceSpec dataclass

A write: what it takes, who may run it, what it acts on, what it returns.

Dispatch runs it in a fixed order: the shape check and the closed argument set, class-level authorization, target resolution, object-level authorization, validation with the target in the Validator's context, the service, then the output selector.

Attributes:

Name Type Description
service Callable[..., Any]

The operation. Called with the keywords it declares: the principal (user), the validated values (data, and each one spread by name), the resolved target (instance or collection), and any registered pool seed. It may be async def; the kernel bridges it into atomic either way.

permissions Sequence[PermissionCheck] | None

The checks a principal must pass. None means none were declared, which registration and dispatch both refuse; an open operation says [Unrestricted()].

validator Validator | None

Validates the arguments its own parameters() declare. A Validator instance; None for an operation that takes no arguments beyond its target.

instance_selector_spec SelectorSpec | None

A RETRIEVE resolving the one row the service acts on, from the arguments its reads declare. A missing row is not-found and the service never runs.

collection_selector_spec SelectorSpec | None

A LIST resolving the rows a bulk operation acts on. At most one of the two target selectors.

output_selector_spec SelectorSpec | None

Re-reads what the service produced, with its return value as result in the pool - the way to return a row with fresh annotations, or a list after a bulk write.

presenter Presenter | None

Renders the dispatch value. When None, the output selector's presenter is used; declaring both is refused.

atomic bool

Run the service inside transaction.atomic().

metadata Mapping[str, Any] | None

A project's own per-operation facts, stored as given.

target_selector_spec

target_selector_spec() -> SelectorSpec | None

The selector resolving what the service acts on, if it declares one.

parameters

parameters() -> Parameters

Everything this spec takes: its target selector's reads, then its Validator's.

Assembled on every call rather than at construction, because a Validator reading a model-backed declaration may need the app registry, and specs are commonly built at import time. A name both sources declare is refused here, as is one dispatch seeds itself.

presenter_for_output

presenter_for_output() -> Presenter | None

The presenter the dispatch value is rendered with: this spec's or its output selector's.

output

output() -> Output | None

What this spec returns, as its presenter declares it, or None.

SelectorSpec dataclass

A read: what it takes, who may run it, how its rows are shaped, what it returns.

Dispatched on its own, it is a list or a retrieve. Nested in a ServiceSpec, it is how that service resolves its target, its collection or its output.

Attributes:

Name Type Description
kind SelectorKind

LIST or RETRIEVE; see SelectorKind.

selector Callable[..., Any]

The read itself. Called with the keywords it declares, from the principal (user), any registered pool seed, and the arguments reads declares. It may be async def: the selector is this spec's run, and the run is the one callable allowed to be.

permissions Sequence[PermissionCheck] | None

The checks a principal must pass. None means none were declared, which registration and dispatch both refuse; an open read says [Unrestricted()]. Not read when the spec is nested: authorization belongs to the spec being dispatched.

reads Parameters

The arguments the selector reads with no Validator in front of it - its filters, its ordering, the key of the row it retrieves. They are the spec's whole Parameters, so the closed argument set and the shape check both apply to them.

presenter Presenter | None

Renders the rows. Not read on an instance or collection selector, whose rows the service consumes rather than returns.

allow_none bool

RETRIEVE only. A missing row is the value None rather than not-found - the shape of an optional singleton, or of an upsert's target.

select_related, (prefetch_related, annotations)

Applied to the queryset the selector returns, in that order. Declaring any of them when the selector returns something else is refused at dispatch.

extend_queryset Callable[..., QuerySet[Any]] | None

Applied last, called with the keywords it declares from the same pool plus queryset, the shaped queryset so far. It returns the queryset to use.

metadata Mapping[str, Any] | None

A project's own per-operation facts, stored as given and read back by its own checks or audit hooks.

parameters

parameters() -> Parameters

Everything this spec takes: its reads.

output

output() -> Output | None

What this spec returns, as its presenter declares it, or None.

SelectorKind

Bases: str, Enum

The shape a selector spec returns.

LIST returns a collection, a queryset when it can be shaped. RETRIEVE returns one row: a queryset is materialized with .first(), and a missing row is reported as not found unless the spec allows None.

Inheriting from str keeps the value JSON-serializable while still behaving as a proper enum for is / ==.

Parameters

Parameter dataclass

One thing an operation takes, as a declaration every transport reads.

JSON Schema, argv, the closed argument set and the shape check are all derived from it, so a transport describes itself from the declaration rather than from whichever validation library the spec happens to use.

name and type are positional because every declaration has both; everything else is keyword-only.

items is an array's element: a JSON type name for a flat list, or the Parameters of the object each element is. fields is the same for a parameter whose own type is object. Nesting is what lets a relation write declare its rows: an author with its books says what a book is, where a flat items="object" would say nothing and let a malformed row through.

nullable is the parameter's own: whether the argument may be null. items_nullable is its element's: whether a null may stand in the array where an element would, as in list[int | None]. The two are independent, because a list that may be absent and a list that may hold gaps are different declarations, and an array with no declared items admits a null element already.

default is UNSET when none is declared, so a default of None stays a real default. It is reported, not applied: whether an omitted argument arrives as the default, as None or not at all is the validating library's behaviour, and each adapter reports what its library does.

There are no bounds (maxLength, minimum) yet. The Validator enforces those; adding them here is additive.

nested property

nested: Parameters | None

The Parameters inside this one: an object's fields, or an array's rows.

Parameters dataclass

An ordered set of Parameter.

A spec's Parameters are assembled from several sources - its target selector's reads, its Validator's parameters() - and + is how. A name declared twice is refused, at construction and at + alike: two sources claiming one argument is a spec bug, and a last-wins merge would hide which of the two declarations a transport describes and which one the argument reaches.

Any iterable of parameters is accepted and stored as a tuple, so the declaration stays hashable and ordered.

names

names() -> frozenset[str]

Every declared name, for the closed argument set.

get

get(name: str) -> Parameter | None

The parameter declared as name, or None.

of classmethod

of(*parameters: Parameter) -> Parameters

Parameters.of(a, b): the same as Parameters((a, b)), read aloud.

check_arguments

check_arguments(
    parameters: Parameters,
    arguments: Mapping[str, Any],
    *,
    unknown_arguments: UnknownArguments = UnknownArguments.REJECT,
) -> dict[str, Any]

The shape check: what a caller can get wrong that the declaration can see.

Presence, JSON type, format, choices and nullability, at every level of nesting, with no query. It is what runs at enqueue time and in front of an untrusted caller's Validator, so a malformed call is refused before it costs a row lookup, and it is no stricter than the libraries behind it: a decimal accepts a JSON number as well as a string, because every decimal validator does, and a check stricter than the worker refuses what would have run.

  • A required parameter with a declared default is not refused when absent, because the Validator will supply the default. The default is not applied here: whether an omitted argument arrives as its default is the validating library's behaviour, not the shape check's.
  • bool is neither an integer nor a number, although Python makes it an int: a JSON true sent for a count is a caller's mistake, not the number one.
  • NaN and the infinities are refused wherever the declaration reaches. JSON has no spelling for either, so they are outside the number type, and this is a type rule rather than a stricter check - although pydantic's float and the dataclass adapter's both take one.
  • choices constrain the value, and an array's choices constrain each of its elements, since an array is never one of a list of scalars.
  • An object with no fields is free-form: its type is checked and its contents are not, because nothing is declared inside it to check against. An array with no items takes any element, and each is still one of the array's values: its choices and the finiteness rule reach it, while an element that is itself an object or an array is not walked.

The closed argument set applies at every level, inside each row and each nested object as well as at the top. Closed only at the top, an unknown key in a row would reach the row's constructor and come back as a TypeError naming no row. REJECT refuses the key where it appeared; IGNORE drops it from the returned copy.

Returns a cleaned copy and never modifies arguments. The containers it walks are new - the top-level mapping, each declared object, each array - while a value it does not look inside, such as a free-form object, is passed through as the caller's.

Raises:

Type Description
InvalidArguments

carrying every problem found as one tree addressed by path, rather than the first; the tree's shape is documented on InvalidArguments.

coerce_flat

coerce_flat(parameters: Parameters, raw: Mapping[str, Any]) -> dict[str, Any]

Flat strings to the JSON-like primitives a typed caller would have sent.

Every flat-string transport - argv, a query string - needs this, and needs it to agree, so it is the kernel's rather than each transport's: validation libraries read strings differently from one another, and after this every Validator sees an argv call exactly as it would see typed JSON. It is directed by the declaration: an integer or a number is parsed, a boolean is parsed from exactly four spellings, and everything else is left as the string it arrived as. A decimal, a date-time and a date stay strings, for the Validator to decode, exactly as they would arrive off a JSON wire.

  • An array's elements are each coerced by its items type, and a single value becomes a one-element list, since a flat transport sends a one-element list as a bare value. None is not wrapped: an option argparse never received is absent, not a list holding a null.
  • A parameter with nested Parameters is refused by name: an object, or an array of objects, has no flat spelling, and passing its strings through would leave the refusal to a type error that does not say why.
  • A key the parameters do not declare passes through untouched, so the closed argument set in check_arguments stays the one place that refuses it, under the caller's policy.
  • A value that is not a string passes through untouched: it has already been decoded by someone, and this does not second-guess them.

Raises:

Type Description
InvalidArguments

every refusal at once, a parameter's at its name and an array element's at its index: {"ids": {1: ["..."]}}.

InvalidArguments

Bases: DispatchError

The arguments were refused: their shape, or the Validator's verdict on them.

Parameters are declared and arguments are supplied, and this refuses the arguments. It is raised by the shape check, the closed argument set, coerce_flat, a Validator and the relation writes, so every refusal of input reaches a transport as one type.

Not a service validation error, and not a subclass of one. A service raising ServiceValidationError is stating a business rule about well-shaped input; this says the input was not well-shaped, or did not validate. An MCP server answers the two differently on purpose.

detail is a tree addressed by path, because nested arguments fail inside rows:

  • a field: {"title": ["..."]}
  • a nested object: {"author": {"name": ["..."]}}
  • rows, keyed by their int index, only the rows that failed: {"books": {1: {"title": ["..."]}}}
  • a message about a row itself, under non_field_errors inside the row

Every leaf is a list of str, so the whole tree is JSON-serializable once the integer keys are written as strings, which is what json.dumps does.

Validation

Validator

Bases: ABC

The kernel's validation contract.

parameters() declares what the Validator takes, and validate() turns JSON-like primitives into validated values or raises InvalidArguments. An adapter reads its library's own declaration into parameters(), and must not query doing it: a transport describes a spec before any call.

Nominal on purpose. An adapter subclasses this, and subclassing is the opt-in. A structural check on a method name is the gate that lets stock classes through by accident: an is_valid protocol matches a Django form and a DRF serializer, and a validate protocol matches a DRF serializer and a pydantic model class, none of which was written to this contract.

No partial, no instance, no many. Those are one HTTP framework's update and list semantics: off HTTP, what an update may omit is a property of the declared parameters, the row it acts on is target resolution, and a list is an array parameter.

Sync-only. A Validator may query - a uniqueness check does - so dispatch runs it in the executor on the async path, and it must not be async def.

validate() receives only the arguments its own parameters() declare, already through the shape check. It returns the values the operation receives, keyed by name; a nested row may come back as whatever the library builds (a dataclass instance, a model instance), since the mutation helpers read either.

parameters abstractmethod

parameters() -> Parameters

What this Validator takes, as a declaration. Never queries.

validate abstractmethod

validate(arguments: Mapping[str, Any], context: ValidationContext) -> dict[str, Any]

Validated values, or InvalidArguments. May query; never async def.

ValidationContext dataclass

The principal, and the target the operation acts on.

target is here, rather than a constructor keyword the way DRF takes instance=, because the kernel is what resolves it: target resolution runs before validation, so an update's uniqueness check can exclude the row being updated. It is the resolved row for a spec with an instance selector, the resolved queryset for one with a collection selector, and None for a create.

UnknownArguments

Bases: str, Enum

The closed argument set's policy, applied at every level of nesting.

REJECT is the default everywhere. An untrusted caller's undeclared key is refused before it costs a query, and a trusted one's typo - a command's misspelt option, a task enqueued with last month's argument name - fails where the caller can see it rather than being dropped on the floor.

There is no pass-through. An undeclared argument reaching a callable is exactly what the closed set exists to prevent: on a selector, with no Validator in front of it, it would let a caller supply a keyword the operation's author never offered.

REJECT class-attribute instance-attribute

REJECT = 'reject'

Refuse the undeclared key with InvalidArguments, at the level it appeared.

IGNORE class-attribute instance-attribute

IGNORE = 'ignore'

Drop the undeclared key and carry on.

Output

Presenter

Bases: ABC

Turns one value an operation produced into JSON-like data.

output() declares the result and present() renders one value. They are separate because describing the result is not optional where rendering is: an HTML transport hands the object to a template and never renders it, but its confirmation page still reads what the result declares.

present takes one value. A list result is presented item by item by dispatch's present, so a Presenter never has to guess whether it was handed a row or a collection of them.

Sync-only. Presenting a row that reads a relation is a query, and the kernel cannot tell in advance whether the row was prefetched, so the async path runs every Presenter in the executor.

output abstractmethod

output() -> Output

What present returns, as a declaration. Never queries.

present abstractmethod

present(value: Any) -> Any

One value to JSON-like data. May query; never async def.

Output dataclass

The ordered fields of an operation's output, read without rendering anything.

Readers that never render: an agent tool's output schema, a capability manifest, a command's table header when there are no rows, a confirmation page's target display. Ordered, because a table's columns are, and the order is declaration order.

Paired with Parameters: that is what goes in, this is what comes out.

names

names() -> tuple[str, ...]

Every field name, in declaration order.

get

get(name: str) -> OutputField | None

The field declared as name, or None.

OutputField dataclass

One field of an operation's output, declared without rendering anything.

The output-side counterpart of Parameter, carrying what a reader needs that names and types alone do not give:

  • label: a readable header. Libraries disagree here (one invents Id from id, others give nothing), so the adapter supplies what its library has and None means it has none.
  • choices: value-and-display pairs, because a table shows the display and a schema states it as a title, and a bare value list gives neither.
  • always_present: whether the key is in every rendered row. A rendering rule rather than a type - one library drops a read-only key it cannot read where another always emits it - so the adapter supplies it, and a schema lists only always-present keys as required.
  • marking: how an agent audience is shown the field.

fields and items describe a nested object and an array's element, and items_nullable whether a null may stand in for an element, as on Parameter.

FieldMarking dataclass

How one output field is presented to an agent audience.

Carried on the OutputField it marks, so it belongs to the declaration of what an operation returns rather than to whichever library rendered it. That is what lets a transport's projection for an audience work over a dataclass, a pydantic model and a DRF serializer alike: each adapter reads its own library's declaration into Output, marking included, and the projection reads only Output. The kernel carries the marking and applies none of it.

The marking lives on the field, not in a list beside the output. That is what lets it travel into a nested output with no hoisting rule, and what stops a rename from silently desyncing it from a name the parent maintains.

description class-attribute instance-attribute

description: str | None = None

Audience-facing description, replacing the field's help text for this audience only.

Help text is shared with every human reader, so it cannot say "opaque handle, never read this out". This can, without changing a word of what a human reader sees.

handle classmethod

handle(description: str | None = None) -> FieldMarking

An opaque identifier: passed to other tools, never spoken to a user.

hidden classmethod

hidden() -> FieldMarking

Plumbing: dropped from the projected payload.

label classmethod

label(description: str | None = None) -> FieldMarking

The field that names this record for a human.

FieldAudience

Bases: str, Enum

Whether an output field is content, a name, a handle, or plumbing.

An operation's output is often read by more than one kind of consumer: a frontend or a command that decides its own presentation, and a model that will read the payload aloud unless told otherwise. The two want different subsets of the same fields, and the difference is not a transport difference - an MCP server and an in-process toolset want the same thing as each other and something different from a browser.

So the axis this names is audience, not protocol. Declared per field through FieldMarking, on the field's OutputField.

Inheriting from str keeps the value JSON-serializable and print-friendly while still behaving as a proper enum for is / ==.

CONTENT class-attribute instance-attribute

CONTENT = 'content'

The default: ordinary data, shown to every consumer.

LABEL class-attribute instance-attribute

LABEL = 'label'

The field that names this record for a human. At most one per output.

HANDLE class-attribute instance-attribute

HANDLE = 'handle'

An opaque identifier. Passed to other tools, never read out to a user, and never re-spelled by a choice's display - a handle is somebody else's input.

HIDDEN class-attribute instance-attribute

HIDDEN = 'hidden'

Plumbing. Left out of what an agent audience is shown.

The kernel declares the marking and applies none: leaving the field out is the job of the transport that renders for that audience.

JSON Schema

spec_input_schema

spec_input_schema(
    spec: ServiceSpec | SelectorSpec,
    *,
    unknown_arguments: UnknownArguments = UnknownArguments.REJECT,
) -> dict[str, Any]

parameters_schema over spec.parameters().

The whole argument set dispatch checks: a selector spec's reads, or a service spec's target selector's reads followed by its Validator's parameters. Pass the unknown_arguments the transport dispatches with, because the schema closes the argument set exactly when dispatch does.

Read on every call, as parameters() is, so a Validator reading a model-backed declaration is asked only once the app registry is ready.

spec_output_schema

spec_output_schema(spec: ServiceSpec | SelectorSpec) -> dict[str, Any] | None

The JSON Schema of what present returns for spec.

None when spec.output() is None: a spec with no presenter declares nothing about what it returns, and a shape invented for it would be a guess every client then trusts.

Otherwise the item's output_schema, shaped by what dispatch returns for this kind of spec:

  • A LIST selector spec, or a service spec whose output selector is a LIST, presents every row: {"type": "array", "items": <item>}.
  • A RETRIEVE selector spec with allow_none presents None for a missing row, so the item's type gains "null". Without allow_none a missing row is not-found, which a transport answers in its own terms and never presents, so the item stays as it is.
  • A service spec whose output selector is a RETRIEVE gains "null" whatever its allow_none says: once the service has run, a re-read that finds nothing is None rather than not-found, because reporting not-found would tell the caller a committed write did not happen.
  • A service spec with no output selector presents what the service returned, described by its presenter as the item. Whether a service may return None is not in its declaration, so it is not stated.

parameters_schema

parameters_schema(
    parameters: Parameters,
    *,
    unknown_arguments: UnknownArguments = UnknownArguments.REJECT,
) -> dict[str, Any]

The JSON Schema object an argument set must satisfy, read from parameters.

One dialect whichever library declared the parameters, because every adapter reads its library into Parameters and this reads only that. The policy is precise: never false, and not incomplete where the declaration knows. What it still leaves out is what a Parameter does not carry.

  • An object is {"type": "object", "properties": {...}}, with "required" listing, in declaration order, the names the shape check refuses when absent - a required parameter with no declared default, since one with a default is not refused - and left out when there are none.
  • "additionalProperties": false is emitted under REJECT only, and at exactly the levels check_arguments closes: the top, every object parameter that declares fields, and every row of an array whose items are Parameters - an empty declaration included, since it refuses every key. Under IGNORE an undeclared key is accepted and dropped, so stating false there would refuse a call that runs.
  • An object parameter with no fields is {"type": "object"}: what is still known, constraining nothing a valid call could fail. It is also the node an adapter truncates a recursive declaration to.
  • An array is {"type": "array", "items": ...}: a type name's {"type": t}, the object schema of its rows, or {} for an array that declares no items and accepts any element.
  • A scalar is {"type": t} with its "format" when it declares one. A decimal is therefore {"type": "string", "format": "decimal"}: the form every decimal validator reads without a binary float's rounding. The shape check also accepts a JSON number, which this does not advertise.
  • Nullable makes the type [t, "null"], on objects and arrays too, and items_nullable does the same to an array's items.
  • Choices are an "enum" beside the "type" the parameter declares. On an array they go on items, because the shape check applies them to each element. A nullable parameter's enum gains None when its choices do not list it, since an enum claims the whole set of accepted values. An array's items do so by items_nullable alone, whether or not the array itself may be null.
  • A default is emitted when one is declared and survives JSON encoding; a Decimal, a date or a callable is left out rather than misstated. So is a choice set holding such a value, since listing only some of the accepted values would be false.
  • help becomes "description".

Nothing else, deliberately. No title, because a Parameter has no label. No $defs or $ref ever: most MCP clients refuse a schema with a reference in it, so every schema is flat and self-contained, however deep the declaration nests. No $schema key, which a transport adds if its wire wants one. And no bounds (maxLength, minimum), which Parameters does not carry yet; the Validator enforces them.

Every call builds a new schema, so a transport may add its own keys to the result without reaching the declaration or another transport's copy. Defaults and choice values are placed in it as declared, not copied.

unknown_arguments accepts the policy's plain value as well, and a value that is neither raises ValueError here rather than quietly describing one policy or the other.

output_schema

output_schema(output: Output) -> dict[str, Any]

The JSON Schema object of one presented item, read from output.

One item: whether the operation returns a list of them, or may return None instead, is the spec's to say, and spec_output_schema wraps this accordingly. The dialect is the input side's, with the differences a description of what came back needs:

  • An object is {"type": "object", "properties": {...}}, with "required" listing the fields whose always_present is true, in declaration order, and left out when there are none. required says the key is there, not that its value is non-null.
  • No additionalProperties. Output is not a closed set a caller can get wrong, and a transport may add keys of its own to what it sends.
  • A field states its type, [t, "null"] when nullable, its "format" when it declares one, and "title" from its label when it has one. Adapters set a label only where the author wrote one, so a title is never a field name restated in worse English.
  • Nesting is as on input: fields make an object schema, an object with none is {"type": "object"}, and an array's items are {"type": t}, a nested item's object schema, or {} when undeclared. items_nullable makes the item's type [t, "null"].
  • Choices are (value, display) pairs. When every display is just str(value) they are an "enum" of the values, since a title restating the constant teaches nothing. Otherwise they are a "oneOf" of {"const": value, "title": display}, so the human phrasing travels with each value; title constrains nothing, and the accepted set stays exactly the constants. The "type" is stated either way. A nullable field adds None ({"const": None} in a oneOf) when its choices do not list it, because both forms claim the whole set of values. On an array the choices describe each element and go on items, widened by items_nullable rather than the array's own nullability. A choice set holding a value JSON cannot carry is left out whole.

marking is not emitted. Showing a field to an agent audience, or leaving it out, is a projection over this schema, and projection is not the kernel's yet; the kernel declares a marking and applies none, which is why a HIDDEN field is described here like any other.

As on input: no $defs or $ref, no $schema key, and a new schema on every call.

Dataclass adapters

DataclassValidator

Bases: Validator

A Validator whose declaration is a standard-library dataclass.

The reference adapter: what any adapter owes the kernel, done with nothing but the standard library and Django. parameters() reads the fields and their annotations; validate() decodes JSON-like arguments into those types and builds the instance, nested dataclasses included.

The annotations it reads, and what each declares:

  • str, int, float, bool: their JSON types. float takes an integer as well, since JSON writes 2.0 as 2; nothing else crosses types, so true is not an int.
  • Decimal, datetime, date: a string, with the format it decodes from. A decimal takes a JSON number too, as every decimal validator does.
  • X | None: nullable. Inside a list, list[X | None], it is the element that is, declared as items_nullable and not as the array's own nullability.
  • Literal[...] and an Enum (Django's TextChoices and IntegerChoices included): choices, of the JSON type the values share. An Enum decodes to the member.
  • list[X]: an array; list[SomeDataclass] is an array of rows, each declared as nested Parameters. For any other element, Parameter.items can hold only its JSON type, so a list of decimals is declared as an array of strings and decoded here. A list of choices declares them on the array, where they constrain each element.
  • a dataclass: an object, with its own fields as nested Parameters.
  • X | UnsetType: the argument may be left out, with or without a default, and the field then holds UNSET rather than a value a service would act on. It adds no JSON type, since no wire can send it.

A field with a default, or a default_factory, is optional, and one with neither is required. Only a plain default is reported as the Parameter's default: a factory's value is computed per instance, so it is not a declaration a transport could print. A field declared init=False is the dataclass's to compute, so it is neither taken nor returned. Annotated is looked through, and an annotation outside this list is refused with ImproperlyConfigured naming the field, never read as a string.

A row that updates must declare its key. A relation write matches an incoming row to an existing one by its primary key, so a row dataclass used for an update needs pk: int | None = None: present for a row that exists, absent for a new one. Without it no incoming row matches an existing one, and every update replaces every row. Nothing fails when that happens, which is why it is written down here.

Time zones follow Django's forms, not pydantic: an offset-less date-time is made aware in the current time zone when USE_TZ is on, so a date-time means the same thing whichever library validated it.

Every failure is reported, not the first, as one InvalidArguments whose detail is addressed by path: {"books": {1: {"title": [...]}}} for a field inside the second row, only the rows that failed, and a message about a row itself under non_field_errors inside it. A ValueError or Django ValidationError raised by the dataclass's own __post_init__ is a message about that object, and lands there too.

It is correct without the shape check in front of it, for a caller that uses it directly: a wrong JSON type is a refusal, not a crash, and an argument no field declares is refused as the dataclass's own constructor would refuse it.

The annotations are read on first use rather than at construction, for the reason a spec's parameters are: specs are built at import time, and an annotation may name a class its module cannot resolve yet.

parameters

parameters() -> Parameters

The dataclass's __init__ fields, as Parameters. Never queries.

validate

validate(arguments: Mapping[str, Any], context: ValidationContext) -> dict[str, Any]

The built instance's __init__ fields, by name, or InvalidArguments.

Nested values stay dataclass instances, which is what the mutation helpers read a row from.

DataclassPresenter

Bases: Presenter

An operation's output declared as a dataclass, rendered from any object.

output() reads the fields and annotations the way DataclassValidator does, so one dataclass declares the same types going in and coming out.

present(value) reads each declared field by attribute, so the value may be an instance of the dataclass or anything carrying the same names: a model row is the case this is for, and it is never converted into the dataclass first. A nested dataclass field is read the same way off the attribute's value, and a list field may be a related manager, read through .all() so a prefetch is used when there is one. Encoding is JSON's: a Decimal as its string, a datetime or date in ISO 8601, an Enum as its value.

A field whose value is UNSET is left out of the rendered object, and only a field whose annotation admits UnsetType is declared as possibly absent (always_present=False). Labels are None: a dataclass has no label of its own, and a reader can title-case a name as well as an adapter.

Choices are (value, display) pairs. A Django Choices enum supplies its label, translated when output() is called; a plain Enum or a Literal has no display of its own, so the value's str stands in.

A FieldMarking is declared on the field it marks, in its annotation: id: Annotated[int, FieldMarking.handle()].

output

output() -> Output

The dataclass's fields as an Output, in declaration order. Never queries.

present

present(value: Any) -> Any

One value's declared fields, read by attribute and encoded to JSON-like data.

None is presented as None: it is JSON's null, not a row whose every attribute is missing.

Forms adapter

FormValidator

Bases: Validator

A Validator whose declaration is a Django form class.

parameters() reads the form's class-level fields, base_fields, in declaration order; validate() binds the arguments to a new form and returns what it cleaned. Every rule the form states stays the form's to enforce - its fields' validators, clean_<field>(), clean() and, on a ModelForm, the model's own validation and uniqueness checks - and the shape check in front of it enforces what the declaration can see.

The fields it reads, and what each declares, the first match winning:

  • BooleanField and NullBooleanField: a boolean.
  • DecimalField: a string in the decimal format, which takes a JSON number as well. FloatField: a number. IntegerField: an integer.
  • DateTimeField and DateField: a string in the date-time or date format. TimeField and DurationField: a string.
  • ChoiceField: choices, with the empty choice dropped and option groups flattened. A plain one cleans with str and matches the string against each choice's str, so it takes a string and its choices are declared as strings, whatever type they were written in. A TypedChoiceField takes the JSON type all of its values share. A choice set given as a callable is computed per form and may query, so it is not read: the field is a string with no choices, which every choice field accepts, and the form checks the choice.
  • MultipleChoiceField and TypedMultipleChoiceField: an array of that, whose choices constrain each element.
  • ModelChoiceField: the JSON type of the model field it matches rows on - its to_field_name, or the primary key - and no choices, since they are rows and reading them would query. ModelMultipleChoiceField: an array of that. They validate to the row, and to a queryset of rows.
  • any other CharField (email, URL, slug, UUID, regex, IP address): a string.

Refused with ImproperlyConfigured, naming the form, the field and its class: a FileField or ImageField, a JSONField, a MultiValueField such as SplitDateTimeField, a ComboField, any field the list does not cover, and a ModelChoiceField with no queryset or matching rows on a model field with no JSON type. The declaration is read at construction, so a form that cannot be described fails where the spec is written; nothing in it queries or needs a translation, so building a spec at import time is safe. help is the exception, and is read from help_text on each parameters() call, in the language active then.

What the Parameters say about presence is what the form does:

  • required is the field's own required. For a BooleanField that is a statement about presence too: an absent checkbox cleans to False, which a required one refuses, as it refuses false. A NullBooleanField is never required, whatever it says, because its validation is empty and it takes an absent value as None.
  • nullable is not required, and always true for a NullBooleanField. A form reads None as an empty value for every field, so an optional field takes null and cleans it to its empty value, and a required one refuses it.
  • default is always UNSET: a field's initial is what an unbound form displays, and a bound form cleans an absent optional field to its empty value, never to its initial.

The types are JSON's, and do not cross. A form reads its data as an HTML post's strings, so it also takes a value of another type through str - a number for a CharField, 1 for the choice "1" - and the shape check refuses one, as it does for every declaration: a caller sends the declared type. The empty choice is the other case: an optional choice field reads "" as no value, and a caller sends null for that instead.

An argument is in its wire form, which no locale touches. A number field reads a string through the active locale when it is localized, and under one whose thousands separator is a dot that reads "1.500" as fifteen hundred. So a string argument for an IntegerField, FloatField or DecimalField is bound as the Decimal it spells, which the field reads as the number it is, localized or not.

Declare the fields on the class. A field a form adds or changes in its own __init__ is not in base_fields, so it is not described, and under UnknownArguments.REJECT the closed argument set refuses its key before the form is built. A field declared disabled is not a parameter either: the form ignores what a caller sends for it and cleans its initial, which validate() returns with the rest.

A ModelForm is bound to a copy of the target. When the ValidationContext carries a row of the form's model, the form is built with instance= a shallow copy of it, so its uniqueness checks exclude the row being updated. It is a copy because a ModelForm writes the cleaned values onto its instance while it validates: handed the target itself, it would change the row the service receives before the service runs, and a helper that saves only what changed, such as update_from_input, would find nothing to save. Any other target - the queryset a collection selector resolves, or none - binds the form as a create. The form is built from data= and instance= alone, so one whose __init__ requires more, such as the signed-in user, cannot be used as it is.

A refusal is one InvalidArguments carrying the form's own errors and messages, {field: [messages]}, with the form-wide ones Django keeps under "__all__" moved to non_field_errors, the key every other refusal uses. An argument no field declares is ignored by the form, as Django ignores any key it has no field for: the closed argument set in front of it is what refuses one.

parameters

parameters() -> Parameters

The form's class-level fields, in declaration order, as Parameters. Never queries.

validate

validate(arguments: Mapping[str, Any], context: ValidationContext) -> dict[str, Any]

The form's cleaned_data, or InvalidArguments carrying its errors.

A ModelChoiceField cleans to the row and a ModelMultipleChoiceField to a queryset, which is what the service receives.

Pydantic adapters

Installed with the pydantic extra and imported from django_service_specs.adapters.pydantic.

PydanticValidator

Bases: Validator

A Validator whose declaration is a pydantic model.

parameters() reads the model's fields into Parameters, and validate() hands the arguments to the model: pydantic builds the instance, nested models included, and its own validators run as they would for any other caller.

The annotations are read as DataclassValidator reads them, from one shared table, so a model and a dataclass declaring the same field declare the same parameter: str, int, float, bool; Decimal, datetime and date as strings with their format; X | None as nullable; Literal[...] and an Enum as choices; Annotated looked through; and list[X] of any of these, where list[X | None] makes the element nullable and not the array. On top of those:

  • a nested model is an object with its own fields as nested Parameters, and list[SomeModel] an array of rows;
  • dict[str, X] is an object with no declared fields, whose values are pydantic's to validate;
  • a model that contains itself is described four times on one path (MAX_APPEARANCES in the adapter's utils), and deeper levels as an object with no fields. pydantic still validates every level.

Any, a union of two types, UnsetType, a RootModel and anything else without one JSON type is refused with ImproperlyConfigured naming the model and the field, never read as a string.

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 (the name alone when the model sets validate_by_alias=False). An AliasPath or AliasChoices is not one name, and is refused. validate() returns the values keyed by the field name whatever the wire called them, because that is the keyword the service is called with.

required is pydantic's own is_required(). The default is reported when JSON carries it as itself; a default_factory, or a default such as a Decimal or a date, is not reported, and the field is optional all the same. help is the field's description.

A row that updates must declare its key, as with the dataclass adapter: pk: int | None = None on the row's model, or no incoming row matches an existing one and every update replaces every row.

Refusals keep pydantic's words, addressed by path into the kernel's tree: {"books": {1: {"title": ["..."]}}}, with a row's index as an int. A message about a model itself - its model_validator refusing - sits under non_field_errors inside that model, or at the top for the model being validated. The kernel's shape check runs first and refuses a wrong JSON type in the kernel's words, so pydantic's are what a caller reads for its own rules: bounds, patterns, validators.

Time zones are pydantic's: an offset-less date-time stays naive, where DataclassValidator makes it aware as Django's forms do. Declare the offset, or convert in the service.

The declaration is read at construction, so an unmappable field fails where the spec is written. A model whose annotations named a class not yet defined when it was built is completed first, as pydantic completes it on first use.

parameters

parameters() -> Parameters

The model's fields as Parameters, named as pydantic validates them. Never queries.

validate

validate(arguments: Mapping[str, Any], context: ValidationContext) -> dict[str, Any]

Every field's validated value, keyed by field name, or InvalidArguments.

The context reaches pydantic's validators as info.context, a dict with "principal" and "target", so a validator can check a value against the caller or the row being updated. Nested values stay model instances, which is what the mutation helpers read a row from.

PydanticPresenter

Bases: Presenter

An operation's output declared as a pydantic model, rendered by that model.

output() reads the fields as PydanticValidator does, so one model declares the same types going in and coming out, and adds what only the output side has:

  • each field is named for the key model_dump(by_alias=True) emits: its serialization_alias, else its alias, else its name;
  • fields declared exclude=True are not output, and each computed_field is, after the fields, typed by its return annotation;
  • every field is always_present, because model_dump emits every key, except one with an exclude_if, which may be dropped;
  • label is the field's title when the author set one, and None otherwise: pydantic invents no label, and a reader can title-case a name as well as an adapter;
  • choices are (value, display) pairs: a Django Choices enum supplies its label, translated when output() is called, and a plain Enum or a Literal has no display of its own, so the value's str stands in;
  • a FieldMarking is declared in the field's Annotated metadata: id: Annotated[int, FieldMarking.handle()].

present(value) renders with the model itself, so its serializers and its JSON encoding are what a caller receives. The value may be an instance of the model, a model row, any object carrying the same names, or a mapping keyed by field name. Anything but an instance is read one declared field at a time, by attribute or by key, and the model validates what was read: a related collection may be a manager, read through .all() so a prefetch is used when there is one, where pydantic's own from_attributes would fail on it. A field the value does not carry is left to the model's default.

A value that does not fit the model raises pydantic's ValidationError as it is. What is presented is the operation's own value rather than a caller's, so a mismatch is a defect in the operation, not a refusal to report.

The declaration is read at construction, as the validator's is.

output

output() -> Output

The model's output fields, then its computed fields, as an Output. Never queries.

present

present(value: Any) -> Any

One value, validated by the model and dumped as JSON data by alias.

None is presented as None: it is JSON's null, not a row whose every attribute is missing.

Dispatch

dispatch

dispatch(
    spec: ServiceSpec | SelectorSpec,
    *,
    principal: Any,
    arguments: Mapping[str, Any],
    grant: Grant | None = None,
    pool_seeds: PoolSeeds = DEFAULT_POOL_SEEDS,
    unknown_arguments: UnknownArguments = UnknownArguments.REJECT,
) -> DispatchResult

Run spec for principal with arguments, in a fixed order.

The same six steps for both kinds of spec, and for adispatch:

  1. check_arguments over spec.parameters() under unknown_arguments: the closed argument set and the shape check. A parameter a registered seed in pool_seeds occupies is refused before that, as a configuration error.
  2. authorize, honouring grant if it covers this spec and principal. Before anything is resolved, so a refused principal learns nothing about which rows exist.
  3. Resolution: a selector spec's own selector, or a service spec's instance or collection selector, called with the base pool plus the arguments its reads declare, then shaped, and a RETRIEVE collapsed to its row. A missing row is DispatchResult(kind="not_found"), returned rather than raised, unless the selector says allow_none.
  4. authorize_target on a retrieved row - never on a list, and never on a None that allow_none let through.
  5. A service spec's Validator, on only the arguments it declares, with the resolved target in its context.
  6. The run: the service, called with the principal, data (the validated values), instance or collection, each validated value by name and every registered seed, through run_service so spec.atomic holds and an async def service is bridged. Then the output selector, if declared, with the service's return as result.

A selector spec stops after step four: its selector is its run.

kind is "list" for a LIST selector spec or a service whose output selector is a LIST, and "instance" otherwise. Nothing is presented here; present does that, so a transport that hands the object to a template never pays for rendering it.

Raises:

Type Description
InvalidArguments

step one, or the Validator.

NotPermitted

step two or step four.

ImproperlyConfigured

a spec that declares no permissions, a parameter or a validated value named after a seed, or shaping declared on a selector that returned something other than a queryset.

adispatch async

adispatch(
    spec: ServiceSpec | SelectorSpec,
    *,
    principal: Any = None,
    principal_id: Any = None,
    arguments: Mapping[str, Any],
    grant: Grant | None = None,
    pool_seeds: PoolSeeds = DEFAULT_POOL_SEEDS,
    unknown_arguments: UnknownArguments = UnknownArguments.REJECT,
) -> DispatchResult

Run spec from async code: dispatch's steps, order and refusals.

Every concept is sync-only, and so is every spec-carried callable but one. A permission check, a Validator, a selector nested in a service spec, extend_queryset and a seed's resolver may each query, so each runs in Django's thread-sensitive executor, never on the event loop. Only the run may be async def - the service, or a selector spec's own selector - because it is the one callable no phase shares: a spec is written once for both entry points, and a concept written async def could not be called from dispatch.

So the phases run in one sync_to_async(thread_sensitive=True) hop, and the run's shape decides only what else joins it:

  • A sync run joins the hop, so a sync spec costs exactly one.
  • An atomic async def service joins it too, through the bridge in run_service: transaction.atomic is sync-only, so the transaction is opened on the executor thread and the coroutine is driven from inside it, where its own thread-sensitive ORM calls come back to the connection holding it.
  • A non-atomic async def run is awaited on the loop after the hop. Holding the executor thread while it awaits - a network call, say - would stall every thread-sensitive call queued behind it. What follows it (an output selector; a selector spec's shaping, row and object-level check) takes a second hop, and only when there is such a thing to do.

Exactly one of principal and principal_id. principal_id is resolved with resolve_principal inside the hop, because resolving it is itself a query; taking only the row would push that query into a hop of the caller's. A grant never covers a principal resolved here, since a grant is bound to the principal object it was minted for.

Raises:

Type Description
TypeError

both or neither of principal and principal_id.

PrincipalUnavailable

principal_id names no principal who may act.

(InvalidArguments, NotPermitted, ImproperlyConfigured)

as dispatch.

DispatchResult dataclass

The value an operation produced, and how to read it.

Not-found is reported, not raised: a required row that does not exist comes back as kind="not_found" and the transport decides what that means on its wire - a 404, an exit code, a tool error. Nothing here is a status code, because most transports have none.

Attributes:

Name Type Description
kind Literal['instance', 'list', 'not_found']

"instance" (one value, possibly None), "list" (a collection, a queryset when it can be one), or "not_found".

value Any

What to present: the selector's rows, or the service's return, or what its output selector re-read.

service_result Any

The service's own return, before any output selector.

instance Any

The resolved target - the row, or the collection's queryset.

data Any

The validated values the service received.

present

present(spec: ServiceSpec | SelectorSpec, result: DispatchResult) -> Any

Render result.value as JSON-like data, the way spec declares it.

The presenter is a selector spec's own, or a service spec's presenter_for_output(): its own, or its output selector's when it declares none.

  • kind="list": each item through the presenter, into a list. With no presenter the items are returned as they are, but still in a list: a queryset is evaluated here rather than handed back lazily, because apresent's caller is on the event loop, where the first iteration of an unevaluated queryset is a query Django refuses to run.
  • kind="instance": the value through the presenter. With no presenter, or a value of None - an allow_none retrieve that found nothing, a service that returned nothing - the value as it is. None is not a row, and asking a presenter to render one produces either a crash or an object of blank fields indistinguishable from a real row.

Raises:

Type Description
ValueError

kind="not_found". Presenting nothing as a success is the bug this refuses; the transport answers a not-found in its own terms (a 404, an exit code, a tool error) and never presents it.

apresent async

apresent(spec: ServiceSpec | SelectorSpec, result: DispatchResult) -> Any

present in one executor hop.

A presenter is sync-only and may query - reading a relation off a row the selector did not prefetch, or evaluating a list's queryset - and the kernel cannot tell in advance which, so the whole of present runs on Django's thread-sensitive executor rather than on the event loop. What comes back is plain data, safe to use on the loop.

Raises:

Type Description
ValueError

kind="not_found", as present.

bind_arguments

bind_arguments(
    spec: ServiceSpec | SelectorSpec,
    arguments: Mapping[str, Any],
    *,
    principal: Any,
    target: Any = None,
    unknown_arguments: UnknownArguments = UnknownArguments.REJECT,
) -> dict[str, Any]

Check arguments against the spec's parameters, then validate them.

The bind a transport calls when it binds input itself rather than through dispatch - an MCP server answering a malformed call before it opens a transaction, a queue validating a task in the worker. It is the same two steps dispatch takes, so a transport that binds for itself cannot drift from the gate dispatch applies:

  1. check_arguments over spec.parameters(), under unknown_arguments: the closed argument set and the shape check, at every level.
  2. For a ServiceSpec with a Validator, validate() on only the arguments the Validator's own parameters() declare - never the target selector's reads - with ValidationContext(principal, target).

Returns the Validator's values. A service spec with no Validator returns {}, because every argument it takes is its target selector's and none reaches the service. A SelectorSpec has no Validator and returns the checked arguments, which are what its selector is called with.

It does not resolve or authorize. Dispatch runs class-level authorization, target resolution and object-level authorization between these two steps, so the Validator sees the resolved row and a refused principal learns nothing about which rows exist. A caller of this function owns both: it authorizes before binding, and passes the row it resolved as target, or None for a create.

Raises:

Type Description
InvalidArguments

from either step. The shape check's tree carries every problem at once, and a Validator is only reached with well-shaped arguments.

HTTP

dispatch_request

dispatch_request(
    spec: ServiceSpec | SelectorSpec,
    request: HttpRequest,
    *,
    url_kwargs: Mapping[str, Any] | None = None,
    success_status: int | None = None,
    grant: Grant | None = None,
    pool_seeds: PoolSeeds = DEFAULT_POOL_SEEDS,
    unknown_arguments: UnknownArguments = UnknownArguments.REJECT,
) -> HttpResponse

Dispatch spec for request and answer it as JSON: the function a view calls.

  1. The principal is request.user, anonymous included, since the spec's permission check is what decides whether anonymous may act. An authenticated user whose is_active is false is refused as PrincipalUnavailable: a backend other than ModelBackend can log one in, and a deactivated principal never acts.
  2. The arguments are request_arguments for spec.parameters(), with url_kwargs merged last.
  3. dispatch, with grant, pool_seeds and unknown_arguments as given.
  4. The answer. A not-found result is 404 with a detail. Otherwise the presented value, bare, at success_status if given, else 204 for a service with nothing to present and 200 for everything else - the defaults djangorestframework-services answers with. A 204 has no body.

A refusal of either family, from any of the four steps, is answered by error_response. Anything else propagates, a configuration error included: it is wrong for every caller, so it belongs to the host's error handling rather than to one client's response. A URL kwarg spec does not declare is one, raised as ImproperlyConfigured: every URL kwarg is an argument, and the route is the host's.

CSRF is the host's middleware, as for any Django view: nothing here exempts a view, and csrf_exempt is one line for a host that wants it.

adispatch_request async

adispatch_request(
    spec: ServiceSpec | SelectorSpec,
    request: HttpRequest,
    *,
    url_kwargs: Mapping[str, Any] | None = None,
    success_status: int | None = None,
    grant: Grant | None = None,
    pool_seeds: PoolSeeds = DEFAULT_POOL_SEEDS,
    unknown_arguments: UnknownArguments = UnknownArguments.REJECT,
) -> HttpResponse

dispatch_request, from async code.

The same principal, arguments, statuses and refusals, reached through adispatch and apresent, so the kernel's async rule holds: nothing that may query runs on the event loop.

That includes reading the request, which is one executor hop of its own before adispatch's. request.user is the lazy object AuthenticationMiddleware leaves, and its first read is a session query, which Django refuses on the loop; Django 4.2, the floor, has no request.auser() to await instead. spec.parameters() goes in the same hop, because a Validator may read a model to declare what it takes. Building the response does not query and stays on the loop.

SpecView

Bases: SpecViewBase

One spec as a view: each request it answers goes through dispatch_request.

Every attribute can be set on a subclass or passed to as_view():

urlpatterns = [
    path("notes/", SpecView.as_view(spec=list_notes_spec)),
    path("notes/<int:pk>/rename/", SpecView.as_view(spec=rename_note_spec)),
]

The methods follow the spec. A SelectorSpec answers GET and HEAD, a ServiceSpec POST, and methods replaces that with the ones it names, from GET, HEAD, POST, PUT, PATCH and DELETE. Any other method is Django's own 405, whose Allow header lists exactly the methods served, and OPTIONS is Django's own answer. The URL kwargs are arguments, merged last so the route wins a clash, which means the spec declares each one: a route capturing a kwarg its spec does not declare raises ImproperlyConfigured on the first request it serves, since that is the host's configuration rather than anything a client sent.

as_view() checks the spec and the methods when the view is built, so a view with no spec fails when the URLconf is imported rather than on its first request.

CSRF is the host's middleware, as for any Django view. Nothing here is exempted, and csrf_exempt(SpecView.as_view(...)) is one line for a host that wants it.

Attributes:

Name Type Description
spec ServiceSpec | SelectorSpec | None

The operation served. Required.

success_status int | None

The status of a success, or None for the default: 204 for a service with nothing to present, 200 otherwise.

unknown_arguments UnknownArguments

What becomes of an argument no parameter declares.

pool_seeds PoolSeeds

The seeds the spec's callables are resolved with.

methods Sequence[str] | None

The HTTP methods served, in either case, or None for the spec's default.

answer

answer(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse

Dispatch the spec for request, the route's kwargs as url_kwargs.

AsyncSpecView

Bases: SpecViewBase

SpecView, with every handler async.

The same attributes, methods, checks and answers, through adispatch_request. Django serves a class-based view async only when every handler it defines is async def, and refuses a class that mixes the two, so every method a spec may be served on is async here, not only the ones it answers by default. Under ASGI this keeps the request off a worker thread until the kernel needs one; under WSGI Django runs it in an event loop of its own, which costs more than SpecView and buys nothing.

Not a subclass of SpecView, for the same reason: an async handler cannot stand in for a sync one.

answer async

answer(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse

Dispatch the spec from async code, the route's kwargs as url_kwargs.

SpecFormView

Bases: TemplateResponseMixin, ContextMixin, View

A ServiceSpec validated by a form, served as the page a person fills that form in on.

The spec's Validator must be a FormValidator, and its form class is the page's form:

urlpatterns = [
    path(
        "books/new/",
        SpecFormView.as_view(
            spec=add_book_spec,
            template_name="books/book_form.html",
            success_url=reverse_lazy("books"),
        ),
    ),
]

GET renders template_name with an unbound form under form, beside view and spec: the names Django's FormView uses, with extra_context merged in as Django's ContextMixin does. The spec's class-level permission check runs first, through authorize, so a page is never offered to a principal the post would refuse. The form is always unbound: showing an update's current row as its initial data is a page of its own, which this view does not build.

POST starts as GET does: the route first, then that check, and only then is the form built from the post. A refused post is answered with the page again, and dispatch's shape check comes before its own permission check, so without it a principal the spec refuses would be shown the page - every row a choice field lists, and extra_context - by posting a malformed form, and a form whose constructor reads rows would read them for that principal. The Grant it returns goes to dispatch, so the class-level check runs once; the object-level check is still dispatch's, on the row it resolves.

POST binds the form to the post and reads the arguments through its own widgets - a checkbox is True or False, a multi-select a list - because that is how Django reads a form, and a flat reading refuses a checkbox's "on". Only the form's fields are read, so the CSRF token and a named submit button never become arguments, and a disabled field is not read at all, as the form ignores what is posted for it. A field left blank is absent, as it is to the form. A number, date or date-time the field reads in one of its input formats - 10/25/2006, a localized 12,50 - is sent on in the form the shape check reads, through the field's own to_python, so the shape check never refuses what the form accepts. The URL kwargs are merged last, so the route wins a clash, as in request_arguments. Each is an argument, so the spec declares each one: a kwarg it does not is the host's misconfiguration, raised as ImproperlyConfigured under either unknown_arguments policy rather than answered as a refused post. Then dispatch:

  • A success redirects to get_success_url(result): success_url through Django's resolve_url by default, so a reverse_lazy or a URL name works, and a subclass overrides it to read the result.
  • A refusal of the arguments - InvalidArguments, or a ServiceValidationError - re-renders the bound form at 400 with the refusal placed on it by add_argument_errors. A service's string or list detail is about the whole form.
  • Any other refusal re-renders it with the message as a non-field error: ServiceConflict at 409, any other ServiceError at 422, any other DispatchError at 400. The statuses are error_response's, so a client reading a refused post - htmx, or Turbo, which will not render a failed post answered 200 - reads it the same way.
  • NotPermitted and PrincipalUnavailable raise Django's PermissionDenied, and a not-found result or ServiceNotFound raises Http404, for the host's own 403 and 404 pages.

A re-rendered form carries the refusal and nothing else: the page says what dispatch decided. The page's own form is bound to no row, so its own errors are not shown: its uniqueness check would tell an update page that an unchanged unique value was taken. After a shape-check refusal, then, the form's further checks - a length, clean(), uniqueness - answer the next post rather than this one.

as_view() refuses, when the URLconf is imported, a view with no ServiceSpec, a spec whose Validator is not a FormValidator, and a view with no success_url that does not override get_success_url.

Sync only, in this release. CSRF is the host's middleware, as for any Django view, and the template renders {% csrf_token %}.

Attributes:

Name Type Description
spec ServiceSpec | None

The operation served. Required.

template_name ServiceSpec | None

The page's template, as for any Django template view.

success_url Any

Where a successful post redirects, read by get_success_url: a path, a reverse_lazy, or a URL name.

pool_seeds PoolSeeds

The seeds the spec's callables are resolved with.

unknown_arguments UnknownArguments

What becomes of an argument no parameter declares.

as_view

as_view(**initkwargs: Any) -> Callable[..., HttpResponseBase]

Django's as_view, then the spec and the redirect checked, once, for every request.

get

get(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse

The page, with an unbound form, for a principal the spec's class check admits.

post

post(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse

Dispatch the posted form: a redirect on success, the form re-rendered on a refusal.

get_context_data

get_context_data(**kwargs: Any) -> dict[str, Any]

Django's context - view, and extra_context - with the spec served.

get_success_url

get_success_url(result: DispatchResult) -> str

Where a successful post redirects: success_url, resolved as redirect() reads it.

Override it to read result, such as the row a create returned.

served_spec

served_spec() -> ServiceSpec

spec, which as_view refused to build a view without.

request_arguments

request_arguments(
    request: HttpRequest,
    parameters: Parameters,
    *,
    url_kwargs: Mapping[str, Any] | None = None,
) -> dict[str, Any]

The arguments a request carries for parameters, typed as a JSON caller would send them.

Where they are read from depends on the method and the body:

  • GET and HEAD: the query string, which is flat. Any body is ignored.
  • Any other method, with an application/json body: the body, read as it is. It must be a JSON object; an empty body is no arguments, as DRF's parser reads one. Its values are not coerced: a JSON caller that sends "5" for an integer has sent a string, and the shape check says so.
  • Any other method, with a form body (application/x-www-form-urlencoded or multipart/form-data): the fields, which are flat. Django parses one into request.POST for POST alone, so a PUT, PATCH or DELETE body is parsed here the same way rather than arriving empty. Any other body is refused as UnsupportedMediaType rather than read as none, since an operation whose parameters are all optional would then run with nothing; a request with no body carries no arguments, whatever its Content-Type says.

A flat source is read by the declaration. A parameter of type array takes every value its key was sent with, and anything else takes the last, as QueryDict reads a repeated key. A blank value is absent unless "" is one the parameter can take: a plain string can, and a number, a boolean, a date, a decimal, or a string whose choices leave the blank out cannot. A flat wire has no other spelling of "left blank", and an empty ?count= or ?since= from a filter form is not a refusal to count or a malformed date. Each blank element of an array is read by the same rule, the whole array absent once nothing is left. Django's csrfmiddlewaretoken is dropped. What remains goes through coerce_flat, which leaves an undeclared key as it is, so the closed argument set in dispatch stays the one place that refuses it, under the caller's policy.

Files are not arguments. Parameters have no file type, so an upload in a multipart body is never one. A POST's uploads are in request.FILES, which Django fills for POST alone: those in a PUT, PATCH or DELETE body parsed here are dropped.

URL kwargs are merged last, through coerce_flat as well since a path segment with no converter is a string. The route wins a clash, as it does in djangorestframework-services: a client-supplied value must not move the route's scope, so /notes/4/ with pk=9 in its body is about note 4. On a flat request the two are coerced together, so every refusal comes back at once and a clashing client value the route replaced is never refused.

Raises:

Type Description
InvalidArguments

a JSON body that does not parse or is not an object, under non_field_errors; or coerce_flat's refusals, each at its parameter's name.

UnsupportedMediaType

a body that is neither JSON nor a form.

ImproperlyConfigured

a URL kwarg parameters does not declare, which is the host's route rather than the client's request; see route_arguments in this subpackage's utils.

error_response

error_response(exc: DispatchError | ServiceError) -> JsonResponse

exc as JSON, at the status djangorestframework-services answers it with.

One ladder for both families, at djangorestframework-services' statuses. One body differs from its answer: a service's string or list detail, which DRF answers as a bare list and this as a field map under non_field_errors, so every 400 here has one shape:

Refusal Status Body
InvalidArguments 400 the error tree
ServiceValidationError 400 its detail, as a field map
NotPermitted, PrincipalUnavailable 403 {"detail": message}
ServiceNotFound 404 {"detail": message}
ServiceConflict 409 {"detail": message}
any other ServiceError 422 {"detail": message}
UnsupportedMediaType 415 {"detail": message}
any other DispatchError 400 {"detail": message}

Every 400 body is a field map. InvalidArguments is its tree, whose integer row keys JSON writes as strings. A ServiceValidationError's mapping is its body as it is, and a string or a list goes under non_field_errors, where a message about the input as a whole sits in the tree too. A lazy translation is one message, not a sequence of characters, and renders in the active language: JsonResponse's encoder renders every lazy string it meets, messages included.

Never 401. A session-authenticated view has no challenge to answer, and a 401 tells a client to start an authentication flow it has no way to finish. An anonymous caller a permission check refuses is a 403, like any other refused principal.

A DispatchError no row names - raised by nobody in this package, or a subclass a later release adds - is still a refusal of the call rather than the operation's own verdict, so it answers 400 rather than 422.

UnsupportedMediaType

Bases: DispatchError

A request carried a body that is neither JSON nor a form.

Refused rather than read as no arguments. Read as none, a PATCH sent as text/plain or application/merge-patch+json to an operation whose parameters are all optional would run with nothing and answer success, which tells its client the change was made. A request with no body is not refused, whatever it is labelled, since there is nothing in it to misread.

A refusal of the call rather than the operation's verdict, so a DispatchError, answered 415 as djangorestframework-services answers it.

add_argument_errors

add_argument_errors(form: BaseForm, detail: Mapping[Any, Any]) -> None

Place a refusal tree on a bound form, beside the form's own errors, never twice.

detail is an InvalidArguments tree, or a ServiceValidationError's mapping. Each message goes where a person reading the form looks for it, through Django's public form.add_error:

  • A key naming one of the form's fields puts its messages on that field. A form is flat, so the only tree below a field is an array's element indices, which mean nothing to a person reading one control: the messages go on the field without them.
  • non_field_errors, and Django's own "__all__", go to the form's non-field errors as they are.
  • Any other key - a URL kwarg such as pk, or a key a service chose - goes to the non-field errors prefixed with its path, "pk: ...", so the reader knows what it is about. A nested tree is flattened to its leaves with the path joined by dots, "books.1.title: ...", a row's own non_field_errors adding nothing to the path.

Never a duplicate. A message the form already carries at the same place is not added again. The form validating the same data says the same thing the kernel does - a FormValidator's refusal is the form's errors, and the shape check spells its messages as Django's fields do - so without this most refusals would show twice. Reading form.errors runs the form's full_clean() first, as Django documents for add_error called from a view, so the form's own errors are always the ones compared against.

A leaf is a message whatever its type: a string, a lazy translation (which is not a str, and is never taken apart into characters), or a list of either. The form must be bound, as Django's add_error requires.

Authorization

PermissionCheck

Bases: ABC

Whether a principal may run a spec, and whether they may act on a row.

A principal and a spec, and nothing else: no request and no view, because most transports have neither. Instances rather than classes, so a check can carry its own configuration (a codename, a group) and a spec reads as the list of checks it applies.

has_permission is the class-level check, run before any row is resolved, so a principal refused here learns nothing about which rows exist. has_object_permission runs against the resolved target of a retrieve or an update; it allows by default, because most checks are class-level only.

message is what NotPermitted says when this check refuses; None keeps the generic wording.

Sync-only, like every concept: a check may query, so the async path runs it in the executor.

has_permission abstractmethod

has_permission(principal: Any, spec: ServiceSpec | SelectorSpec) -> bool

Whether principal may run spec at all.

has_object_permission

has_object_permission(
    principal: Any, spec: ServiceSpec | SelectorSpec, target: Any
) -> bool

Whether principal may act on target. Allows unless overridden.

Unrestricted

Bases: PermissionCheck

Anyone may run this spec: permissions=[Unrestricted()].

A spec that declares no permissions is refused at registration and at dispatch, because off HTTP there is no view whose policy it could inherit, and running it anyway is how an off-HTTP runner skips authorization with nothing warning. An operation that is genuinely open says so with this.

A named class rather than an empty list, so every open operation in a project is one search away, and an empty list computed by accident is not read as a decision.

Grant dataclass

Proof that the class-level check already ran, for one spec and one principal.

Dispatch enforces permissions by default. A transport that authorized through its own machinery first - an HTTP view running its framework's permission classes - passes a grant instead, rather than skipping the check by omission, and pays one class-level evaluation per call instead of two.

Bound to one spec and one principal, by identity: a grant carried to another operation or another user covers nothing, and dispatch refuses it.

target_checked says whether the object-level check ran too, which a transport can only claim once it has resolved the row itself. Without it, dispatch still runs has_object_permission on the row it resolves.

Not serializable. Pickling one raises TypeError, so a grant cannot ride a queue into a worker: a task runs minutes later against state that may have moved, and it re-authorizes by construction.

covers

covers(spec: ServiceSpec | SelectorSpec, principal: Any) -> bool

Whether this grant is for exactly this spec and this principal.

authorize

authorize(
    spec: ServiceSpec | SelectorSpec, principal: Any, *, grant: Grant | None = None
) -> Grant

Run spec's class-level permission checks for principal.

Dispatch enforces this by default. A transport that already authorized through its own machinery - an HTTP view running its framework's permission classes - passes the Grant it produced instead of paying for a second evaluation; the grant is honoured only if it covers this exact spec and principal, by identity, so a grant carried over from another operation or another user is refused rather than trusted.

Without a grant, spec.permissions is None is a configuration error, not the principal's fault: off HTTP there is no view whose policy an undeclared spec could inherit, so an operation that means to be open says so with permissions=[Unrestricted()].

Otherwise every check's has_permission must pass, in order; the first refusal raises NotPermitted with that check's own message, or the generic one when it declares none.

Returns:

Type Description
Grant

The grant that was honoured, or a freshly minted one covering this

Grant

spec and principal.

authorize_target

authorize_target(
    spec: ServiceSpec | SelectorSpec, principal: Any, target: Any, *, grant: Grant
) -> None

Run spec's object-level permission checks against a resolved target.

Dispatch calls this on a retrieved row, never on a list: a collection has no single object to check against, and a member-level rule belongs to the presenter or the query that shaped it.

grant.target_checked lets a transport that resolved the row itself and already ran the object-level check claim it once, so dispatch does not pay for it twice; otherwise every check's has_object_permission must pass, in order, and the first refusal raises NotPermitted with that check's own message.

spec.permissions is None means nothing to check here rather than a configuration error: authorize already refused an undeclared spec before any target could be resolved, so this function is never reached with one - iterating an empty tuple in its place is simpler than asserting an invariant this module cannot enforce.

resolve_principal

resolve_principal(identifier: Any) -> Any

Look up the user who is to act, from an identifier a transport carries.

HTTP has a session or a token to resolve a principal from; most transports this kernel dispatches for do not, so a caller hands the primary key it already has (a queue payload, a management command argument, an agent's own notion of who is asking) and this is the one place that becomes a user row.

Every way an identifier can fail to name someone who may act becomes PrincipalUnavailable, terminal and never a fallback:

  • no row with that primary key (DoesNotExist);
  • a malformed primary key (ValueError, TypeError, or Django's own ValidationError - a non-UUID string against a UUID primary key raises that one instead of ValueError);
  • is_active is False. Read with getattr rather than assumed, because a custom user model is not required to declare the field at all, and one that omits it is active by default.

Never AnonymousUser: an operation dispatched with no resolvable principal has no principal, not an anonymous one.

NotPermitted

Bases: DispatchError

A permission check refused the principal, or a grant did not cover the call.

Named for what a failed has_permission means, literally. Not PermissionDenied, which Django and DRF both own for their own exceptions, and not a service error: a model that reads a denial as a refusal to route around keeps trying, where a denial should end the attempt.

PrincipalUnavailable

Bases: DispatchError

The identifier names no user who may act: missing, malformed, or deactivated.

Terminal. Never a fallback to AnonymousUser, which would run the operation as somebody the caller did not name, and never a retry: a deleted row does not come back, and a deactivated account is deactivated on purpose.

Services

run_service

run_service(fn: Callable[..., Any], kwargs: dict[str, Any], *, atomic: bool) -> Any

Call fn(**kwargs) from sync code, optionally inside transaction.atomic().

An async def service is bridged transparently via async_to_sync. The bridge is not optional politeness: without it an async service returns its coroutine object to the caller un-awaited — no exception, and under atomic=True the transaction commits before the body would have run.

Async services under atomic=True route through arun_service, which owns the thread-sensitivity rule that keeps the ORM connection holding the open transaction the same one the inner async DB calls use.

arun_service async

arun_service(
    fn: Callable[..., Awaitable[Any]], kwargs: dict[str, Any], *, atomic: bool
) -> Any

Await fn(**kwargs), optionally inside transaction.atomic().

is_async

is_async(fn: Callable[..., Any]) -> bool

Return True if calling fn(...) produces a coroutine.

Handles plain async def functions, functools.partial wrapping one (via inspect.iscoroutinefunction's built-in unwrapping), and any callable whose __call__ is a coroutine function.

ServiceError

Bases: Exception

Raised by services to signal a business-rule failure.

Carries a message and nothing else, so a transport can surface it without the service depending on that transport. A structured payload belongs to a member that declares one: ServiceValidationError carries a field-keyed detail.

ServiceValidationError

Bases: ServiceError

Raised by services to signal invalid input or invalid state.

Distinct from ServiceError so a transport can tell "the arguments were fine but the business rule refused" from "the input itself is invalid" and map the two differently. detail may be a string, a dict (field → error(s)), or a list of errors.

ServiceConflict

Bases: ServiceError

The operation collides with the resource's current state.

A slot already taken, a row someone else moved first, a name already used. The resource is there and the request is well-formed; the two are simply incompatible right now, and a caller can often resolve it by re-reading and trying again — distinct from a plain ServiceError, which says "understood, and still not doing it" with no such implication.

def slot_is_free(*, user, data):
    if Event.objects.filter(owner=user, day=data["day"], hour=data["hour"]).exists():
        raise ServiceConflict(f"{data['day']} at {data['hour']}:00 is taken.")

A transport that has never heard of this type still handles a ServiceError, and one that wants to do better matches on the class. It must match before its generic ServiceError handler, or the subclass check swallows it.

ServiceNotFound

Bases: ServiceError

The operation's target does not exist, or is not this caller's to see.

The distinction from a plain ServiceError is which thing is wrong: the resource is absent rather than in the wrong state, so a client should stop asking rather than try again differently.

def move_event(*, user, data):
    event = Event.objects.filter(owner=user, pk=data["event_id"]).first()
    if event is None:
        raise ServiceNotFound(f"No event {data['event_id']}.")

Say the same thing for "absent" and "not yours." Answering a permission refusal for a row the caller cannot see confirms that it exists, which is why the owner-scoped lookup above raises this either way.

A transport that has never heard of this type still handles a ServiceError, and one that wants to do better matches on the class. It must match before its generic ServiceError handler, or the subclass check swallows it.

Pool

PoolSeeds dataclass

Names a project adds to every dispatched callable's kwargs pool.

The names in RESERVED_POOL_SEEDS are the dispatcher's own. Everything else a service legitimately needs — a tenant, a correlation id, a locale, a clock, a feature-flag reader — has no channel, because off HTTP there is no request for it to hang off. This is that channel.

A registration contributes two things:

  • the value, resolved into every pool base_pool builds;
  • reservation: dispatch refuses a spec whose Parameters declare the name, and a Validator that returns a value under it, so an argument never meets a seed in one pool. A caller sending the name is then refused as an unknown argument, or dropped under UnknownArguments.IGNORE, like any other name the spec does not declare.

The reason the two travel together is that either alone is a trap. A seed with a value and no reservation shares the pool with an argument of the same name, and which of the two a callable receives would depend on spreading order; a reserved name with no value makes a callable that declares it fail with a TypeError instead.

Immutable, and extend returns a new registry rather than mutating, so there is no shared global state to leak between mounts or across tests. Pass one per dispatch rather than installing it process-wide: a project that mounts two operations with different ambient context needs them to differ, and a module-level default cannot.

seeds = DEFAULT_POOL_SEEDS.extend(tenant=lambda *, user: user.tenant)
dispatch(spec, principal=user, arguments=arguments, pool_seeds=seeds)

A resolver is called through the same declare-to-receive rule as every other spec callable: it names the pool entries it wants (user, an adapter's own entry) and receives only those, or takes **kwargs for all of them.

Attributes:

Name Type Description
seeds tuple[_Seed, ...]

The registrations, in registration order.

names property

names: frozenset[str]

The registered names, without their resolvers.

reserved property

reserved: frozenset[str]

Every name an argument may not occupy: the dispatcher's plus these.

Dispatch reads this rather than RESERVED_POOL_SEEDS directly, which is what makes a registered seed as protected as a built-in one.

extend

extend(**seeds: Callable[..., Any]) -> PoolSeeds

Return a new registry with seeds appended.

Raises:

Type Description
ValueError

A name is one of the dispatcher's own, or is already registered here. Both are refused at registration rather than at dispatch, because a silent last-wins is the exact failure the reservation exists to prevent — and a collision discovered on the hot path is one a test suite can miss.

resolvers

resolvers() -> Mapping[str, Callable[..., Any]]

The registrations as a mapping, for a caller that resolves them.

Resolution itself lives in base_pool rather than here: it needs the declare-to-receive helper (resolve_callable_kwargs), and this module stays a plain value carrier so nothing in pool/ depends on it in a cycle.

DEFAULT_POOL_SEEDS module-attribute

DEFAULT_POOL_SEEDS: PoolSeeds = PoolSeeds()

The empty registry every dispatch entry point falls back to.

A project that registers nothing gets the pool dispatch builds on its own: the principal as user, and each step's own values (data, instance, collection, result, queryset) where that step has them.

RESERVED_POOL_SEEDS module-attribute

RESERVED_POOL_SEEDS: Final[frozenset[str]] = frozenset(
    {"user", "data", "instance", "collection", "result", "queryset", "progress"}
)

The names dispatch seeds into a callable's keyword pool, which no argument may use.

user is the principal, data the validated values, instance and collection the resolved target, result the service's return (in an output selector's pool) and queryset what extend_queryset shapes. They are the names djangorestframework-services seeds for the same values, so a callable written for it runs under this kernel unchanged.

progress has no value yet and is reserved anyway, so that seeding it later is additive: reserving a name after a spec has declared a parameter by it would break that spec.

A parameter declaring one of these names is refused, because a spread argument must never be able to outrank the value dispatch seeded - a caller naming itself user is the case that matters. request and view are not here: they are an HTTP adapter's, and it registers them through PoolSeeds, which reserves them the same way.

base_pool

base_pool(
    *, user: Any, seeds: PoolSeeds = DEFAULT_POOL_SEEDS, **extra: Any
) -> dict[str, Any]

The seeds a dispatched callable's kwargs pool carries, on every transport.

Every pool dispatch and adispatch build routes through here, so a spec callable that declares a seed behaves the same whichever entry point resolved it.

That is a property of the pools built here, not a rule the kernel can enforce on its callers: this is a builder, not a gate. A transport adapter that assembles a pool as a dict literal of its own carries exactly the keys it wrote there, because resolve_callable_kwargs forwards only keys the pool actually has. A callable declaring a seed the literal omitted then raises TypeError at call time rather than running.

There is no request entry here: this is the kernel, and off HTTP there is no request to seed. An HTTP adapter that wants one registers it as its own seed through PoolSeeds, the same way any other ambient value is added. progress is reserved (see RESERVED_POOL_SEEDS) but has no value yet, so it is not seeded either.

An adapter that dispatches callables through this package must build its pool from this function, with its own entries spread in — base_pool(user=…, **own_entries) — rather than restating the seeds. Routing those entries through **extra is also what makes a name collision loud: an entry called user raises TypeError here, where in a dict literal it would quietly outrank the value the transport authenticated.

seeds is the project's own registry (PoolSeeds), resolved into every pool this builds. It is the supported way to add an entry, and the difference from spreading one through **extra is not convenience: a registered name is also reserved, so dispatch refuses a spec that declares an argument by that name rather than letting the two meet in one pool. An **extra entry has no such protection. Spread an entry that is genuinely per-call; register one that is ambient.

resolve_callable_kwargs

resolve_callable_kwargs(fn: Callable[..., Any], pool: dict[str, Any]) -> dict[str, Any]

Pick the subset of pool matching fn's declared parameters.

Every pool-resolved callable in the kernel — a seed's resolver, a spec's selector, extend_queryset, a service — binds through this one rule, so a callable written for one entry point runs unchanged on another: it names what it wants and gets exactly that, or takes **kwargs for the whole pool.

If fn declares **kwargs, the entire pool is passed. Otherwise only parameters present in the signature are forwarded.

Mutations

create_from_input

create_from_input(
    model: type[ModelT],
    data: Any,
    *,
    field_map: dict[str, str] | None = None,
    exclude_fields: list[str] | None = None,
    m2m: dict[str, Any] | None = None,
    relations: Mapping[str, RelationSpec] | None = None,
    context: Mapping[str, Any] | None = None,
) -> ChangeResult[ModelT]

Build, save(), and return a fresh instance of model.

Regular fields come from data (a dataclass, dict, or object with __dict__).

m2m maps a many-to-many attribute to the rows it should hold — instances or primary keys of rows that already exist — and set()s them after the save; it creates nothing. A ManyToManySpec in relations does the other job: it writes the target rows from the payload, creating or updating each, and then links them. A relation named by both is refused, since it would be written twice and keep whichever ran last.

relations maps a relation name to the spec for its kind, and is the one map for every kind — a reverse-FK ChildSpec included. The nested payload is read from data[relation] and written in the order the kind dictates, not the order the map is spelled in (see RelationPhase). Keep the whole call inside the service's atomic block.

context is an opaque mapping forwarded verbatim down the tree, into the pool of every row service a relation spec declares and of every scope callable. This helper never reads it — it exists so per-row work downstream can see the acting caller. A service opts in by handing on the pool it was called with, context=kwargs.

acreate_from_input async

acreate_from_input(
    model: type[ModelT],
    data: Any,
    *,
    field_map: dict[str, str] | None = None,
    exclude_fields: list[str] | None = None,
    m2m: dict[str, Any] | None = None,
    relations: Mapping[str, RelationSpec] | None = None,
    context: Mapping[str, Any] | None = None,
) -> ChangeResult[ModelT]

Async sibling of create_from_input using asave()/aset().

update_from_input

update_from_input(
    instance: ModelT,
    data: Any,
    *,
    field_map: dict[str, str] | None = None,
    exclude_fields: list[str] | None = None,
    m2m: dict[str, Any] | None = None,
    update_fields: bool | list[str] = True,
    relations: Mapping[str, RelationSpec] | None = None,
    context: Mapping[str, Any] | None = None,
) -> ChangeResult[ModelT]

Update instance with values from data, persisting only deltas.

By default (update_fields=True), the save call uses update_fields=<changed> to write the minimal set of columns. auto_now=True fields (e.g. updated_at) are automatically added to that list so they are refreshed alongside the mutation. Pass False to perform a full save, or an explicit list to control exactly which columns are written (no auto-injection in that case).

m2m assigns many-to-many rows that already exist, and only where the set of primary keys differs from the current one; ManyToManySpec in relations writes the target rows from the payload instead. A relation named by both is refused rather than written twice.

relations maps a relation name to the spec for its kind, one map for every kind. The nested payload from data[relation] is reconciled with what is already there — create / update / orphan-remove per the spec — and a relation the input omits is left untouched. Kinds are written in the order their class dictates, so a forward foreign key is resolved before this instance is saved and the assignment rides the same diff and update_fields path as any other column. Keep the call inside the service's atomic block.

A relation that was written stays readable off the returned instance. The one-row kinds resolve their row by re-querying, so the parent's cached related object is replaced with the row that was written — and dropped where the write removed it — rather than left holding pre-write values.

context is an opaque mapping forwarded verbatim into the pool of every row service and scope callable the relation specs declare; this helper never reads it. See create_from_input.

aupdate_from_input async

aupdate_from_input(
    instance: ModelT,
    data: Any,
    *,
    field_map: dict[str, str] | None = None,
    exclude_fields: list[str] | None = None,
    m2m: dict[str, Any] | None = None,
    update_fields: bool | list[str] = True,
    relations: Mapping[str, RelationSpec] | None = None,
    context: Mapping[str, Any] | None = None,
) -> ChangeResult[ModelT]

Async sibling of update_from_input using asave()/aset().

apply_input

apply_input(
    instance: Model,
    data: Any,
    *,
    field_map: dict[str, str] | None = None,
    exclude_fields: list[str] | None = None,
) -> ChangeResult

Set attributes from data onto instance without saving.

Returns a ChangeResult describing fields whose value actually changed. Useful when a service wants to inspect what the input would change before deciding whether to persist.

delete_relations

delete_relations(
    instance: ModelT,
    *,
    relations: Mapping[str, RelationSpec],
    context: Mapping[str, Any] | None = None,
) -> ChangeResult[ModelT]

Remove the rows instance owns, deepest first, and leave instance.

The cascade for a delete service to run where the database will not: a PROTECT foreign key, a relation whose row services must see each removal, or a soft delete, which Django never cascades through because no row is deleted. The service deletes (or retires) instance itself afterwards, inside the same atomic block.

relations is the same map the write helpers take, so one declaration describes both how a payload is written and what goes when the row does. One rule covers every kind: the rows the instance owns are disposed of, and the rows it merely points at are not.

  • A ChildSpec or GenericRelationSpec collection, and a ReverseOneToOneSpec row, is disposed of after its own declared relations, so a non-nullable grandchild goes first. Each row is deleted, unlinked or handed to the spec's delete_service by the rule a write removing it follows: the spec's orphan setting, or the service when one is declared.
  • A ManyToManySpec loses its membership, and its target rows survive.
  • A ForwardRelationSpec is reported untouched rather than refused: its row is not the instance's, and refusing it would make a map that is right for writing impossible to cascade.

The returned ChangeResult reports the collections under children and the one-row kinds under relations, as a write does, with no field changes. The instance's cached relations are brought in line with the removal, so an instance that outlives its cascade renders without the rows it lost.

context is an opaque mapping forwarded verbatim into the pool of every row service the specs declare; this helper never reads it.

adelete_relations async

adelete_relations(
    instance: ModelT,
    *,
    relations: Mapping[str, RelationSpec],
    context: Mapping[str, Any] | None = None,
) -> ChangeResult[ModelT]

Async variant of delete_relations.

The same rule, the same order and the same report, through Django's async ORM. A row service a spec declares must be async def here, as in every async helper: one the loop cannot await is refused, naming its slot.

ChangeResult dataclass

Bases: Generic[ModelT]

Outcome of a mutation helper call.

instance is the model instance after the mutation. created is True iff this came from create_from_input / acreate_from_input. changes records every field whose value actually differed from its prior value (or from UNSET for creates).

Every relation declared in relations= reports in one of two fields, chosen by the relation's shape. children carries one ChildCollectionChange per collection: a reverse-FK ChildSpec, a GenericRelationSpec and a ManyToManySpec. relations carries one RelatedObjectChange per one-row relation: a ForwardRelationSpec and a ReverseOneToOneSpec. A collection reports tuples of pks and a one-row relation reports an outcome, so they are different carriers. Both are empty when no relation is declared, and a declared relation the input omits still gets its entry, reporting nothing. A forward relation shows up twice and means two different things — here as what happened to the row it points at, and in changes as the parent's foreign-key column, which only appears if it actually changed.

The class is generic over the concrete model type: callers that pass Author into a mutation helper get back a ChangeResult[Author] whose .instance is typed as Author. The bare name ChangeResult (no parameter) resolves to ChangeResult[Model] and keeps working for callers that don't care.

changed_fields property

changed_fields: tuple[str, ...]

Names of every field present in changes.

get_field_change

get_field_change(field_name: str) -> FieldChange | None

Return the FieldChange for field_name, or None.

get_child_change

get_child_change(relation: str) -> ChildCollectionChange | None

Return the ChildCollectionChange for relation, or None.

get_relation_change

get_relation_change(relation: str) -> RelatedObjectChange | None

Return the RelatedObjectChange for relation, or None.

FieldChange dataclass

One field's before/after pair from a mutation.

old will be UNSET for fields populated as part of a create (no prior value existed).

ChildCollectionChange dataclass

What a nested write did to one relation that holds a collection.

Carried in ChangeResult.children, one entry per collection declared in relations=: a reverse-FK ChildSpec, a GenericRelationSpec or a ManyToManySpec. A relation holding one row reports a RelatedObjectChange instead. The tuples hold row primary keys:

  • created — rows inserted.
  • updated — rows that already existed, matched by the spec's match_key and written: among the parent's own rows for a child or generic collection, inside scope for a many-to-many.
  • deleted — rows the relation let go and deleted.
  • unlinked — rows the relation let go and kept, with their link to the parent set to None.
  • removed — rows handed to the spec's delete_service. Deliberately a fifth tuple rather than a reuse of deleted: once a service owns the row, the loop no longer knows whether it was deleted, archived, unlinked or left standing, and folding those into deleted would report a guess as fact. What the loop does know is that the row left the relation and a service decided the rest.

Whether a row let go is deleted or unlinked is the spec's orphan (RelationOrphan), not the column alone: "delete" always deletes; "unlink" always unlinks, and is refused where the link cannot hold NULL; "auto", the default, unlinks when it can — both columns, for a generic relation — and deletes when it cannot. A many-to-many target is shared, so a member "replace" drops only loses its membership and is always unlinked; that kind has no orphan and no delete_service, and its deleted and removed stay empty.

updated records every matched row the write ran through update_from_input or the spec's update_service, whether or not that row's own columns actually changed.

RelatedObjectChange dataclass

The delta for a relation that holds one row, not a collection.

Carried in ChangeResult.relations, one entry per singular relation declared in relations= — a forward foreign key / one-to-one, or a reverse one-to-one. ChildCollectionChange's five pk tuples cannot report a one-row relation honestly: every one of them would be either empty or a one-tuple, and "which of the five is non-empty" is a worse way to say "what happened" than saying it. So a singular relation reports one outcome and one pk.

outcome is a RelationOutcome, which documents what each value means.

pk is the primary key of the row the outcome is about, read before any delete (Django clears instance.pk afterwards), and None when no row was touched ("untouched" / "cleared").

RelationOutcome

Bases: str, Enum

The single fact a RelatedObjectChange reports.

A collection reports five tuples of primary keys, which is the honest shape for many rows and a poor one for a relation that holds exactly one: every tuple would be empty or a one-tuple, and "which of the five is non-empty" is a worse way to say what happened than saying it. So only the one-row kinds report an outcome — a forward relation and a reverse one-to-one — and a collection, many-to-many included, reports its tuples.

Inheriting from str keeps the value JSON-serializable and lets a caller compare against the plain string, matching SelectorKind.

UNTOUCHED class-attribute instance-attribute

UNTOUCHED = 'untouched'

Nothing was written to the relation.

The input omitted it, or set a reverse one-to-one that holds no row to None. It is the one outcome for which a RelatedObjectChange is falsy.

CREATED class-attribute instance-attribute

CREATED = 'created'

A new row was written and linked.

UPDATED class-attribute instance-attribute

UPDATED = 'updated'

An existing row was matched and written.

CLEARED class-attribute instance-attribute

CLEARED = 'cleared'

A forward relation was set to None.

The parent's foreign-key column is assigned None and the row it pointed at, if any, is not touched, because a forward target is not owned by the parent and may be shared. The change carries no primary key: nothing happened to a row. It is reported whether or not the column held a value; whether the column moved is ChangeResult.changes' to say.

UNLINKED class-attribute instance-attribute

UNLINKED = 'unlinked'

A reverse one-to-one row was kept and its foreign key set to None.

Mirroring on_delete=SET_NULL. The spec's orphan decides between this and DELETED: "unlink" always, "auto" when the foreign key is nullable. A member a many-to-many drops is not reported here: that kind is a collection, so the member's pk lands in ChildCollectionChange.unlinked.

DELETED class-attribute instance-attribute

DELETED = 'deleted'

A reverse one-to-one row was deleted.

Mirroring on_delete=CASCADE. The spec's orphan decides between this and UNLINKED: "delete" always, whether or not the foreign key could have been blanked, and "auto" when it is not nullable.

REMOVED class-attribute instance-attribute

REMOVED = 'removed'

A row was handed to the spec's delete_service.

Deliberately not DELETED. Once a service owns the row the loop no longer knows whether it was deleted, archived, unlinked or left standing, and reporting a guess as fact is worse than reporting the one thing that is true: the loop removed the row from the relation and a service decided the rest.

Relations

RelationSpec

What the nested-write driver needs from a relation spec: its phase.

The relations={name: spec} mapping of the mutation helpers takes one spec per relation, and the kinds differ in almost everything — a forward foreign key has no orphans, a reverse one-to-one has no collection, a child collection has both. What they share is that the driver must know when to write each one, and that answer belongs to the class rather than to the instance: every forward foreign key is written before the parent's save() because it is a forward foreign key, not because a particular spec asked to be.

So each concrete spec declares write_phase as a class attribute and the driver orders by it (see RelationPhase for the sequence). A spec author never sets it per-instance, and the mapping's insertion order cannot reorder the phases — only the relations within one.

Subclasses are frozen dataclasses; this base deliberately declares no fields, so each kind spells out its own and no kind inherits a knob that means nothing for it. Each takes its required fields positionally and every option by keyword only, so ChildSpec(Book, "author", mode="merge") names what it sets and an option added to a kind moves no other argument.

ChildSpec dataclass

Bases: RelationSpec

How to persist one reverse-FK ("one-to-many") child collection.

The reverse-FK member of the relation taxonomy: it writes rows whose foreign key points back at the parent, so it belongs to RelationPhase.REVERSE and is written after the parent's save().

Passed in the relations={relation_name: ChildSpec(...)} map of create_from_input / update_from_input (and their async siblings). The incoming child rows are read from data[relation_name]; each child is persisted by running it back through the same mutation helpers, so scalar / m2m / nested semantics compose recursively. The whole parent + children write runs inside the service's atomic block; validating the arguments stays with the spec's Validator, which dispatch runs before the service — the helper owns persistence only.

Pluggable services — the spec owns reconciliation, the service owns the row. Matching, mode and orphan handling never move into your code; a slot is called once per row the loop has already decided about. Each is invoked through run_service / arun_service with atomic=False, because the surrounding service's atomic block already wraps the whole tree and letting each row open its own would mean a savepoint per row. Each receives only the pool keys it declares (the library's usual signature-filtering idiom), drawn from the mutation helpers' opaque context= plus the loop's own seeds. Those seeds — data / instance / parent — are applied after the context, so a context key of the same name cannot outrank them, the precedence form of the rule RESERVED_POOL_SEEDS states for dispatch's pools. In the async loops the slot must be an async def: the async path is awaited end to end, so a sync one is refused with ImproperlyConfigured, naming the relation and the slot, before it runs.

A declared slot owns that row entirely: field_map, exclude_fields, m2m and the nested relations map configure the default mutation-helper call, so a create_service / update_service standing in for it makes them dead configuration. Declaring both raises ImproperlyConfigured at construction rather than dropping them quietly. delete_service is exempt — it replaces the unlink-or-delete rule, not the helper call, so the cascade still removes a row's grandchildren before handing the row over. The spec keeps only what it never delegates — which rows exist, which incoming row matches which existing one, and what happens to the ones left over.

Attributes:

Name Type Description
model type[Model]

The child model class.

fk str

Name of the child's forward foreign-key field pointing at the parent ("author" for Book.author). Set automatically on created children, and used to resolve the parent's reverse manager.

match_key str

Field used to pair an incoming row with an existing child. An incoming row whose match_key matches an existing child updates it; one with no match, or no key, is created. The same name is read off both the incoming mapping (item[match_key]) and the existing instance (getattr(child, match_key)), so rows keyed by "id" should set match_key="id". The one name does two jobs — an input key on the mapping side, a model field on the lookup side — which is fine while the two agree and is why field_map may not rename anything onto the primary key while match_key matches on it: there would be no single name left to read. That combination raises at construction.

mode RelationMode | str

"replace" matches incoming to existing, creates new, updates matched, and removes orphans (existing children absent from the incoming set); "merge" upserts only and never removes.

orphan RelationOrphan | str

What removing an orphan does, where mode says whether one is removed at all. "auto" derives it from the schema: unlinked (its fk set to None) when the FK is nullable, else deleted, mirroring on_delete=SET_NULL vs CASCADE. "unlink" and "delete" say it outright, for a spec that means one of them rather than whichever the column happens to allow — a later migration adding null=True would otherwise turn a destructive "replace" into a non-destructive one with nothing in the spec changing. "unlink" against a non-nullable FK raises ImproperlyConfigured when the relation is written, since there is no link to blank. The same rule governs the delete cascade (delete_relations), which disposes of the same rows.

field_map dict[str, str] | None

Forwarded to the per-child create_from_input / update_from_input call, exactly as for the parent. It shapes that write and nothing else: matching, the primary-key guard and the parent link all read the row exactly as it arrived, so renaming a key here does not change which row the payload matches.

exclude_fields list[str] | None

Forwarded to the per-child call, as field_map is. Excluding the match_key does not stop the row matching on it, and a matched row's primary key is dropped from the write for you, so there is no need to name it here.

m2m Mapping[str, Any] | Callable[[Any], Mapping[str, Any]] | None

The child's own many-to-many assignments — the per-child analogue of the helpers' m2m=, and like it, rows that already exist. A static mapping ({"tags": [tag1, tag2]}) gives every child the same; a callable (child_row) -> mapping derives them from each incoming row.

relations Mapping[str, RelationSpec] | None

The child's own relations, of any kind — a {relation_name: RelationSpec} map applied to each child row exactly as the top-level relations= is applied to the parent, so a ChildSpec here writes grandchildren. Recursion follows the declared tree, so depth is bounded by how deeply you nest specs.

create_service Callable[..., Any] | None

Per-row service replacing the default mutation-helper call, for a child whose write has real behaviour (side effects, derived columns, events, an external call). Called as create_service(*, data, parent, **extras), where data is the incoming row with the fk already pointing at parent, since linking the child is reconciliation. Must return the created row; the loop reads its pk for the delta.

update_service Callable[..., Any] | None

The same for updates, called as update_service(*, instance, data, parent, **extras). Returning None means "use the in-memory instance", the helpers' convention.

delete_service Callable[..., Any] | None

Called as delete_service(*, instance, parent, **extras), replacing the unlink-or-delete rule for that row — both for orphan removal and for the delete cascade (delete_relations). The loop can no longer tell an unlink from a delete, so the pk is reported under ChildCollectionChange.removed rather than guessed into one of the two. It is the disposal, so declaring it beside an explicit orphan raises at construction: the flag would decide nothing.

ForwardRelationSpec dataclass

Bases: RelationSpec

How to persist a forward relation — a ForeignKey or a OneToOneField declared on the parent itself.

One spec covers both: OneToOneField subclasses ForeignKey, and the column being unique changes nothing about how it is written.

Declared in relations={field_name: ForwardRelationSpec(...)} on the mutation helpers, where the name is the parent's own field (relations={"author": ...} for Post.author). The nested payload is read from data[field_name] and the resolved row is assigned to that field before the parent is saved (RelationPhase.FORWARD) — an ordinary column assignment, reported by diff_attrs and persisted by the same minimal update_fields save as any other field.

The resolved row is assigned onto the parent in memory whether or not the column moved, so a caller who read the relation before the write does not read the pre-write row back off the returned instance. A row re-matched against scope is a different Python object from the one the parent had cached, and two rows sharing a primary key are equal, so the diff correctly reports no column change and would otherwise leave the stale object behind.

The value reads three ways. Omitted leaves the relation untouched. None sets the parent's foreign key to None without removing the row it used to point at — a forward target is not owned by the parent and may be shared, so removing rows is the reverse kinds' job. A mapping writes the target row: without a match_key it creates one, with a match_key it names one, matched against scope.

A spec writes a row. To merely point the column at a row that already exists, don't declare a relation at all — pass the pk or the instance as the plain field it is.

There is no mode and no delete_service: a forward relation has no collection to reconcile and no orphans to dispose of. Clearing it is the None case, and it clears the column.

Attributes:

Name Type Description
model type[Model]

The target model class.

match_key str

The field pairing an incoming payload with an existing row (default "pk"), read off both the mapping (item[match_key]) and the queryset (filter(**{match_key: key})). It identifies a row rather than describing one, so a key matching nothing in scope raises ServiceValidationError instead of falling through to a create — unlike ChildSpec, which matches inside the parent's own manager, where a miss really does mean "a new child". The one name does two jobs — an input key on the mapping side, a model field on the queryset side — which is fine while the two agree and is why field_map may not rename anything onto the primary key while match_key matches on it: there would be no single name left to read. That combination raises at construction.

scope QuerySet[Any] | Callable[..., QuerySet[Any]] | None

The rows this caller may update — a queryset, or a callable resolved from the caller's context pool by signature (lambda user: Author.objects.filter(owner=user)), the library's usual idiom. Without it the spec is create-only, and a payload carrying a match_key raises ImproperlyConfigured rather than quietly creating a duplicate: a forward target has no owning manager to match within, so an unscoped by-key match would mean "any caller may write any row of that model by guessing a key".

field_map dict[str, str] | None

Forwarded to the target row's own create_from_input / update_from_input call, exactly as for the parent. It shapes that write and nothing else: matching and the primary-key guard both read the row exactly as it arrived, so renaming a key here does not change which row the payload matches.

exclude_fields list[str] | None

Forwarded likewise. Excluding the match_key does not stop the row matching on it, and a matched row's primary key is dropped from the write for you, so there is no need to name it here.

m2m Mapping[str, Any] | Callable[[Any], Mapping[str, Any]] | None

Forwarded likewise — the target's own many-to-many assignments.

relations Mapping[str, RelationSpec] | None

Forwarded likewise — the target's own relations, of any kind.

create_service Callable[..., Any] | None

Optional service replacing that call, for a target whose write has behaviour of its own. It receives data plus the caller context — but no parent: a forward target is written before the parent row exists, which is the whole point of the phase. Declaring it alongside the row-shaping fields above raises at construction, for the reason given on ChildSpec.

update_service Callable[..., Any] | None

The same, and additionally receives instance.

ReverseOneToOneSpec dataclass

Bases: RelationSpec

How to persist a reverse one-to-one — the row that points back.

The other side of a OneToOneField: the column lives on the related row (Profile.author) and the parent (Author) reaches at most one of them through the reverse accessor. So it is the ChildSpec loop minus the collection, written in RelationPhase.REVERSE once the parent has a primary key to point at.

Declared in relations={accessor_name: ReverseOneToOneSpec(...)}, where the name is the parent's reverse accessor (relations={"profile": ...} for Author.profile). The value at data[accessor_name] reads three ways. Omitted leaves the relation untouched. None removes the existing row, if any, by the orphan rule below — unlike a forward relation, this row is the parent's, so clearing the relation has to do something about it. A mapping updates the row when the parent already has one, and creates and links one when it does not.

The existing row is found by querying fk rather than through the reverse accessor, so whatever the accessor had cached is a different Python object from the one that gets written. Each of the three cases leaves the parent agreeing with the write — pointed at the written row, or cleared where the row was removed — so the returned instance does not read a pre-write row.

There is no match_key and no scope — the parent owns at most one row here, so the relation itself is the match — and no mode: a one-row relation has no orphans beyond the None case, which is explicit.

Attributes:

Name Type Description
model type[Model]

The related model class.

fk str

The name of that model's field pointing at the parent ("author" for Profile.author). Set automatically on creation, and the field whose nullability decides unlink-versus-delete by default.

orphan RelationOrphan | str

What removing the row does, by ChildSpec's rule: "auto" (the default) derives it from fk — unlinked when that field is nullable (like on_delete=SET_NULL), deleted when it is not (like CASCADE) — while "unlink" / "delete" state it instead, and "unlink" against a non-nullable fk raises ImproperlyConfigured at write time. It covers both removals there are: the None case above and the delete cascade (delete_relations).

field_map dict[str, str] | None

Forwarded to the row's own create_from_input / update_from_input call. It shapes that write and nothing else: the row is found through fk and the parent link is written onto it raw, neither of them reading this map.

exclude_fields list[str] | None

Forwarded likewise. Shaping configures the row's write only; the row itself is found through fk, and a matched row's primary key is dropped from the write for you.

m2m Mapping[str, Any] | Callable[[Any], Mapping[str, Any]] | None

Forwarded likewise.

relations Mapping[str, RelationSpec] | None

Forwarded likewise — the row's own relations, of any kind.

create_service Callable[..., Any] | None

Optional service replacing that call for the row, with the contract ChildSpec states: it receives parent, and its data already carries the fk. Declaring it alongside the row-shaping fields above raises at construction.

update_service Callable[..., Any] | None

The same; returning None means "use the in-memory instance".

delete_service Callable[..., Any] | None

Replaces the unlink-or-delete rule above, so the outcome is reported as "removed" — the only thing still known — and an explicit orphan beside it raises.

ManyToManySpec dataclass

Bases: RelationSpec

How to persist a many-to-many from nested rows rather than from keys.

Declared in relations={accessor_name: ManyToManySpec(...)}, where the name is whichever side the parent reaches the relation through — the field it declares (Post.tags) or the reverse accessor of a field declared on the other model (Tag.posts). Either works; Django hands back the same related manager.

Each row in data[accessor_name] is a payload, not a key: the target row is created or updated first, and only then is the membership written (RelationPhase.M2M, after the parent has a primary key). That is the difference from the mutation helpers' m2m= argument, which assigns rows that already exist and creates nothing. A relation named by both is refused: it would be written twice and keep whichever ran last.

An omitted relation is untouched; an explicit [] empties the membership in "replace" mode; a list of mappings writes every target and then sets the membership (or adds to it, in "merge" mode).

There is no delete_service: the row is shared, so a target dropped from the relation loses only its membership and is reported under ChildCollectionChange.unlinked, leaving deleted empty for this kind.

A many-to-many with an explicit through model is not covered. The loop writes target rows and lets Django write the through row, which cannot carry the extra columns a custom through model exists for.

Attributes:

Name Type Description
model type[Model]

The target model class.

match_key str

The field pairing an incoming payload with an existing target row (default "pk"). Read off the incoming row as an input key and off the lookup as a model field, so field_map may not rename anything onto the primary key while match_key matches on it — that combination raises at construction, on the terms ChildSpec states.

scope QuerySet[Any] | Callable[..., QuerySet[Any]] | None

The rows this caller may update, on the terms ForwardRelationSpec states: without it the spec is create-only, and a payload carrying a match_key raises ImproperlyConfigured. Matching runs against scope, not against the parent's current membership — the payload names rows to link, which is exactly the set that is not linked yet.

mode RelationMode | str

"replace" (the default) makes the incoming set authoritative and drops the members it leaves out; "merge" adds and never drops.

field_map dict[str, str] | None

Forwarded to the target row's own create_from_input / update_from_input call. It shapes that write and nothing else: matching and the primary-key guard both read the row exactly as it arrived, so renaming a key here does not change which row the payload matches.

exclude_fields list[str] | None

Forwarded likewise. Excluding the match_key does not stop the row matching on it, and a matched row's primary key is dropped from the write for you, so there is no need to name it here.

m2m Mapping[str, Any] | Callable[[Any], Mapping[str, Any]] | None

Forwarded likewise — the target's own many-to-many assignments, not this relation.

relations Mapping[str, RelationSpec] | None

Forwarded likewise — the target's own relations, of any kind.

create_service Callable[..., Any] | None

Optional service replacing that call for a target whose write has behaviour of its own. It receives data plus parent and the caller context, but — unlike a child collection — data carries no link to the parent: the link is a through-table row the loop writes afterwards, which a service could not write by returning one. Declaring it alongside the row-shaping fields above raises at construction, for the reason given on ChildSpec.

update_service Callable[..., Any] | None

The same, and additionally receives instance.

GenericRelationSpec dataclass

Bases: RelationSpec

How to persist a GenericRelation — a collection linked by content type.

The reverse-FK collection with the foreign key replaced by a pair of columns: a ForeignKey to ContentType saying which model the row belongs to, and an id column saying which row. It reconciles exactly as ChildSpec does — matched inside the parent's own accessor, so no scope= is needed or accepted — and is written in RelationPhase.GENERIC, once the parent's save() has given it both a content type and a primary key.

Declared in relations={accessor_name: GenericRelationSpec(...)}, where the name is the GenericRelation declared on the parent (relations={"attachments": ...} for Catalog.attachments). A relation the input omits is untouched; an explicit [] in "replace" mode empties it.

This kind needs django.contrib.contenttypes in INSTALLED_APPS, and nothing else in the library does. Declaring the spec is always safe; writing one without the app installed raises ImproperlyConfigured naming the remedy.

Attributes:

Name Type Description
model type[Model]

The related model class — the one carrying the content-type and id columns, e.g. Attachment.

content_type_field str

Name of the content-type column, defaulting to Django's own "content_type".

object_id_field str

Name of the id column, defaulting to "object_id". Both mirror the GenericRelation arguments of the same name; set them when the model spells the columns differently.

match_key str

The field pairing an incoming row with an existing one (default "pk"), read inside the parent's own accessor. Read off the incoming row as an input key and off the lookup as a model field, so field_map may not rename anything onto the primary key while match_key matches on it — that combination raises at construction, on the terms ChildSpec states.

mode RelationMode | str

"replace" (the default) removes the rows the incoming set leaves out, "merge" upserts only.

orphan RelationOrphan | str

What removing a row does — ChildSpec's rule applied to the pair of link columns rather than to one, since half a link is not a state the relation has a meaning for. "auto" (the default) unlinks (both columns set to None) when both are nullable and deletes otherwise; "unlink" and "delete" state it instead of deriving it, and "unlink" raises at write time unless both columns can hold NULL. The rule also governs the delete cascade (delete_relations).

field_map dict[str, str] | None

Forwarded to the row's own create_from_input / update_from_input call. It shapes that write and nothing else: matching and the primary-key guard both read the row exactly as it arrived, so renaming a key here does not change which row the payload matches.

exclude_fields list[str] | None

Forwarded likewise. Excluding the match_key does not stop the row matching on it, and a matched row's primary key is dropped from the write for you, so there is no need to name it here.

m2m Mapping[str, Any] | Callable[[Any], Mapping[str, Any]] | None

Forwarded likewise.

relations Mapping[str, RelationSpec] | None

Forwarded likewise — the row's own relations, of any kind.

create_service Callable[..., Any] | None

Optional service replacing that call, with the contract ChildSpec states; its data already carries both link columns. Declaring it alongside the row-shaping fields above raises at construction.

update_service Callable[..., Any] | None

The same; returning None means "use the in-memory instance".

delete_service Callable[..., Any] | None

Replaces the unlink-or-delete rule above, so the outcome is reported as "removed" and an explicit orphan beside it raises.

RelationMode

Bases: str, Enum

How a relation reconciles the rows the payload leaves out.

Shared by every kind that reconciles a collection, so the two words mean the same thing on all of them. What "dispose of" means is the kind's own business — a child row is unlinked or deleted, a many-to-many target is only dropped from the relation — but when it happens is this one flag.

Inheriting from str keeps the value JSON-serializable and means a plain string works wherever the member does, in a comparison and as an argument.

REPLACE class-attribute instance-attribute

REPLACE = 'replace'

The incoming set is authoritative; rows it leaves out are disposed of.

MERGE class-attribute instance-attribute

MERGE = 'merge'

The incoming set upserts and removes nothing.

RelationOrphan

Bases: str, Enum

How a relation disposes of a row it no longer holds.

Shared by the kinds that own their rows — a reverse-FK collection, a generic relation, a reverse one-to-one — so the word means the same thing on all three. It answers what happens to a row that is let go; RelationMode answers when a row is let go at all, and the two are independent knobs on purpose.

The default derives the answer from the schema. The other two exist because a derived answer is not a stated one: whether the link can hold NULL is a fact about a column, and a migration adding null=True later would silently turn a replace that deleted into one that unlinks, with nothing in the spec — or in its tests — changing to say so. A spec that means to delete says so.

Inheriting from str keeps the value JSON-serializable and means a plain string works wherever the member does, matching RelationMode and RelationOutcome.

AUTO class-attribute instance-attribute

AUTO = 'auto'

Derive it from the link: unlink when it can hold NULL, else delete.

Mirroring on_delete=SET_NULL versus CASCADE, which is the better default because it honours what the model already declares.

UNLINK = 'unlink'

Always blank the row's link to the parent and leave the row standing.

Refused when the link cannot hold NULL — there is nothing to blank, and deleting the row instead would be the opposite of what was asked.

DELETE class-attribute instance-attribute

DELETE = 'delete'

Always delete the row, whether or not its link could have been blanked.

RelationPhase

Bases: IntEnum

The slot in the write sequence a relation kind belongs to.

A nested write cannot honour the order the relations= mapping happens to be spelled in: a forward foreign key has to be resolved before the parent row exists, and a many-to-many has to be assigned after it does. So the order is a property of the relation kind — each spec class declares its phase as a write_phase class attribute (see RelationSpec) and the driver walks the phases in this enum's order, never the mapping's:

  1. FORWARD — the FK column lives on the parent, so the target row must exist before the parent is saved. Writing it first also means the assignment is an ordinary column change, picked up by the same diff and update_fields machinery as any other field.
  2. the parent's save() — not a phase; the boundary the phases are named around.
  3. REVERSE — the FK column lives on the other row (reverse FK collections and reverse one-to-ones), so the parent must have a primary key to point at.
  4. GENERIC — generic relations, which need the saved parent's content type and primary key.
  5. M2M — through-table writes, which need both rows saved.

Declaration order still decides everything the phases leave open: two relations in the same phase are written in the order they were declared.

The member values order the phases and nothing else; compare and sort by the members, never by the numbers.

Registry

SpecRegistry

A name -> spec map with tags, stable ordering, and filtered views.

One declaration site for the operations a project exposes over more than one transport, so the transports read one source instead of each enumerating specs and drifting. It holds only the invariant part of an operation - which spec, its canonical name, its tags; per-transport configuration stays at the binding that owns it. Adapters read a registry at configuration time to build their binding tables; nothing consults one per dispatch.

registry = SpecRegistry()
registry.register("list_orders", orders_spec, tags=("read", "public"))
registry.register("refund_order", refund_spec, tags=("write", "admin"))

public = registry.by_tag("public")   # a new registry - a snapshot

There is no global registry: a consumer holds as many of its own instances as it likes, with no shared state between them. Registries are mutable - register adds - but every derivation (by_tag, subset, merge) returns a new registry holding a snapshot of the selected entries, sharing the spec objects rather than copying them. A derived view never mutates its source, and a later register() on the source does not appear in a view derived earlier.

register

register(
    name: str, spec: ServiceSpec | SelectorSpec, *, tags: Iterable[str] = ()
) -> None

Add a spec under name.

Parameters:

Name Type Description Default
name str

The canonical name. Must be unused in this registry - a duplicate raises rather than overwriting, so a copy-pasted declaration fails at import time instead of silently shadowing an operation.

required
spec ServiceSpec | SelectorSpec

A ServiceSpec (write) or SelectorSpec (read).

required
tags Iterable[str]

Free-form labels, deduplicated into a frozen set.

()

Raises:

Type Description
ValueError

name is already registered here.

TypeError

spec is neither a ServiceSpec nor a SelectorSpec.

ImproperlyConfigured

spec.permissions is None. Off HTTP there is no view whose policy an undeclared spec could inherit, so registering one that means to be open must say so with permissions=[Unrestricted()]; the alternative is a spec that dispatch would refuse on every call.

get

get(name: str) -> RegisteredSpec | None

Return the entry registered under name, or None.

all

all() -> tuple[RegisteredSpec, ...]

Every entry, in registration order, so a transport's listing is stable.

mutations

mutations() -> tuple[RegisteredSpec, ...]

The ServiceSpec entries, in registration order.

queries

queries() -> tuple[RegisteredSpec, ...]

The SelectorSpec entries, in registration order.

by_tag

by_tag(*tags: str) -> SpecRegistry

A new registry holding the entries carrying any of tags.

Union, not intersection - chain calls for an intersection (reg.by_tag("read").by_tag("public")). No tags matches nothing.

subset

subset(*names: str) -> SpecRegistry

A new registry holding the named entries, in the order given.

Raises:

Type Description
KeyError

A name is not registered here - a typo is a configuration error, not a quietly smaller surface.

merge

merge(*others: SpecRegistry) -> SpecRegistry

A new registry combining this one with others, in order.

Names are unique per registry, so independent registries may reuse one; merging is the single place that reuse becomes a conflict.

Raises:

Type Description
ValueError

The same name appears in more than one input.

specs

specs() -> dict[str, ServiceSpec | SelectorSpec]

A fresh name -> spec dict - the shape adapters already accept.

Pass registry.specs() wherever a dict[str, spec] goes today. Mutating the returned dict does not affect the registry.

RegisteredSpec dataclass

A spec under its canonical name, with free-form tags.

The value type held by SpecRegistry. It carries only the part of an operation that is invariant across transports - which spec, what it is called, and how it is grouped. Per-transport configuration (an HTTP view's URL kwargs, an MCP tool's annotations, an agent adapter's own metadata) stays at the binding that configures it, not here.

Fields:

  • name - the canonical name for this operation. Unique within the registry that holds it; two independent registries are separate namespaces and may reuse a name.
  • spec - a ServiceSpec (a write) or a SelectorSpec (a read). The kind is deliberately not stored as its own field: it is derived by isinstance wherever it is needed, so a stored discriminator can never drift from the object it describes.
  • tags - a frozen set of free-form labels used to derive filtered views (registry.by_tag("public")). Tags carry boolean-ish facts - "read", "admin", "destructive" - that every transport can interpret in its own vocabulary.

Selectors

shape_queryset

shape_queryset(
    queryset: Any, spec: SelectorSpec, pool: Mapping[str, Any], *, source_label: str
) -> Any

Apply spec's shaping fields to queryset, in the fixed order.

select_related, prefetch_related and annotations run first, in declaration order, so extend_queryset always sees the fully declaratively-shaped queryset. extend_queryset is resolved from {**pool, "queryset": queryset} through the same declare-to-receive rule as every other pool-resolved callable, and its return is the shaped queryset from here on.

Parameters:

Name Type Description Default
source_label str

Named in the misconfiguration error to point at the offending spec ("SelectorSpec.selector" vs "ServiceSpec.output_selector_spec.selector").

required

Returns:

Type Description
Any

The shaped queryset, or queryset unchanged when nothing is

Any

configured.

Raises:

Type Description
ImproperlyConfigured

Shaping is configured but queryset is not a Django queryset — loud failure beats a stray AttributeError deep inside whichever transport renders the result.

Types

DispatchError

Bases: Exception

A refusal made by dispatch rather than by the operation.

Deliberately not a subclass of ServiceError, and none of its children is one. Every consumer's exception ladder reads a service error as a refusal the caller adapts to: an agent transport turns one into a result the model reads and routes around while its run goes on. A denial declared that way becomes something the model retries, and an invalid argument set declared as a service validation error moves an MCP server's answer from "invalid arguments" to a business-rule failure, which is exactly the distinction that server draws by type.

So the pair says who refused: dispatch, before the operation ran, or the operation itself.

UNSET module-attribute

UNSET: UnsetType = UnsetType()

UnsetType

Singleton sentinel type. Always falsy; identity-equal to itself only.

Don't instantiate this directly — use the module-level UNSET singleton. UnsetType() returns that same instance, but the sentinel is the value you compare against and UnsetType is only useful as a type annotation.