Dispatch (stable surface)¶
The stable dispatch surface: the primitives an alternate transport
(such as djangorestframework-mcp-server)
builds on instead of re-implementing the "how to call a service / selector"
rules. Every symbol here is importable from the top-level package, follows
semantic versioning, and will not move or change signature within a major
version. Blessed in 0.17, which also removed the private _compat
package — run_service / arun_service now live in services/ and
is_async at the package root; downstreams re-point their imports here
when they bump past 0.17.
Deliberately not part of this surface: dispatch_mutation_for_spec and
dispatch_selector_for_spec. They are view-coupled orchestrators (they take
a view, read its URL kwargs, and walk the get_<action>_*_kwargs hook
chains). The transport-neutral spec dispatcher that composes the leaves
below — dispatch_spec — is documented first.
dispatch_spec and adispatch_spec are the DRF edge over
django-service-specs'
dispatch and adispatch: they compile the spec to the kernel's
(Service specs on the kernel), hand it the call, and
translate what it refuses into DRF's exceptions. The order of a call, the
closed argument set, validation, affordances, preconditions, the run and the
output re-read are the kernel's, documented on its dispatch.
Most leaves on this page are the kernel's own too, re-exported at the path this
package has always exported them from: DispatchResult, UnknownArguments,
OutputPage, paginate_output, DEFAULT_PAGE_SIZE,
operation_affordances, provider_keys,
ProviderKeys, is_async, run_service, arun_service, run_selector,
arun_selector, is_queryset, materialize_retrieve,
amaterialize_retrieve, base_pool and null_progress. Each is the same
object under both names, so the import you have keeps working and the
documentation rendered for it here is the kernel's. The four affordance
functions, enforce_affordances, aenforce_affordances,
unmet_operation_affordance and aunmet_operation_affordance, are drfs' own
thin wrappers over the kernel's: they reserve the names the DRF edge seeds
(request and view) beside the set they are given, and otherwise pass the
kernel's arguments through.
Transport-neutral dispatch¶
The single execution path a non-HTTP transport drives: hand a spec, the
acting user, and a flat params mapping, get back a DispatchResult to
format for your wire. No view, no request required.
Each primitive comes as a sync / async pair — dispatch_spec /
adispatch_spec, render_spec_output / arender_spec_output. The async
member is not a thin alias: a spec's callables (selector, service, kwargs
provider, extend_queryset, filter_set, serializer-context providers) are
written once for both transports and so are never async def, and rendering
walks the ORM. The async members run all of that in Django's thread-sensitive
executor, which is what keeps SynchronousOnlyOperation out of an async
transport. Await the pair together; don't mix an a-prefixed call with a sync
one on the same result.
Who validates a call depends on its view. A live DRF view, meaning any
view that is not an
OfflineServiceView,
has its request validated by the input serializer alone, from the body the
request parsed, in DRF's words. With an OfflineServiceView or no view, the
kernel first checks every declared argument against its type, so "true" for a
boolean or 123 for a string is refused before the serializer runs. A declared
UrlKwarg or QueryParam follows the same split: a live view reads it from its
route or query string alone, and off HTTP it is the caller's argument. See
who validates what.
The call's context. dispatch_spec and adispatch_spec take
call_context=, the kernel's call_context: one plain dict the caller owns
for its own scope, handed as the same object, never a copy, to every stage that
names it. Those are the kernel's dispatch and its target lookup, and the two
stages this edge calls itself, the spec's kwargs provider and input_data,
beside the input_serializer_context and output_serializer_context
providers, so a stage writes where a later one, or the caller, reads. With none,
each call gets a fresh dict. A live view builds one per request, with the same
four entries below, and hands that one dict to its dispatch, to the render, bulk
included, and to every provider and get_* hook it invokes itself (its
kwargs, input_data, serializer-context and progress-reporter hooks among
them) and to a PolymorphicServiceSpec's discriminator. Each is offered it by
name only, as the kernel binds it: a callable declaring a call_context
parameter is handed the dict, and one taking **kwargs alone never sees it.
success_status and response_finalizer are not offered it, and a view naming
it in either is refused when it is built. Two dispatches in one request share
the dict; a second request is a second dict. A serializer's context is a
separate dict of its own:
a provider that names call_context is handed the call's, and copies into the
context it returns what the serializer should see.
The edge fills four standard entries, each only where the dict has none, so a caller's own entry wins:
| Entry | Value |
|---|---|
request |
the request passed to the dispatch |
view |
the view passed to the dispatch |
format |
always None: the edge is handed no format, as base_serializer_context says |
action |
the view's action where it has a non-empty one, else None |
An empty dict passed is still the caller's, and is filled. A transport that
runs the kernel's own dispatch over kernel_spec(spec) instead of this edge
puts the same four keys in its context by the same convention, which is what
the permission check and the serializer context read when the call carries no
request or view of its own (see
the request a call carries).
The rendering and affordance functions below take the same argument: a
transport that renders, or asks which operations to offer, after the dispatch
returned passes the dict it dispatched with.
dispatch_spec¶
dispatch_spec ¶
dispatch_spec(
spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
*,
user: Any,
pool_seeds: PoolSeeds = DEFAULT_POOL_SEEDS,
params: Mapping[str, Any] | list[Any],
request: Any = None,
view: Any = None,
success_status: int | None = None,
argument_binding: ArgumentBinding = ArgumentBinding.AUTO,
unknown_arguments: UnknownArguments = UnknownArguments.IGNORE,
on_target_resolved: TargetGuard | None = None,
progress: ProgressReporter | None = None,
view_hooks: ViewHooks | None = None,
instance: Any = UNSET,
filter_data: Mapping[str, Any] | None = None,
many_as_argument: bool = False,
partial: bool | None = None,
call_context: dict[str, Any] | None = None,
) -> DispatchResult
Run spec through the kernel's dispatch, as DRF would, returning its
DispatchResult.
The edge compiles the spec once per variant
(kernel_spec_variant),
hands dispatch the arguments, the principal and a Grant, and
translates what the kernel refuses into DRF's exceptions
(translate_dispatch_errors).
The order of a call, the closed argument set, the Validator, affordances,
preconditions, the run and the output re-read are the kernel's, documented
on its dispatch. Nothing is rendered here; render the result with
render_spec_output.
Authorization is the caller's, as it always was: the spec's
permission_classes are never asked here. Over HTTP the view has run
them; off HTTP the caller runs
enforce_permissions
first and passes it as on_target_resolved. So the dispatched variant
carries no permissions and the call goes in under a Grant.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
spec
|
ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any]
|
The
|
required |
user
|
Any
|
The acting user, the kernel's principal. |
required |
params
|
Mapping[str, Any] | list[Any]
|
The caller's arguments, one flat mapping. A |
required |
request
|
Any
|
Registered as the call's |
None
|
view
|
Any
|
Registered as the |
None
|
pool_seeds
|
PoolSeeds
|
The project's seeds. Registering |
DEFAULT_POOL_SEEDS
|
success_status
|
int | None
|
Accepted for the transports that pass it. The result carries no status: a transport answers its own. |
None
|
argument_binding
|
ArgumentBinding
|
Whether a service spec with no |
AUTO
|
unknown_arguments
|
UnknownArguments
|
|
IGNORE
|
on_target_resolved
|
TargetGuard | None
|
The guard, called |
None
|
progress
|
ProgressReporter | None
|
The transport's progress reporter, combined with
|
None
|
view_hooks
|
ViewHooks | None
|
The calling view's resolved hook chains: an |
None
|
instance
|
Any
|
The row the caller resolved itself (a view's
|
UNSET
|
filter_data
|
Mapping[str, Any] | None
|
The values the |
None
|
many_as_argument
|
bool
|
Whether a |
False
|
partial
|
bool | None
|
The request's partial flag (a |
None
|
call_context
|
dict[str, Any] | None
|
The call's context, the kernel's |
None
|
Returns:
| Type | Description |
|---|---|
DispatchResult
|
The kernel's |
DispatchResult
|
|
DispatchResult
|
|
DispatchResult
|
|
Raises:
| Type | Description |
|---|---|
ImproperlyConfigured
|
under that |
TypeError
|
|
ValueError
|
a retired |
InvalidArguments
|
the kernel refused the arguments. |
adispatch_spec¶
adispatch_spec
async
¶
adispatch_spec(
spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
*,
user: Any,
pool_seeds: PoolSeeds = DEFAULT_POOL_SEEDS,
params: Mapping[str, Any] | list[Any],
request: Any = None,
view: Any = None,
success_status: int | None = None,
argument_binding: ArgumentBinding = ArgumentBinding.AUTO,
unknown_arguments: UnknownArguments = UnknownArguments.IGNORE,
on_target_resolved: TargetGuard | None = None,
progress: ProgressReporter | None = None,
view_hooks: ViewHooks | None = None,
instance: Any = UNSET,
filter_data: Mapping[str, Any] | None = None,
many_as_argument: bool = False,
partial: bool | None = None,
call_context: dict[str, Any] | None = None,
) -> DispatchResult
Async
dispatch_spec:
the same arguments, the same policies and the same result.
The call is prepared exactly as the sync twin prepares it, in one hop to
Django's thread-sensitive executor: compiling the spec's variant, routing
the arguments, calling the spec's kwargs provider, resolving a service
spec's target and running the on_target_resolved guard all happen
there, since the provider, the lookup and the guard may query. Then the kernel's adispatch runs the call, and its own
rule for what runs off the loop applies to everything after: a sync
selector, service, provider, precondition or affordance condition runs in
the executor, an async def one is awaited. A selector spec's guard runs
as a precondition there.
A LIST result comes back as the lazy shaped queryset, for the async
transport to materialize or paginate in a thread
(arender_spec_output
does both).
render_spec_output¶
call_context= is handed to an output_serializer_context provider that names
call_context, as the same object. A transport renders after the dispatch
returns, so it passes the dict it dispatched with, and the provider reads what
the call's stages left there. These functions fill none of the standard
entries: the dict a dispatch was given already has them, and with none passed
the provider has a fresh dict for this render alone. arender_spec_output,
render_for_audience and arender_for_audience take it the same way.
render_spec_output ¶
render_spec_output(
spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
value: Any,
*,
many: bool = False,
view: Any = None,
request: Any = None,
extras: Mapping[str, Any] | None = None,
view_hooks: ViewHooks | None = None,
call_context: dict[str, Any] | None = None,
) -> Any
Render value to a JSON-shaped payload using spec's output serializer.
The blessed render step that pairs with
dispatch_spec:
every transport renders the same way instead of re-implementing serializer + context
plumbing. Reads the output serializer from spec.output_serializer (selector) or
spec.output_selector_spec.output_serializer (service), a raw dataclass type
rendering through a DataclassSerializer for it; when none is set the value
passes through (list-coerced when many=True so a queryset evaluates).
None renders as None when many is false, without the serializer
being asked: a nullable RETRIEVE that resolved nothing, or a service that
returned nothing, is not a row. The HTTP views already answer that case with a
JSON null and never reach this function, so every caller that did reach it
-- an agent transport rendering an allow_none miss -- used to receive what
a DRF serializer builds for no instance: an object of blank and default field
values, indistinguishable from a real row with empty fields.
The serializer context always carries DRF's baseline — request / format
/ view, from
base_serializer_context
— so a serializer that reads self.context["request"] renders identically here
and behind a DRF view. The spec's output_serializer_context provider, if any, is
resolved with view / request plus the resolved-data extras it declares
(page for a list, instance for a retrieve, result for a mutation) and
merged over that baseline. A provider naming call_context is handed the
call_context passed here, the same object, so a transport rendering after
dispatch_spec returns passes the dict it dispatched with; with none, the
provider has a fresh dict for this render alone.
Pagination is the caller's job — pass the already-sliced page as value
(and the same page object under the matching extras key) so an
id-keyed batched context query reuses the page's result cache.
Affordances. When the selector spec rendered through declares
affordances, each rendered object gains an affordances key: per
declared name, {"available": true}, or {"available": false, "code": ...,
"reason": ...} naming the first condition the row does not meet, or a bare
{"available": false} for a row deleted before its answers were asked,
which fails no condition and so has no sentence to report; None is not a
row and carries none. The answers are read off the rows as the
selector's dispatch left them -- annotations on a queryset's rows, or the
answers it attached to rows a selector returned directly -- so rendering them
costs no query; many rows are materialised once so the rows walked are the
rows rendered.
render_for_audience
renders the same answers, reason included. A spec declaring none renders
exactly as before.
arender_spec_output¶
arender_spec_output
async
¶
arender_spec_output(
spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
value: Any,
*,
many: bool = False,
view: Any = None,
request: Any = None,
extras: Mapping[str, Any] | None = None,
view_hooks: ViewHooks | None = None,
call_context: dict[str, Any] | None = None,
) -> Any
Async
render_spec_output.
Identical arguments, identical result — the whole render runs in Django's thread-sensitive executor instead of on the event loop, which is what an async transport needs. Rendering is full of sync ORM work, none of it optional:
serializer.dataiterates the value. Formany=Truethat evaluates the queryset; per row, every relation a field traverses is another query unless it wasselect_relatedin.- The spec's
output_serializer_contextprovider is user code, and is documented as the place to run one batched query keyed on the page. - The no-serializer path list-coerces, which evaluates a queryset too.
So an async caller that awaited
adispatch_spec —
which returns a LIST result as a lazy queryset, deliberately — cannot render
it inline without raising SynchronousOnlyOperation. Pair the two:
result = await adispatch_spec(spec, user=user, params=params)
payload = await arender_spec_output(spec, result.value, many=result.kind == "list")
A transport that already does its own thread hop around rendering (to paginate or
post-process in the same hop) can keep calling
render_spec_output
inside it — this is for the ordinary case, where the hop shouldn't have to be the
caller's problem to remember.
Pagination is still the caller's job; see the sync twin for the extras
contract and the rest of the semantics.
render_for_audience¶
The agent-audience twin of render_spec_output: it renders through the same
path, then applies the serializer's
FieldMarking markings. An alternate transport whose
consumer is a model calls this instead, so every such transport shapes payloads
identically. Not for a pipeline that feeds one spec's output into the next —
that still wants render_spec_output, or the handles the next step reads by
will have been projected away.
render_for_audience ¶
render_for_audience(
spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
value: Any,
*,
projection: AudienceProjection | None = None,
many: bool = False,
view: Any = None,
request: Any = None,
extras: Mapping[str, Any] | None = None,
view_hooks: ViewHooks | None = None,
call_context: dict[str, Any] | None = None,
) -> Any
render_spec_output
plus the agent projection.
The single call an agent transport makes instead of render_spec_output,
so an MCP server, an in-process toolset, and anything added later shape
payloads identically rather than each growing its own post-processor. Two
copies of the render path have drifted in this stack before; this exists so
a third does not.
Every argument other than projection is passed straight through and means
exactly what it means there, pagination included.
projection is the serializer's resolved markings. Omit it and one is
derived from the spec, which costs a serializer instantiation per call — a
transport that registers its tools up front should build it once with
audience_projection_for_spec
and pass it in.
Render the agent's answer with this. A pipeline that feeds one spec's
output into the next must keep rendering with render_spec_output, or the
handles the next step reads by will have been projected away.
Declared affordances pass through whole: available, the code a
transport or client branches on, and the reason -- the sentence a model
relays when it explains why an action is not possible, written for exactly
that reader.
arender_for_audience¶
arender_for_audience
async
¶
arender_for_audience(
spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
value: Any,
*,
projection: AudienceProjection | None = None,
many: bool = False,
view: Any = None,
request: Any = None,
extras: Mapping[str, Any] | None = None,
view_hooks: ViewHooks | None = None,
call_context: dict[str, Any] | None = None,
) -> Any
Async
render_for_audience.
Identical arguments, identical result. The whole render — and the projection
that follows it — runs in Django's thread-sensitive executor, for the same
reason
arender_spec_output
exists: rendering evaluates querysets and traverses relations, so an async
caller cannot do it inline without SynchronousOnlyOperation.
paginate_output¶
One page of a list selector's rows, in the envelope
output_to_json_schema has always
published for kind=LIST, paginate=True — {items, page, totalPages, hasNext}.
That schema described a payload nothing in this package produced: the shaper
lived in a transport, so one agent transport wrapped its pages and another
returned a bare list, against one schema claiming the envelope for both.
Rendering happens between the two calls, because it needs a view, a request and a spec that belong to the caller:
page = paginate_output(rows, page=page, limit=limit, max_page_size=ceiling)
rendered = render_for_audience(spec, page.items, projection=projection, many=True, ...)
payload = page.envelope(rendered)
page and limit are taken already parsed. Turning an untyped argument into an
integer is where transports legitimately differ — a public endpoint clamps a
malformed value and answers, an in-process toolset can hand the model its
mistake back and ask again — and that is a policy about bad input, not about
what a page is. Everything that is about what a page is lives here: the clamps
at both ends, the count taken before the slice, and the reported page being
the one actually served.
rows is a queryset, a manager or a sized, sliceable sequence. A queryset is
counted with COUNT and sliced lazily; a manager — Model.objects, which a
LIST selector may return as it is — is paged as its .all(); a list or
tuple is paged in memory. Anything else raises TypeError, since there is
nothing to count and nothing to slice.
paginate_output ¶
paginate_output(
rows: Any,
*,
page: int | None = None,
limit: int | None = None,
max_page_size: int | None = None,
) -> OutputPage
Slice rows into the page a transport serves.
A function a transport calls on a paged spec's result value before
presenting it, not a paging scheme: the names a caller pages by, and the
ceiling a mount allows, are the transport's. A transport takes them off the
call before the spec's closed argument set sees them, and bounds
legitimately differ between two mounts of one spec.
Whether to page is read off the spec, never off the result's kind: a
LIST selector spec, or a service spec whose output selector is a
LIST, is paged, and
spec_output_schema
describes it as the envelope under paginate=True. A many or
returns_many service spec's result is kind="list" too, and is never
paged: it is every row the service returned, which its output schema
describes as a bare array whatever paginate says.
page and limit default to 1 and
DEFAULT_PAGE_SIZE.
Out-of-range values clamp at both ends - limit down to
max_page_size and up to 1, page up to 1 and down to the last page
that exists - and the clamps are not silent the way truncating an unpaged
result would be: totalPages and hasNext are computed from the
clamped limit, and the returned page is the one actually served. A
caller that asked for 500 rows and got 100, or for page 10 of 3, is told
what it received.
Both values are taken already parsed. Turning an untyped argument into an integer is where transports legitimately differ - a public endpoint clamps a malformed value and answers, an in-process toolset can hand a model its mistake back and ask again - and that is a policy about bad input, not about what a page is.
The upper clamp on page is why total is counted before the
slice: (page - 1) * limit on an unclamped page is an arbitrarily large
SQL OFFSET, which a backend either scans towards or refuses outright
with a DatabaseError this does not catch. No slice is longer than
total for the same reason, so a limit past any integer a database
holds, which no max_page_size bounded, still serves every row; the
page reports the limit asked for.
A queryset is counted with COUNT and sliced lazily, so the page's
items is still a queryset: present them, as
present does a list,
and the page is read once. A manager - any BaseManager, so one built
with BaseManager.from_queryset too - is paged as its .all(), since
it counts and cannot be sliced.
Raises:
| Type | Description |
|---|---|
TypeError
|
|
DEFAULT_PAGE_SIZE¶
DEFAULT_PAGE_SIZE
module-attribute
¶
Rows per page when the caller asks for a page and names no size.
OutputPage¶
OutputPage
dataclass
¶
One page of rows, plus what a caller needs to know to ask for the next.
What paginate_output
returns, and the payload
spec_output_schema(paginate=True)
describes. The schema and the shaper are one mechanism in two places, so
they are kept beside each other: a transport that wraps its pages and one
that returns a bare list, against a schema claiming the envelope for both,
is the drift this exists to prevent.
items is the slice itself, unpresented: presenting needs the spec and
belongs to the caller. Hand the presented result back to
envelope
to get the wire shape.
total_pages
property
¶
How many pages exist at this limit. At least one, even for no rows.
An empty result is one empty page rather than zero pages: page is
1-based and the page served for an empty result is 1, so reporting 0
would describe a page the caller was just handed as not existing.
envelope ¶
Wrap already-presented rows in the published paging envelope.
rendered rather than items because an audience projection lands
on the rows and never on the envelope: page, totalPages and
hasNext are this shape's own keys and belong to no Output, so a
projection walking them would look for markings that cannot exist.
base_serializer_context¶
The DRF baseline (request / format / view) that every serializer gets for
free over HTTP, synthesized for the off-HTTP path. dispatch_spec and
render_spec_output apply it themselves — reach for it directly only when a
transport builds a serializer outside them. See
Customise serializer context.
base_serializer_context ¶
Build the baseline serializer context a spec-driven render starts from.
Over HTTP every serializer DRF builds carries
get_serializer_context —
{"request", "format", "view"} — so serializers routinely read
self.context["request"] unguarded (build_absolute_uri,
request.user, a permission check in a SerializerMethodField). A
spec's input_serializer_context / output_serializer_context provider
is additive config layered on top of that baseline, not a replacement for
it, so off HTTP — where there is no DRF view to ask — the baseline has to be
synthesized rather than skipped. Without it the same serializer that renders
over HTTP raises KeyError: 'request' when the spec is dispatched from an
MCP tool call, a Pydantic-AI toolset, or a management command.
Two sources, in order:
view.get_serializer_context()when the view has it — a real DRF view (the HTTP bulk path renders through the same helper). It is the view's own documented extension point and may already be overridden, so it wins.- Otherwise DRF's shape, synthesized from the
view/requestthe caller passed: the synthetic pair frombuild_offline_contextoff HTTP, orNonewhen the caller supplied neither.formatis alwaysNone— content negotiation is an HTTP-only concern.
The keys are always present, mirroring HTTP: a serializer reading
self.context["request"] off HTTP sees the synthetic request (and
None only when the caller passed no request at all), never a
KeyError. Absolute-URI fields additionally need real headers — pass the
ambient http_request to build_offline_context when the transport has
one, as the MCP server does.
The spec's provider is merged over this by
resolve_output_context /
resolve_input_context, so a
provider keeps the final say on every key — including these three.
renderable_serializer_class¶
The class an output_serializer declaration renders through: a serializer
class as declared, a bare dataclass wrapped in a DataclassSerializer.
render_spec_output and the HTTP views resolve it themselves — a transport
that instantiates an output declaration outside them resolves it here first,
or a dataclass output raises where a serializer would have rendered.
renderable_serializer_class ¶
The class an output_serializer declaration renders through.
A bare dataclass type is wrapped in a DataclassSerializer — the payload
output_to_json_schema
already describes for it, field by field — and anything else comes back
exactly as declared, None included. Instantiating the declaration directly
works for a serializer and fails for a dataclass, whose own __init__ is
handed many= / context=; resolving it here first is what lets both
shapes render.
Every render site in this package resolves through it: render_spec_output,
the selector views' and viewsets' get_serializer_class(),
@selector_action, a mutation's response, and
build_audience_projection,
so an agent is shaped by the markings of the class it is actually rendered
with. A transport that instantiates an output declaration itself — to render a
resource outside render_spec_output, say — calls this and instantiates what
it returns:
serializer_class = renderable_serializer_class(spec.output_serializer)
payload = serializer_class(
value, many=many, context=base_serializer_context(view=view, request=request)
).data
A dataclass gets one class for the life of the process, so repeated calls return the same object.
The pass-through is deliberate rather than a check. A BaseSerializer
subclass is the ordinary case, and a declaration that is neither is refused
where its schema is derived; refusing it here as well would break a caller
that renders through a serializer-shaped factory without ever deriving one.
DispatchResult¶
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 Validator's |
context |
PresentationContext | None
|
The call a presenter renders for: its principal, its target,
its resolved seeds, the call's context and the arguments sent for
the spec's |
It carries no status: a transport answers its own, which over HTTP is the
view's success_status. dispatch_spec still accepts success_status= for
the transports that pass it, and ignores it. context, new with the kernel,
is the call a presenter renders for, which the output serializer's context is
built from.
A ServiceSpec's kind is "list" for a many=True spec, and otherwise
whenever its output_selector_spec declares kind=SelectorKind.LIST, with a
selector to re-read through or without one. Without one, the value is the
service's own return, passed through as it came: a QuerySet stays lazy, as a
re-read one does, and a list or a generator is the same object. A return that is no set
of rows (a mapping, a str or bytes, None, anything that does not iterate)
raises ImproperlyConfigured naming the declaration and the type returned,
after the service has written; see
what a service's output schema describes,
which states the same array.
Input policies¶
Three optional, caller-side policies let a transport map its wire onto a spec
without dispatch_spec baking in HTTP's implicit answers. The spec declares
what (its inputs, filters, output shape, permissions); the caller declares
how strict to be about undeclared keys and how to authorize a resolved
target.
ArgumentBinding¶
A call has one argument pool: every callable takes its keyword arguments
from it by name, and an author's value (a view hook's, or the spec's kwargs
provider's) outranks the caller's for the same name. What the enum still
decides is one question, about a ServiceSpec with no input_serializer and
about a SelectorSpec: whether the callable's own parameters are caller input.
AUTO, the default, isBUNDLEfor a service andSPREAD_AUTHOR_WINSfor a selector. A selector's declaredreadsare the caller's under every member.- Under
BUNDLEa selector's own parameters are not the caller's either: a route capture, an author's value, a seed and its declaredreadsfill them, itsfilter_setstill reads the caller's values, and a parameter nothing fills is the callable's ownTypeErrorrather than a400no client could answer. A selector view dispatchesBUNDLE. - Under
BUNDLEa serializer-less service's own parameters are never the caller's: it receivesdata={}, its declaredreadsand the target lookup's row, and nothing else the client sends. - Under
SPREAD_AUTHOR_WINSits signature is the caller's input, still below an author's value on a conflict. SPREAD_CALLER_WINSis refused withValueErrornamingAUTO: leave a key out of thekwargsprovider where the caller's value should win.
A service with an input_serializer reads the serializer under every member:
the validated payload arrives as data, and each validated value also reaches a
parameter of the same name (the field's source). A parameter named like a key
any callable of the call marks NotClientInput is never handed the client's
validated value: the client's
value reaches it only through data, while an author's value for it (a view
hook's, the spec's kwargs provider's or input_data's) still reaches the
parameter. On a DRF view such a parameter needs a default, since as_view()
refuses a required parameter no call is sure to provide. See
a service with no input serializer.
ArgumentBinding ¶
Bases: Enum
Whether a caller's values may fill a service's own parameters.
A call has one pool, and every dispatched callable takes its keyword
arguments from it by name: the seeds (user, progress, request,
view, a project's registered ones and, for a mutation, data,
serializer, instance / collection), the arguments the spec
declares - its reads, its filter's names, its input serializer's fields -
and the author's values from a view's hooks and the spec's kwargs
provider. An author's value for a declared argument replaces the
caller's, so the author wins a conflict, and a key a typed provider is
annotated to fill is never the caller's at all. A seed's name is never an
argument: a caller's value named like one is dropped (view aside,
which UnknownArguments.REJECT refuses), and an input serializer's
field of that name is validated into data while the seed keeps its
own value, so a client cannot poison transport-controlled state by naming
an argument after one. A name any callable of the call marks
NotClientInput is the server's: the client's value for an input
serializer's field of that name reaches the service only inside data,
while an author's value for it (a view hook's, the spec's kwargs
provider's or input_data's) still reaches the parameter.
What is left for this enum is one question, about a service spec with no
input_serializer and about a selector spec: whether the callable's own
parameters are caller input. A selector spec's declared reads are the
caller's under every member. It
is a trust-boundary decision, which is why it belongs to the caller
rather than the spec. Pass the member directly; the value never appears
on a wire.
Attributes:
| Name | Type | Description |
|---|---|---|
AUTO |
The default, resolved per spec type: |
|
BUNDLE |
A service's own parameters are never the caller's: the
validated payload reaches it as |
|
SPREAD_AUTHOR_WINS |
A service with no |
|
SPREAD_CALLER_WINS |
Retired. |
UnknownArguments¶
How strict dispatch_spec is about params keys outside the spec's declared
set: drop them (IGNORE, dispatch_spec's default) or refuse them (REJECT,
the kernel's default). PASSTHROUGH stays importable, as the kernel's member,
but passed to dispatch_spec it is refused with ValueError: an argument the
spec does not declare never reaches a callable. Declare it instead, as a reads
entry, a serializer field or a parameter.
The declared set is the compiled spec's parameters
(Service specs on the kernel): the
input_serializer's fields, or, for a service with none dispatched under
SPREAD_AUTHOR_WINS, its own signature; a
selector's signature, less the names its filter_set owns, which are the
filter's; the target lookup's; and every reads entry. A key that any callable
in the call marks NotClientInput, and every pool seed's name, is
server-owned and never declared. REJECT names every undeclared key in one
"Unexpected argument(s): ..." message under non_field_errors, a row's
inside that row; a pool seed's name is never one, so a caller's request is
dropped like any seed's, while a caller's view is refused. Off HTTP an unknown key inside a nested serializer is refused in
the kernel's words, {"inner": {"zzz": ["Unknown argument."]}}; over a live
view the nested serializer drops it, as DRF does. On DRF 3.18 or later a live
view's many call can answer in two shapes: rows the serializer accepted but
which carry an undeclared key are refused as a list of rows, while a row the
serializer refuses comes back in DRF's mapping of row indexes. Where a target lookup
will query, an undeclared key is refused before it does. A selector or target
lookup taking a bare **kwargs, and a service with no input_serializer taking
one under SPREAD_AUTHOR_WINS, drop an undeclared key rather than refuse it.
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 policy. 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. PASSTHROUGH is a member and not
a policy, and every function taking one refuses it.
REJECT
class-attribute
instance-attribute
¶
Refuse the undeclared key with InvalidArguments, at the level it appeared.
PASSTHROUGH
class-attribute
instance-attribute
¶
Kept as a name, and refused as a policy.
It is here for the current release of djangorestframework-services, whose
published transports compare a policy against it by identity: with the
member, that comparison is false for either policy, where its absence
would raise AttributeError. It is never run.
check_arguments
and
parameters_schema
raise ValueError for it where they read the policy, so dispatch,
adispatch, check_call, bind_arguments and spec_input_schema
refuse it too, rather than running it as IGNORE.
TargetGuard¶
The object-permission hook invoked with the resolved mutation target before the
service runs. Its signature matches enforce_permissions, so that primitive is
passed directly — by name, not wrapped in a lambda.
TargetGuard ¶
Bases: Protocol
Invoked with the resolved target before a mutation runs; raise to abort.
dispatch_spec deliberately does not consult permission_classes
(authorization is the caller's job — which is why
enforce_permissions
ships separately). But object-level checks need the resolved instance, which only
dispatch_spec sees, before the service runs. So dispatch_spec exposes this
hook rather than folding authz in: it stays authz-agnostic, only invoking a
caller-supplied guard.
The signature is deliberately identical to
enforce_permissions, so the canonical wiring
is passing that function directly, by name — not wrapping it in a lambda:
dispatch_spec(spec, ..., on_target_resolved=enforce_permissions)
A consumer needing custom logic writes a module-level function with the same
shape and passes it the same way; ty enforces conformance.
dispatch_spec builds the
OfflineContext
itself, from what the caller passed it, and calls guard(spec, context,
instance=target). That distinction matters for the canonical wiring above:
a pure non-HTTP caller may pass neither request nor view, and
enforce_permissions
hands the request straight to a DRF permission class, which reads
request.user. So a spec declaring permission_classes must be
dispatched with the request / view from
build_offline_context;
without them the guard refuses by name rather than failing inside DRF. A
custom guard that needs no request is unaffected. instance is the resolved row for an update, the
resolved set for a collection (bulk) mutation, and None for a create — so the
guard fires uniformly on every resolved target, running the class-level check when
there is no object.
ViewHooks¶
The calling DRF view's resolved hook-chain layers, handed to the core so one
pipeline serves both transports. HTTP-only — like view, an off-HTTP caller
omits it and nothing changes. It carries the view layers only
(get_service_kwargs, get_input_data, the serializer-context hooks and their
per-action twins); the spec's own kwargs / input_data /
input_serializer_context providers stay the core's job, so they resolve exactly
once no matter who dispatches.
The spec's kwargs provider outranks the view's hooks. A hook value for a name
the call reads (a parameter of the spec's callable or target lookup, or a field
of the input serializer, by its name or its source) is that argument's value,
over the client's, and is validated as the client's would be. For a field, the
value given is validated on its own, by the field and its validate_<field>,
once the serializer has accepted the body, never what the field reads back from
the body; what that gives is the value under its source key, and under the key
the hook used where that differs, wherever the call
reads it (the service's parameter, data, the bound serializer's
validated_data), even where a later field with the same source, a
source="*" nested serializer, the serializer's validate() or a
to_internal_value override writes that key from the client's input, or runs
the field's validation again on a value of its own; input_data is held the
same way. That run is the field's second for the call, so a related field
queries once more for each key it holds and the field's validators and its validate_<field> run
twice, as do a nested serializer's validate() and its fields' own. A field with a dotted source or
source="*" hands it to the parameter named like the field. Where data is a
dataclass instance it is left as the serializer built it, and so are a many
spec's rows (see extra kwargs). A value naming a
seed (request, view, progress) is dropped, the seed winning; anything else
reaches the callables beside the arguments. input_data from the hooks is
merged beneath the spec's own input_data.
ViewHooks
dataclass
¶
The HTTP view's hook-chain contributions, resolved and passed down.
dispatch_spec is the single execution core, but the view layer owns a
configuration surface the core knows nothing about: the get_service_kwargs /
get_<action>_service_kwargs / get_input_data / get_<action>_input_data /
get_*_serializer_context chains declared on
MutationFlowMixin
and its viewset mixins. Those are methods on a DRF view, so the core cannot resolve
them; this carrier is how a caller that has resolved them hands them over.
These are the view layers only — never the spec's own providers.
Each chain resolves view.get_<x> → view.get_<action>_<x> →
spec.<x>, spec winning on overlap. dispatch_spec owns that last
layer, so a caller must pass spec_kwargs=None / spec_provider=None
when resolving these; hand over the fully resolved chain instead and the
core runs the spec provider twice, which a spec.kwargs doing a
tenant lookup will not survive.
Every field defaults to None (contributes nothing), so a transport with
no view — MCP, an agent toolset, a management command — simply omits the
argument and the core behaves exactly as it did before this existed.
Attributes:
| Name | Type | Description |
|---|---|---|
extra_kwargs |
Mapping[str, Any] | None
|
Merges into the dispatched callable's pool, beneath
|
input_data |
Mapping[str, Any] | None
|
Merges onto the client payload before validation, beneath
|
input_serializer_context |
Mapping[str, Any] | None
|
Layers onto the baseline serializer context
( |
output_serializer_context |
Callable[[Any], Mapping[str, Any]] | None
|
Lazy — a callable taking the final
post-selector |
progress |
Any | None
|
The view's own progress sink, resolved for both chains — a selector can be long too (a large export is a selector). Reach for it only when a buffered request genuinely needs it: if a request runs long enough to want progress, a task plus polling is usually the right shape, and this seam is for the cases where that does not apply (a streaming response, or a websocket sidecar the host already runs). |
Authorizing an off-HTTP call¶
dispatch_spec is authz-agnostic by design — it never consults a spec's
permission_classes (on HTTP that is the view's job). An off-HTTP transport
that wants the same authorization a DRF view would apply wires
enforce_permissions in two places:
from rest_framework_services import (
adispatch_spec,
build_offline_context,
enforce_permissions,
)
context = build_offline_context(user)
# 1. Class-level `has_permission`, before any work. Covers create / list-payload
# and every spec that has no resolvable target.
enforce_permissions(spec, context)
# 2. Object-level `has_object_permission`, on the resolved target. Fires on the
# mutation *and selector* paths (update, retrieve, and — class-level only —
# bulk / list collections, which are not per-row authorized).
result = await adispatch_spec(
spec,
user=user,
params=params,
request=context.request,
view=context.view,
on_target_resolved=enforce_permissions,
)
The upfront call is what authorizes a spec with no target (a create, or a
many=True list-payload); the on_target_resolved hook adds object-level checks
once the row (or collection) is resolved. Together they are the canonical wiring
for every spec kind. enforce_permissions is collection-safe: a resolved
queryset runs only the class-level check, never has_object_permission.
build_offline_context¶
build_offline_context ¶
build_offline_context(
user: Any,
params: Mapping[str, Any] | list[Any] | None = None,
*,
http_request: HttpRequest | None = None,
action: str | None = None,
kwargs: Mapping[str, Any] | None = None,
query_params: Mapping[str, Any] | None = None,
host: str | None = None,
) -> OfflineContext
Build the
OfflineContext for
dispatching a spec outside an HTTP request.
dispatch_spec
forwards request / view to spec callables (kwargs providers,
extend_queryset, context providers) that declare them, and
enforce_permissions
needs a request + view to evaluate permission_classes. This synthesizes both so
a spec written for the HTTP transport keeps working when driven from a Pydantic-AI
toolset, the MCP server, a management command, or a task runner.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
user
|
Any
|
Set as |
required |
params
|
Mapping[str, Any] | list[Any] | None
|
Seeds |
None
|
http_request
|
HttpRequest | None
|
An ambient Django request to wrap, so its headers / |
None
|
action
|
str | None
|
The view action name, exposed on the
|
None
|
kwargs
|
Mapping[str, Any] | None
|
The channel for route-derived values — the off-HTTP counterpart of a
view's URL captures.
|
None
|
query_params
|
Mapping[str, Any] | None
|
Seeds the request's |
None
|
host
|
str | None
|
The origin the synthesized request reports, so |
None
|
The
| Type | Description |
|---|---|
OfflineContext
|
|
OfflineContext
|
to hand to |
Read-shaping over the offline path. Pass query_params= to seed the synthetic
request's GET QueryDict — the source request.query_params reads. That is how
read-shaping params that are not spec inputs reach the serializer off-HTTP: any
serializer that branches on request.query_params (django-restql field selection,
custom serializers), and a request-scoped FilterSet that reads
self.request.query_params itself. It is not what a filter_set is bound to:
dispatch_spec binds a SelectorSpec's own filter_set, and a target lookup's,
to filter_data when it is passed, else to params, reading only the filter's
own names from either. filter_data, and the query string a live view's filter
reads, feed the filter alone: the selector, its preconditions and a target
lookup's selector never receive them, unless a declared QueryParam names the
filter. Under a filter's name those callables take the caller's params value as
sent, a route capture, or an author's value. A service's output_selector_spec.filter_set
is bound to filter_data as it was passed, else to an empty mapping, unless the
spec sets output_filter_from_call=False, which binds it to an empty mapping on
every call. To filter from the synthetic query
string, pass filter_data=context.request.query_params. It does not make DRF
filter_backends (SearchFilter / OrderingFilter) run — the offline path never
calls filter_queryset; filter_set is the drf-services-native equivalent.
OfflineHttpRequest¶
The synthetic request build_offline_context creates when there is no ambient
one. Relevant when you're deciding what build_absolute_uri() should return off
the HTTP path — see
Absolute URLs off the HTTP path.
OfflineHttpRequest ¶
Bases: HttpRequest
An HttpRequest with no ambient host, made safe to use.
Built by
build_offline_context
when the caller has no real request to wrap (a Pydantic-AI toolset, a management
command, a task runner). A bare HttpRequest has an empty META, and
Django resolves the host from HTTP_HOST / SERVER_NAME — so get_host()
raises KeyError: 'SERVER_NAME' and takes build_absolute_uri() down with it.
That reaches serializers through the most ordinary field there is: DRF's
FileField.to_representation calls request.build_absolute_uri(value.url)
whenever a request is in the context.
Two behaviours, decided by whether the caller configured a host
(build_offline_context(host=…), which seeds META — a configured
request is an ordinary one and none of this applies):
- Host configured — nothing here intervenes. Absolute URIs are built by Django exactly as on HTTP.
- No host —
build_absolute_urireturns the location unchanged, i.e. the relative URL. There is no honest absolute URL to return: the process has no idea what origin serves it, and a guess (the firstALLOWED_HOSTSentry, say) would emit confidently-wrong links that look valid. A relative URL is the same thing DRF's own file / hyperlinked fields fall back to when there is no request in the context at all, so this is the shape those serializers already handle.
get_host() still raises without a host — but with a message naming the
fix, rather than a bare KeyError from Django's internals.
offline_host
class-attribute
instance-attribute
¶
The configured host ("example.com" / "example.com:8000"), or
None when the caller didn't configure one. Set by
build_offline_context;
when it is set, META carries the matching keys and Django's own
machinery is in charge, so nothing on this class changes behaviour.
build_absolute_uri ¶
Absolute URI when a host is configured; the location itself when not.
Degrading beats raising: the caller is a serializer field rendering a link, and a relative URL is a usable answer that its consumer can resolve against whatever origin it reached the data through.
get_host ¶
The configured host verbatim; a pointed error when there is none.
Deliberately not validated against ALLOWED_HOSTS. That setting
rejects spoofed Host headers from untrusted clients; this value came
from the project's own code, and there is no client. Requiring it to
appear in ALLOWED_HOSTS would break the ordinary case of a worker or
management command that renders links for a site it does not itself
serve — and would add nothing, since a caller who can pass a host can
equally pass one that is on the list.
enforce_permissions¶
enforce_permissions ¶
enforce_permissions(
spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
context: OfflineContext,
*,
instance: Any = None,
) -> None
Enforce spec.permission_classes against an off-HTTP context.
Mirrors what a DRF view does on the HTTP path — [perm() for perm in
spec.permission_classes] then perm.has_permission(request, view) — but against
the synthetic request and view from
build_offline_context.
dispatch_spec deliberately does not consult permission_classes
(authorization is the view's job on HTTP), so an off-HTTP transport must call this
itself before dispatching, or it would skip authorization entirely.
When instance is a Django Model, object-level permissions
(has_object_permission) are also checked — matching the HTTP path's
check_object_permissions against a resolved instance. (drf-mcp's adapter
omits this; off-HTTP parity restores it.) A non-Model instance — most
importantly the collection queryset a bulk / LIST dispatch resolves — runs
only the class-level check, never has_object_permission: object
permissions are a per-row concept (the BULK decision authorizes per-set, not
per-row), and has_object_permission(request, view, <QuerySet>) would
AttributeError or silently mis-authorize. This makes
on_target_resolved=enforce_permissions the safe canonical guard for
every dispatch mode.
spec.permission_classes is None (the default — "inherit the view's
class-level permissions" on HTTP) is a no-op off-HTTP: there is no view
class to inherit from, so the transport owns any default policy. An empty
sequence is likewise a no-op ("no permissions").
Raises PermissionDenied (403) on the first
failing permission, carrying that permission's message / code when it
declares them — the same surface a DRF view produces.
Permission classes that read DRF APIView attributes beyond request /
action / kwargs (e.g. DjangoModelPermissions, which inspects
view.queryset) are not supported off-HTTP.
enforce_affordances / aenforce_affordances¶
dispatch_spec checks a spec's affordances itself. A transport that runs a
service without the core -- a chain step handed a pool it built, say -- calls
one of these the way it calls enforce_permissions: after permissions and target
resolution, before the spec's preconditions, which is the order the core uses.
Skip it and a call the direct path refuses with ActionUnavailable succeeds on
that path instead. Like the two below, they are the kernel's functions with
request and view reserved beside the reserved set passed, the names
dispatch_spec seeds itself, so a condition reading either is handed it from
the pool. Each takes call_context= too, passed to the kernel's function, which
hands it to a condition that names call_context (and to none taking a bare
**kwargs): pass the dict the call dispatches with, so the check and the call
read one context. A call_context passed beside a different one already in the
pool is refused with ImproperlyConfigured.
enforce_affordances ¶
enforce_affordances(
spec: Any,
pool: dict[str, Any],
*,
instance: Any,
reserved: frozenset[str] = RESERVED_POOL_SEEDS,
call_context: dict[str, Any] | None = None,
) -> None
Refuse the call with ActionUnavailable if one of the spec's affordances is not met.
django-service-specs' own enforce_affordances, with request and
view reserved beside reserved: dispatch_spec seeds both itself,
so a condition declaring either is handed it from pool here as it is
at the call. call_context is the call's context, handed to a condition
naming it as the kernel's function hands it. See the kernel's function for
the order it must run in.
aenforce_affordances
async
¶
aenforce_affordances(
spec: Any,
pool: dict[str, Any],
*,
instance: Any,
reserved: frozenset[str] = RESERVED_POOL_SEEDS,
call_context: dict[str, Any] | None = None,
) -> None
Async enforce_affordances, with request and view reserved the same way.
unmet_operation_affordance / aunmet_operation_affordance¶
A transport deciding which operations to offer, such as an MCP server building
its tool list or an agent assembling its toolset for a step, asks one of these
before any call is attempted. They answer the conditions the capability manifest
calls "operation" scope, the callables that need no row, and return the first
one not met, or None. A condition on the row is skipped without a query. Build
the pool with base_pool and the same seeds= the call would use, passing the
request (and view, where there is one) the call would be handed, and pass
reserved=seeds.reserved. dispatch_spec seeds request and view itself on
every call, beside the seeds it is handed, so these functions reserve both beside
the set you pass: a condition declaring either is answered from the pool here as
it is at the call, and the two agree. The answer is advisory: dispatch_spec
still enforces every affordance when the operation is called. call_context=
is handed to a condition naming call_context, as in enforce_affordances;
with none, the one the pool carries is, else a fresh dict the conditions of
this call share.
unmet_operation_affordance ¶
unmet_operation_affordance(
spec: Any,
pool: dict[str, Any],
*,
reserved: frozenset[str] = RESERVED_POOL_SEEDS,
call_context: dict[str, Any] | None = None,
) -> Affordance | None
The first of the spec's operation-scope affordances not met now, or None.
django-service-specs' own unmet_operation_affordance, with request
and view reserved beside reserved: dispatch_spec seeds both
itself, so a condition declaring either is handed it at the call, and is
handed it here from pool too. Pass reserved=seeds.reserved for the
seeds the call registers, and listing time reads the names call time does.
call_context is the call's context, handed to a condition naming it as
the kernel's function hands it. See the kernel's function for everything
else it answers.
aunmet_operation_affordance
async
¶
aunmet_operation_affordance(
spec: Any,
pool: dict[str, Any],
*,
reserved: frozenset[str] = RESERVED_POOL_SEEDS,
call_context: dict[str, Any] | None = None,
) -> Affordance | None
Async unmet_operation_affordance, with request and view reserved the same way.
operation_affordances¶
The conditions those two would ask, before anything is asked. An empty answer means there is nothing to ask at list time, so a transport can skip building a pool, or making a thread hop of its own, for most of the operations it lists.
operation_affordances ¶
The operation-scope affordances spec declares, in declaration order.
The callable ones: the conditions answered without a row. A condition on the
row is left out, and so is anything on a SelectorSpec, which answers
(): its affordances maps names to other operations, to be answered
for its rows, and reading that mapping as the selector's own conditions
would answer a question about some other operation. A spec declaring none
answers () too.
A transport uses this to decide cheaply whether there is anything to ask at
list time before it builds a pool: most operations declare nothing, and an
empty answer means
unmet_operation_affordance
would return None without running anything. That function selects its
conditions here, so a transport's shortcut and the answer it skips cannot
disagree about which conditions there are.
Shared¶
resolve_callable_kwargs¶
resolve_callable_kwargs ¶
Pick the subset of pool matching fn's declared parameters.
If fn declares **kwargs, the entire pool is passed.
Otherwise only parameters present in the signature are forwarded.
provider_keys¶
The static half of the kwargs= provider contract, whose runtime half is
dispatch calling the provider and dropping every key it returns as UNSET. A
transport building a selector's input schema reads it to fill in
supplied, and to know
which calls to refuse before dispatch, rather than keeping a copy of the
reader:
from rest_framework_services import provider_keys
keys = provider_keys(spec.kwargs)
if keys is None:
... # The provider may fill any name, so none is required for lacking a default.
else:
filled, declinable = keys
# ``filled`` joins ``supplied``. A ``declinable`` key stays the caller's to
# send and is not required of it, since the provider may fill it after all.
The answer is a ProviderKeys, a NamedTuple, so a caller
that would rather not unpack it reads keys.filled and keys.declinable.
provider_keys ¶
The names provider fills and those it may decline, or None if it does not say.
The static half of the kwargs= provider contract, whose runtime half is
how dispatch calls the provider. ServiceSpec.kwargs and
SelectorSpec.kwargs return a mapping, and one annotated to return a
TypedDict says, before it runs, which names it fills. A
transport reads that here to build a schema that does not ask the caller
for them, and to know which calls to refuse before dispatch, rather than
keeping a copy of this reader.
Returns a
ProviderKeys,
(filled, declinable): two disjoint sets that together are every key of
the TypedDict, the keys the provider always answers for and the keys
whose value admits UnsetType, which it may decline with UNSET. It
unpacks as a pair and names each set, so keys.filled reads as well as
filled, declinable = keys.
ProviderKeys(frozenset(), frozenset()) for provider=None: a spec with
no provider fills nothing. None when the provider says nothing about its
keys -- no return annotation, a plain dict, a lambda, or a return
annotation that does not resolve -- so it may fill any name, and a transport
requires none of them for lacking a default. Held by
test_a_spec_without_a_provider_fills_nothing and
test_a_provider_whose_return_says_nothing_is_untyped.
Each annotation costs only what it says. The return annotation is
resolved on its own, in the globals of the function a decorator wrapped
rather than the wrapper's (decorated-elsewhere in
test_a_generic_typed_dicts_arguments_decide_which_keys_may_be_declined),
so a parameter annotation naming a type imported only under
TYPE_CHECKING leaves the keys readable
(test_a_parameter_type_imported_only_for_type_checking_leaves_the_keys_readable).
The keys are read off the TypedDict without hints, and each value is
resolved on its own, so one that does not resolve makes only its own key
declinable: whether the provider may decline it cannot be read, and
declinable is the reading that neither hides the name from the caller nor
requires it (test_a_value_type_that_does_not_resolve_makes_only_its_key_declinable).
Python 3.14's lazily evaluated annotations are read the same way, in the
format that leaves an unresolvable name as a forward reference rather than
raising (test_lazily_evaluated_annotations_are_read_one_at_a_time).
That format is why typing-extensions is floored at 4.14: on 3.14, 4.13
hands the standard library's annotationlib a format number it reserves
for internal use, which raises, and every typed provider would read as
untyped.
A generic TypedDict is read with whatever binds its parameters.
Scope[str | UnsetType] does not relay __required_keys__, so the keys
come off Scope, where a value annotated T is the bare type variable.
What T stands for is substituted wherever it appears in a value, a union
included, whether an alias's arguments bind it
(test_a_generic_typed_dicts_arguments_decide_which_keys_may_be_declined)
or a class statement does, as class Declining(Scope[str | UnsetType])
does, and a subclass handing its own parameter on to Scope
(test_a_subclass_binding_decides_which_keys_may_be_declined). An
argument that does not admit UNSET still fills the key
(test_a_parameterised_typed_dict_is_read_off_its_origin), and one
wrapped in Annotated is read as what it wraps
(test_annotated_around_a_binding_is_read_as_what_it_annotates). A PEP 695
class Scope[T](TypedDict) is read the same way under postponed
annotations, where the "T" a value leaves resolves only beside the
class's __type_params__
(test_a_pep_695_type_parameter_is_read_as_the_class_declares_it).
Each key is read with the bindings of the class that declared it, so
one T reused at every level reads as each class binds it:
class Shadow(Scope[list[T]], Generic[T]) with extra: T, returned
as Shadow[str | UnsetType], fills tenant with a list and may
decline extra
(test_each_key_is_read_with_the_bindings_of_the_class_that_declared_it).
A key reached through two bases that bind it differently is filled if
either reading fills it.
A type variable nothing binds counts as filled, as in a bare
-> Scope (test_a_type_variable_nothing_binds_is_filled). Whether it
may be UNSET is written nowhere, and filled keeps the decision with the
provider by hiding the key from the caller. Declinable would let a
transport whose caller's value wins put that value in a key the provider
was meant to decide, such as a tenant.
Three readings stay as they are, each held by a test:
- A PEP 696 default on a type variable is not consulted
(
test_a_type_variables_default_is_not_consulted). - A base that cannot be substituted makes the provider read as untyped
(
test_a_base_that_cannot_be_substituted_leaves_the_provider_untyped). - A key a subclass redeclares is read with its base's bindings, since no
runtime record says which class wrote it
(
test_a_key_a_subclass_redeclares_is_read_with_its_bases_bindings).
Duck-typed on the keys a TypedDict class carries rather than
is_typeddict, because the standard library's answers False for a
typing_extensions.TypedDict on the older Pythons this package supports.
ProviderKeys¶
ProviderKeys ¶
Bases: NamedTuple
What
provider_keys
read off a kwargs= provider, before it runs.
Two disjoint sets of key names that together are every key of the
TypedDict the provider is annotated to return.
A NamedTuple so that it reads both ways its consumers read it. It still
unpacks as filled, declinable = provider_keys(spec.kwargs), which is how
the MCP server's call site reads it, and it answers .filled and
.declinable, which is what the Pydantic-AI toolset's private dataclass
offered before this replaced it. Both are held by
test_the_answer_names_its_two_sets_and_still_unpacks.
Attributes:
| Name | Type | Description |
|---|---|---|
filled |
frozenset[str]
|
The keys the provider always answers for, |
declinable |
frozenset[str]
|
The keys whose value admits |
server_owned_keys¶
The names no caller supplies anywhere in a spec's dispatch: every key the
selector or service, one of spec.preconditions or the target lookup marks
NotClientInput, and view. Dispatch drops the
caller's value for each before any callable reads it, REJECT refuses it on a
closed spec, and spec_to_json_schema leaves it out of the input schema.
The set governs the keyword pool, not an input serializer's fields. A field the
input_serializer declares under an owned name stays client input: the schema
lists it, REJECT admits it, and the caller's value is validated into data,
the one place it reaches the service, since dispatch keeps it out of the spread.
A same-named key can mean something else there, such as a destination tenant in
data beside the server's current tenant at the gate, so the declaration is not
refused.
A transport that builds an input schema of its own subtracts the same set, less the input serializer's field names, rather than reading each callable's markers again, which would miss a key that one callable hides and another takes plainly:
from rest_framework_services import server_owned_keys
# A service tool merging its target lookup's keys into what it built from the
# service's input serializer (empty when the spec has none).
properties = {**lookup_properties, **serializer_properties}
# A field the serializer declares stays the caller's, whatever its name.
owned = server_owned_keys(spec) - set(serializer_properties)
for name in owned: # the service spec, not its nested lookup
properties.pop(name, None)
Pass the spec the transport dispatches. Asked with the nested
instance_selector_spec / collection_selector_spec, it answers only what the
lookup and its own preconditions hide, and a key the service or one of its
preconditions hides is still advertised, then dropped.
server_owned_keys ¶
Every name no caller supplies anywhere in spec's dispatch.
The union of hidden_input_keys over each callable a dispatch of spec
feeds from the caller's input: the selector or service, each of
spec.preconditions, and a service's target lookup
(instance_selector_spec / collection_selector_spec). A key any one of
them marks NotClientInput is server-owned for the whole call, because
the callables share what the caller sent: a precondition reads the pool the
selector or service is spread into, and a lookup reads the same arguments a
spread service takes. Hiding a key on one callable while another delivered
the caller's value for it would let that value decide what the marker said
only the server decides, such as which tenant a gate checks, or which tenant a
setattr loop over an open **changes writes onto the row.
Dispatch never takes the caller's value for one: the compiled spec reads no
NotClientInput name, so the kernel's dispatch drops it under IGNORE
and refuses it under REJECT. This set is the same answer for a transport
that reads the spec without dispatching it: it is not declared input
(declared_input_keys), and spec_to_json_schema does not list it.
The set governs the keyword pool, not an input serializer's fields. A
field the input_serializer declares under an owned name stays client input:
the schema lists it, REJECT admits it, and the caller's value is validated
into data, the one place it reaches the service: the Validator hands the
key on by name only where the server put the field's value
(SerializerValidator's server_owned). The two may mean
different things, such as a destination tenant in data beside the server's
current tenant at the gate, so the declaration is not refused.
A provider, a registered pool seed, input_data and the parameter's default
still fill it, and so does a route capture for a selector, its preconditions
or a service's target lookup, the pools a route capture reaches.
Public, for a transport building an input schema of its own, such as a
service tool that merges its target lookup's keys into the service's: subtract
this set, less the input serializer's field names, from what it lists, so it
advertises no key dispatch then drops and keeps every field the serializer
validates, and pass the spec it dispatches, the service's rather than the
nested lookup's, or a key the service or one of its preconditions hides is
missed. spec_to_json_schema already does both.
Each callable is one part of the union, and
test_a_service_owns_what_its_service_preconditions_and_lookup_hide names a
key from each, so it fails without any one of them.
is_async¶
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.
Service side¶
run_service¶
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¶
arun_service
async
¶
Await fn(**kwargs), optionally inside transaction.atomic().
build_input_serializer¶
build_input_serializer ¶
build_input_serializer(
request: Request,
input_serializer: type | None,
*,
partial: bool = False,
extra_data: Mapping[str, Any] | None = None,
context: dict[str, Any] | None = None,
instance: Any = None,
many: bool = False,
) -> Serializer | None
Construct + validate the bound input serializer; None if absent.
The serializer is returned validated (is_valid(raise_exception=True) has
run) but never saved; the service owns persistence.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request
|
Request
|
The request whose |
required |
input_serializer
|
type | None
|
A bare dataclass type (wrapped in a
|
required |
partial
|
bool
|
Validate partially, as DRF's |
False
|
extra_data
|
Mapping[str, Any] | None
|
Merged on top of |
None
|
context
|
dict[str, Any] | None
|
Forwarded to the serializer's |
None
|
instance
|
Any
|
The resolved mutation target on update / destroy flows. The
serializer is constructed DRF-style, so |
None
|
many
|
bool
|
Validate |
False
|
build_input_serializer_from_data¶
build_input_serializer_from_data ¶
build_input_serializer_from_data(
data: Any,
input_serializer: type | None,
*,
partial: bool = False,
context: dict[str, Any] | None = None,
instance: Any = None,
many: bool = False,
) -> Serializer | None
Construct + validate the bound input serializer from a raw data dict.
The transport-neutral core of
build_input_serializer:
it takes the input data directly instead of reaching into a DRF
request.data, so a non-HTTP caller (dispatch_spec) and the HTTP view path
share one validation implementation. See
build_input_serializer
for the remaining parameter semantics.
validate_input¶
validate_input ¶
validate_input(
request: Request,
input_serializer: type | None,
*,
partial: bool = False,
extra_data: Mapping[str, Any] | None = None,
context: dict[str, Any] | None = None,
instance: Any = None,
) -> Any
Validate request.data against input_serializer; None if absent.
Thin wrapper over
build_input_serializer
(see there for the parameter semantics) returning only validated_data, for
callers that don't need the bound serializer itself.
resolve_mutation_instance¶
resolve_mutation_instance ¶
Resolve the mutation target, or defer to the core.
``None`` for a **bulk** spec (``many=True`` or a
| Name | Type | Description |
|---|---|---|
Any
|
|
|
Any
|
|
|
Any
|
spec carries an |
|
Any
|
right kwarg pool, error label, and reserved-seed strip. Object permissions still |
|
run |
Any
|
the core fires |
Any
|
HTTP caller passes |
|
Any
|
|
|
Any
|
||
Any
|
else DRF's |
|
Any
|
one branch that is genuinely HTTP-only and so cannot move. |
filter_backends do not apply to the two spec-driven branches. DRF
runs filter_queryset() inside its own get_object(), so an
instance_selector_spec (resolved by the core) and a retrieve selector
(which overrides get_object()) both bypass it, exactly as a hand-written
get_object() override does. A tenant-scoping backend in
DEFAULT_FILTER_BACKENDS therefore does not narrow the row a PATCH or
DELETE may reach here, while the sibling list action stays scoped. Put the
scoping in the selector's own queryset. Only the last branch — DRF's own
get_object() — applies the backends.
Selector side¶
run_selector¶
run_selector ¶
Call a selector from sync code, transparently bridging an async one.
A selector is the one callable a SelectorSpec may make async def;
every other concept in the spec is sync-only and runs in the executor. The
bridge keeps sync code from ever seeing an un-awaited coroutine.
kwargs is what the selector is called with, already bound: resolve it
from a pool first, as dispatch does.
arun_selector¶
arun_selector
async
¶
arun_selector(
fn: Callable[..., Any] | Callable[..., Awaitable[Any]], kwargs: Mapping[str, Any]
) -> Any
Call a selector from async code: an async def one is awaited, a sync one called inline.
Inline rather than in the executor, because a selector returns a queryset nobody has evaluated yet and building one runs no query. A sync selector that does query when called has to be run in the executor by its caller, as dispatch runs everything but the selector.
is_queryset¶
is_queryset ¶
True for Django QuerySet objects and Manager instances.
The one definition of a queryset-shaping target: what the shaping fields
may be applied to, what a RETRIEVE selector materializes from, and what
paginate_output pages by counting. Tested by type rather than by
hasattr(…, "first"), which would also match a domain object that
happens to expose first. QuerySet subclasses all pass, and every
BaseManager, so one built with BaseManager.from_queryset does too.
materialize_retrieve¶
materialize_retrieve ¶
Collapse a RETRIEVE selector's return to the single instance, or None.
The one definition of what kind=RETRIEVE means once the selector has
run: a queryset materializes through .first() - so an author can write
selector=lambda *, pk: Model.objects.filter(pk=pk) and still get the
spec's shaping applied first - and anything else passes through as the
resolved object. What each caller does with a None differs; how the
value is arrived at does not.
Exported for a transport that runs a RETRIEVE selector without
dispatch, so the row it checks or presents is the row dispatch would
have resolved. Blocks on the query, so call amaterialize_retrieve
from a coroutine.
amaterialize_retrieve¶
amaterialize_retrieve
async
¶
Async twin of materialize_retrieve, awaiting .afirst().
Separate because the materialization is the query: .first() would
block the event loop, and raises SynchronousOnlyOperation there. The
same rule otherwise; keep the two in step.
apply_queryset_shaping¶
drfs' signature over the kernel's shape_queryset, for a caller holding a view
and a request rather than a spec. Two things differ from the releases before
the kernel:
filter_setmust be a django-filterFilterSet. One duck-typed on(data, queryset) -> .qsis refused withImproperlyConfigured. Each value is coerced to the type its filter declares, and a repeated query-string key reaches a filter taking several values as a list.- Invalid filter data raises the kernel's
InvalidArguments(django_service_specs.parameters.invalid_arguments), keyed as theFilterSetkeys its errors, and nothing here translates it. It is not a DRF exception, so a caller inside a DRF view that wants a 400 translates it itself, withtranslate_dispatch_errors.dispatch_spec, and every view that dispatches through it, shapes inside the kernel'sdispatchand translates for you, answering 400.
extend_queryset is still called as extend_queryset(qs, view, request), by
position; one answering with an awaitable is refused.
apply_queryset_shaping ¶
apply_queryset_shaping(
qs: Any,
view: Any,
request: Request,
*,
select_related: Any,
prefetch_related: Any,
annotations: Any,
extend_queryset: Any,
filter_set: Any = None,
filter_data: Any = None,
source_label: str,
) -> Any
Apply the five shaping fields to qs, through the kernel's shape_queryset.
drfs' signature over django-service-specs' shaping, for a caller holding a
view and a request rather than a spec: the fields become a kernel
SelectorSpec (with a stand-in selector it never runs), and view,
request and the filter's arguments its pool. The order is the kernel's:
the declarative fields first (in declaration order), then
extend_queryset, so the user callable always sees the fully
statically-shaped queryset, and finally filter_set, so filtering
composes with shaping and runs before the retrieve .first()
materialization the caller does next.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
extend_queryset
|
Any
|
Called as |
required |
filter_set
|
Any
|
A django-filter |
None
|
filter_data
|
Any
|
The mapping the filter's arguments are read from, each of
its filters' names and nothing else. |
None
|
source_label
|
str
|
Named in the misconfiguration error to point at the
offending callable ( |
required |
Returns:
| Type | Description |
|---|---|
Any
|
The shaped queryset, or |
Raises:
| Type | Description |
|---|---|
ImproperlyConfigured
|
Shaping is configured but |
InvalidArguments
|
The kernel's, where |
Pool seeds¶
Every kwargs pool the kernel builds for a dispatched spec starts from
base_pool, whether dispatch_spec was called by an HTTP view or by a
transport off HTTP, so a spec callable that declares a seed behaves the same
over either. request is not one of its parameters: dispatch_spec registers
request and view as seeds of each call, so a project registering either in
pool_seeds is refused with ImproperlyConfigured. A falsy progress reporter
is kept rather than swapped for null_progress.
A parameter named like a seed the call registers is the seed's, on a selector,
a target lookup or a spread service as on a service: the seed fills it, and a
caller's value of the name is dropped rather than reaching it. The compile
marks every read it takes off a signature reflected, and the kernel fills a
reflected read from a registered seed on any dispatch, so the kernel's own
dispatch over kernel_spec(spec), handed the same seeds, answers as
dispatch_spec does. A name declared in the spec's reads is not reflected,
and is refused by both when it meets a seed.
base_pool is a builder, not a gate, and nothing checks a pool assembled
elsewhere. If you are writing a transport adapter, build your pool by calling
it — base_pool(user=…, **your_own_entries) — rather than writing a
dict literal of your own:
- A literal that omits a seed carries no trace of it.
resolve_callable_kwargsforwards only the keys the pool has, so a callable declaring the missing seed raises aTypeErrorat call time: the same spec works when your adapter dispatches it one way and fails another.progressis the usual casualty — it is the seed with a default, so a callable can declare it in the documentedprogress: ProgressReporterform and never see that anything is missing until a pool without it comes along. - Spreading your own entries through
**extramakes a name collision loud. An entry calleduserraisesTypeErrorat thebase_poolcall; in a dict literal the same entry silently outranks the value your transport authenticated.
base_pool ¶
base_pool(
*,
user: Any,
progress: ProgressReporter | None = None,
seeds: PoolSeeds = DEFAULT_POOL_SEEDS,
**extra: Any,
) -> dict[str, Any]
The seeds a dispatched callable's kwargs pool carries, on every transport.
Every pool dispatch and adispatch build routes through here, so a
spec callable that declares a seed behaves the same whichever entry point
resolved it.
That is a property of the pools built here, not a rule the kernel can
enforce on its callers: this is a builder, not a gate. A transport adapter
that assembles a pool as a dict literal of its own carries exactly the keys
it wrote there, because resolve_callable_kwargs forwards only keys the
pool actually has. A callable declaring a seed the literal omitted then
raises TypeError at call time rather than running - progress most
often, since it is the seed with a default and so the one nobody remembers.
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.
A seed resolving to an awaitable is refused with ImproperlyConfigured
and never enters the pool. PoolSeeds refuses an async def
resolver when it is registered, but a plain def handing back a
coroutine - a decorated async def, a partial over one - looks sync
until it answers. Nothing awaits a pool entry, and a coroutine is truthy,
so a condition reading it would read an unmet condition as met.
progress is the caller's reporter, and defaults to null_progress
rather than to None, so a declared reporter is always callable - see
ProgressReporter. Only None is replaced: a reporter that happens to
be falsy, such as a list-backed recorder that is empty until its first
report, is seeded as it is, where djangorestframework-services' or
would swap it for null_progress and drop every report. A seed's
resolver sees it like any other entry.
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.
null_progress ¶
null_progress(
progress: float,
*,
total: float | None = None,
message: str | None = None,
meta: Mapping[str, Any] | None = None,
) -> None
The
ProgressReporter
a transport with nowhere to send progress uses.
Seeded by base_pool
whenever the caller supplied no reporter, which is every dispatch from a
transport with no progress channel of its own, every HTTP request, and every
test.
The default is what makes the seed usable. Without it, a service
declaring progress would work over one transport and raise a
TypeError over the others, so nobody could declare it in code meant to
be shared - which is the entire premise of writing a service once.
Discarding the report is the honest behaviour for a caller that cannot
forward it; refusing the call is not.