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 ( |
permissions |
Sequence[PermissionCheck] | None
|
The checks a principal must pass. |
validator |
Validator | None
|
Validates the arguments its own |
instance_selector_spec |
SelectorSpec | None
|
A |
collection_selector_spec |
SelectorSpec | None
|
A |
output_selector_spec |
SelectorSpec | None
|
Re-reads what the service produced, with its
return value as |
presenter |
Presenter | None
|
Renders the dispatch value. When |
atomic |
bool
|
Run the service inside |
metadata |
Mapping[str, Any] | None
|
A project's own per-operation facts, stored as given. |
target_selector_spec ¶
The selector resolving what the service acts on, if it declares one.
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 ¶
The presenter the dispatch value is rendered with: this spec's or its output selector's.
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
|
|
selector |
Callable[..., Any]
|
The read itself. Called with the keywords it declares, from
the principal ( |
permissions |
Sequence[PermissionCheck] | None
|
The checks a principal must pass. |
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
|
|
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 |
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. |
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
¶
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.
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.
boolis neither anintegernor anumber, although Python makes it anint: a JSONtruesent 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
numbertype, and this is a type rule rather than a stricter check - although pydantic'sfloatand the dataclass adapter's both take one. choicesconstrain the value, and an array's choices constrain each of its elements, since an array is never one of a list of scalars.- An
objectwith nofieldsis free-form: its type is checked and its contents are not, because nothing is declared inside it to check against. An array with noitemstakes 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 |
coerce_flat ¶
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
itemstype, and a single value becomes a one-element list, since a flat transport sends a one-element list as a bare value.Noneis 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_argumentsstays 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: |
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
intindex, only the rows that failed:{"books": {1: {"title": ["..."]}}} - a message about a row itself, under
non_field_errorsinside 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.
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.
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
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.
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 inventsIdfromid, others give nothing), so the adapter supplies what its library has andNonemeans 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
¶
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
¶
An opaque identifier: passed to other tools, never spoken to a user.
label
classmethod
¶
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
¶
The default: ordinary data, shown to every consumer.
LABEL
class-attribute
instance-attribute
¶
The field that names this record for a human. At most one per output.
HANDLE
class-attribute
instance-attribute
¶
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
¶
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 ¶
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_nonepresentsNonefor a missing row, so the item's type gains"null". Withoutallow_nonea 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 itsallow_nonesays: once the service has run, a re-read that finds nothing isNonerather 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
Noneis 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": falseis emitted underREJECTonly, and at exactly the levelscheck_argumentscloses: the top, every object parameter that declaresfields, and every row of an array whoseitemsare Parameters - an empty declaration included, since it refuses every key. UnderIGNOREan undeclared key is accepted and dropped, so statingfalsethere would refuse a call that runs.- An object parameter with no
fieldsis{"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 noitemsand 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, anditems_nullabledoes the same to an array'sitems. - Choices are an
"enum"beside the"type"the parameter declares. On an array they go onitems, because the shape check applies them to each element. A nullable parameter's enum gainsNonewhen its choices do not list it, since an enum claims the whole set of accepted values. An array's items do so byitems_nullablealone, 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. helpbecomes"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 ¶
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 whosealways_presentis true, in declaration order, and left out when there are none.requiredsays 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 itslabelwhen 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:
fieldsmake an object schema, an object with none is{"type": "object"}, and an array'sitemsare{"type": t}, a nested item's object schema, or{}when undeclared.items_nullablemakes the item's type[t, "null"]. - Choices are
(value, display)pairs. When every display is juststr(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;titleconstrains nothing, and the accepted set stays exactly the constants. The"type"is stated either way. A nullable field addsNone({"const": None}in aoneOf) 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 onitems, widened byitems_nullablerather 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.floattakes an integer as well, since JSON writes2.0as2; nothing else crosses types, sotrueis not anint.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 asitems_nullableand not as the array's own nullability.Literal[...]and anEnum(Django'sTextChoicesandIntegerChoicesincluded): choices, of the JSON type the values share. AnEnumdecodes to the member.list[X]: an array;list[SomeDataclass]is an array of rows, each declared as nested Parameters. For any other element,Parameter.itemscan 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 holdsUNSETrather 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.
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()].
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:
BooleanFieldandNullBooleanField: aboolean.DecimalField: astringin thedecimalformat, which takes a JSON number as well.FloatField: anumber.IntegerField: aninteger.DateTimeFieldandDateField: astringin thedate-timeordateformat.TimeFieldandDurationField: astring.ChoiceField: choices, with the empty choice dropped and option groups flattened. A plain one cleans withstrand matches the string against each choice'sstr, so it takes astringand its choices are declared as strings, whatever type they were written in. ATypedChoiceFieldtakes 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 astringwith no choices, which every choice field accepts, and the form checks the choice.MultipleChoiceFieldandTypedMultipleChoiceField: an array of that, whose choices constrain each element.ModelChoiceField: the JSON type of the model field it matches rows on - itsto_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): astring.
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:
requiredis the field's ownrequired. For aBooleanFieldthat is a statement about presence too: an absent checkbox cleans toFalse, which a required one refuses, as it refusesfalse. ANullBooleanFieldis never required, whatever it says, because its validation is empty and it takes an absent value asNone.nullableisnot required, and always true for aNullBooleanField. A form readsNoneas an empty value for every field, so an optional field takesnulland cleans it to its empty value, and a required one refuses it.defaultis alwaysUNSET: a field'sinitialis 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.
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_APPEARANCESin the adapter'sutils), 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 ¶
The model's fields as Parameters, named as pydantic validates them. Never queries.
validate ¶
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: itsserialization_alias, else itsalias, else its name; - fields declared
exclude=Trueare not output, and eachcomputed_fieldis, after the fields, typed by its return annotation; - every field is
always_present, becausemodel_dumpemits every key, except one with anexclude_if, which may be dropped; labelis the field'stitlewhen the author set one, andNoneotherwise: pydantic invents no label, and a reader can title-case a name as well as an adapter;- choices are (value, display) pairs: a Django
Choicesenum supplies its label, translated whenoutput()is called, and a plainEnumor aLiteralhas no display of its own, so the value'sstrstands in; - a
FieldMarkingis declared in the field'sAnnotatedmetadata: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.
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:
check_argumentsoverspec.parameters()underunknown_arguments: the closed argument set and the shape check. A parameter a registered seed inpool_seedsoccupies is refused before that, as a configuration error.authorize, honouringgrantif it covers this spec and principal. Before anything is resolved, so a refused principal learns nothing about which rows exist.- 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
readsdeclare, then shaped, and a RETRIEVE collapsed to its row. A missing row isDispatchResult(kind="not_found"), returned rather than raised, unless the selector saysallow_none. authorize_targeton a retrieved row - never on a list, and never on aNonethatallow_nonelet through.- A service spec's Validator, on only the arguments it declares, with the resolved target in its context.
- The run: the service, called with the principal,
data(the validated values),instanceorcollection, each validated value by name and every registered seed, throughrun_servicesospec.atomicholds and anasync defservice is bridged. Then the output selector, if declared, with the service's return asresult.
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 defservice joins it too, through the bridge inrun_service:transaction.atomicis 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 defrun 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 |
PrincipalUnavailable
|
|
(InvalidArguments, NotPermitted, ImproperlyConfigured)
|
as |
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']
|
|
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 ¶
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, becauseapresent'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 ofNone- anallow_noneretrieve that found nothing, a service that returned nothing - the value as it is.Noneis 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
|
|
apresent
async
¶
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
|
|
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:
check_argumentsoverspec.parameters(), underunknown_arguments: the closed argument set and the shape check, at every level.- For a
ServiceSpecwith a Validator,validate()on only the arguments the Validator's ownparameters()declare - never the target selector'sreads- withValidationContext(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.
- The principal is
request.user, anonymous included, since the spec's permission check is what decides whether anonymous may act. An authenticated user whoseis_activeis false is refused asPrincipalUnavailable: a backend other thanModelBackendcan log one in, and a deactivated principal never acts. - The arguments are
request_argumentsforspec.parameters(), withurl_kwargsmerged last. dispatch, withgrant,pool_seedsandunknown_argumentsas given.- The answer. A not-found result is 404 with a
detail. Otherwise the presented value, bare, atsuccess_statusif 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 |
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 |
answer ¶
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
¶
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_urlthrough Django'sresolve_urlby default, so areverse_lazyor a URL name works, and a subclass overrides it to read the result. - A refusal of the arguments -
InvalidArguments, or aServiceValidationError- re-renders the bound form at 400 with the refusal placed on it byadd_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:
ServiceConflictat 409, any otherServiceErrorat 422, any otherDispatchErrorat 400. The statuses areerror_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. NotPermittedandPrincipalUnavailableraise Django'sPermissionDenied, and a not-found result orServiceNotFoundraisesHttp404, 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
|
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 ¶
Django's as_view, then the spec and the redirect checked, once, for every request.
get ¶
The page, with an unbound form, for a principal the spec's class check admits.
post ¶
Dispatch the posted form: a redirect on success, the form re-rendered on a refusal.
get_context_data ¶
Django's context - view, and extra_context - with the spec served.
get_success_url ¶
Where a successful post redirects: success_url, resolved as redirect() reads it.
Override it to read result, such as the row a create returned.
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/jsonbody: 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-urlencodedormultipart/form-data): the fields, which are flat. Django parses one intorequest.POSTfor POST alone, so a PUT, PATCH or DELETE body is parsed here the same way rather than arriving empty. Any other body is refused asUnsupportedMediaTyperather 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 itsContent-Typesays.
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 |
UnsupportedMediaType
|
a body that is neither JSON nor a form. |
ImproperlyConfigured
|
a URL kwarg |
error_response ¶
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 ¶
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 ownnon_field_errorsadding 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.
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 ¶
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 ¶
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 ownValidationError- a non-UUID string against a UUID primary key raises that one instead ofValueError); is_activeisFalse. Read withgetattrrather 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 ¶
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
¶
Await fn(**kwargs), optionally inside transaction.atomic().
is_async ¶
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_poolbuilds; - 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. |
reserved
property
¶
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 ¶
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. |
DEFAULT_POOL_SEEDS
module-attribute
¶
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 ¶
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 ¶
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
ChildSpecorGenericRelationSpeccollection, and aReverseOneToOneSpecrow, 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'sdelete_serviceby the rule a write removing it follows: the spec'sorphansetting, or the service when one is declared. - A
ManyToManySpecloses its membership, and its target rows survive. - A
ForwardRelationSpecis 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.
get_field_change ¶
Return the
FieldChange for
field_name, or None.
get_child_change ¶
Return the
ChildCollectionChange
for relation, or None.
get_relation_change ¶
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'smatch_keyand written: among the parent's own rows for a child or generic collection, insidescopefor 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 toNone.removed— rows handed to the spec'sdelete_service. Deliberately a fifth tuple rather than a reuse ofdeleted: once a service owns the row, the loop no longer knows whether it was deleted, archived, unlinked or left standing, and folding those intodeletedwould 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
¶
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.
UPDATED
class-attribute
instance-attribute
¶
An existing row was matched and written.
CLEARED
class-attribute
instance-attribute
¶
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
¶
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
¶
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
¶
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 ( |
match_key |
str
|
Field used to pair an incoming row with an existing child.
An incoming row whose |
mode |
RelationMode | str
|
|
orphan |
RelationOrphan | str
|
What removing an orphan does, where |
field_map |
dict[str, str] | None
|
Forwarded to the per-child |
exclude_fields |
list[str] | None
|
Forwarded to the per-child call, as |
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' |
relations |
Mapping[str, RelationSpec] | None
|
The child's own relations, of any kind — a
|
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
|
update_service |
Callable[..., Any] | None
|
The same for updates, called as
|
delete_service |
Callable[..., Any] | None
|
Called as
|
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 |
scope |
QuerySet[Any] | Callable[..., QuerySet[Any]] | None
|
The rows this caller may update — a queryset, or a callable
resolved from the caller's |
field_map |
dict[str, str] | None
|
Forwarded to the target row's own |
exclude_fields |
list[str] | None
|
Forwarded likewise. Excluding the |
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 |
update_service |
Callable[..., Any] | None
|
The same, and additionally receives |
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 ( |
orphan |
RelationOrphan | str
|
What removing the row does, by
|
field_map |
dict[str, str] | None
|
Forwarded to the row's own |
exclude_fields |
list[str] | None
|
Forwarded likewise. Shaping configures the row's
write only; the row itself is found through |
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 |
update_service |
Callable[..., Any] | None
|
The same; returning |
delete_service |
Callable[..., Any] | None
|
Replaces the unlink-or-delete rule above, so the outcome
is reported as |
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 |
scope |
QuerySet[Any] | Callable[..., QuerySet[Any]] | None
|
The rows this caller may update, on the terms
|
mode |
RelationMode | str
|
|
field_map |
dict[str, str] | None
|
Forwarded to the target row's own |
exclude_fields |
list[str] | None
|
Forwarded likewise. Excluding the |
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 |
update_service |
Callable[..., Any] | None
|
The same, and additionally receives |
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. |
content_type_field |
str
|
Name of the content-type column, defaulting to
Django's own |
object_id_field |
str
|
Name of the id column, defaulting to |
match_key |
str
|
The field pairing an incoming row with an existing one
(default |
mode |
RelationMode | str
|
|
orphan |
RelationOrphan | str
|
What removing a row does —
|
field_map |
dict[str, str] | None
|
Forwarded to the row's own |
exclude_fields |
list[str] | None
|
Forwarded likewise. Excluding the |
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
|
update_service |
Callable[..., Any] | None
|
The same; returning |
delete_service |
Callable[..., Any] | None
|
Replaces the unlink-or-delete rule above, so the outcome
is reported as |
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.
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
¶
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
class-attribute
instance-attribute
¶
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
¶
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:
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 andupdate_fieldsmachinery as any other field.- the parent's
save()— not a phase; the boundary the phases are named around. 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.GENERIC— generic relations, which need the saved parent's content type and primary key.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 ¶
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 |
required |
tags
|
Iterable[str]
|
Free-form labels, deduplicated into a frozen set. |
()
|
Raises:
| Type | Description |
|---|---|
ValueError
|
|
TypeError
|
|
ImproperlyConfigured
|
|
all ¶
Every entry, in registration order, so a transport's listing is stable.
mutations ¶
The ServiceSpec entries, in registration order.
by_tag ¶
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 ¶
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 ¶
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 ¶
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- aServiceSpec(a write) or aSelectorSpec(a read). The kind is deliberately not stored as its own field: it is derived byisinstancewherever 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 ( |
required |
Returns:
| Type | Description |
|---|---|
Any
|
The shaped queryset, or |
Any
|
configured. |
Raises:
| Type | Description |
|---|---|
ImproperlyConfigured
|
Shaping is configured but |
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.
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.