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.
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.
dispatch_spec¶
dispatch_spec ¶
dispatch_spec(
spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
*,
user: Any,
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,
) -> DispatchResult
Execute spec without a DRF view, returning a
DispatchResult.
The single transport-neutral execution path: a caller hands the flat params
mapping (the role request.data / query_params / URL kwargs play on HTTP)
plus the acting user, and gets back the resolved domain value to format for its
wire. No pagination, ordering, or output rendering happens here — those are
transport concerns; render the result with
render_spec_output.
- A
ServiceSpecruns the mutation flow: resolve the target viainstance_selector_spec(fromparams) → validateinput_serializer→ run the service → re-fetch throughoutput_selector_spec→ result. A missing instance yieldskind="not_found". - A
SelectorSpecruns the read flow: invoke the selector → apply queryset shaping (select_related…filter_set) → forRETRIEVEmaterialize via.first()and honourallow_none/ not-found;LISTreturns the shaped + filtered queryset.
Every argument below the acting user is optional, and the defaults reproduce the pre-policy behaviour exactly.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
spec
|
ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any]
|
The
|
required |
user
|
Any
|
The acting user, seeded into every callable's pool. |
required |
params
|
Mapping[str, Any] | list[Any]
|
The flat client input — a list on a |
required |
request
|
Any
|
Forwarded only to user callables that declare it ( |
None
|
view
|
Any
|
As |
None
|
success_status
|
int | None
|
Overrides the mutation status hint (else
|
None
|
argument_binding
|
ArgumentBinding
|
Whether client input lands as a single |
AUTO
|
unknown_arguments
|
UnknownArguments
|
Strictness about |
IGNORE
|
on_target_resolved
|
TargetGuard | None
|
Hook invoked with the resolved mutation target before the
service runs. Pass
|
None
|
progress
|
ProgressReporter | None
|
The transport's own progress sink, fanned together with the one
|
None
|
view_hooks
|
ViewHooks | None
|
The calling DRF view's resolved hook-chain layers. HTTP-only. |
None
|
instance
|
Any
|
A target the caller resolved itself, skipping
|
UNSET
|
filter_data
|
Mapping[str, Any] | None
|
The data the |
None
|
The
| Type | Description |
|---|---|
DispatchResult
|
|
DispatchResult
|
— value, |
DispatchResult
|
resolved instance and data. |
Raises:
| Type | Description |
|---|---|
TypeError
|
|
adispatch_spec¶
adispatch_spec
async
¶
adispatch_spec(
spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
*,
user: Any,
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,
) -> DispatchResult
Async
dispatch_spec.
Identical contract, arguments, policies and
DispatchResult
shape — see the sync twin. What differs is only the execution model: async selectors
and services are awaited, and sync ones run in Django's thread-sensitive executor so
the ORM stays safe off the event loop.
That rule covers every callable a spec carries, not just the selector /
service: kwargs providers, extend_queryset, filter_set,
input_serializer_context, a callable success_status,
preconditions, and the on_target_resolved guard all run in the
executor (see
arun_off_loop). None of them
can be async def — a spec is written once for both transports — so any
that queries would otherwise raise SynchronousOnlyOperation here and
nowhere else.
A LIST result comes back as the lazy shaped queryset, for the async
transport to materialize / paginate in a thread.
render_spec_output¶
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,
) -> 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); when none is set the
value passes through (list-coerced when many=True so a queryset evaluates).
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.
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.
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,
) -> 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,
) -> 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.
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,
) -> 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.
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 an agent transport serves.
page / 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
unpaginated result would be: totalPages / 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 the 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.
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 rejects outright with a
DatabaseError this does not catch.
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
DEFAULT_PAGE_SIZE¶
OutputPage¶
OutputPage
dataclass
¶
One page of rows, plus what a caller needs to know to ask for the next.
The envelope this describes is already the one
output_to_json_schema
publishes for kind=LIST, paginate=True. That schema was here before
anything in this package produced the payload it describes: the shaper lived
in a transport, so one agent transport wrapped its pages and the other
returned a bare list, against one schema that claimed the envelope for both.
Two implementations of one mechanism drift; one of them was missing
entirely.
items is the slice itself, unrendered — rendering needs a view, a
request and a spec, all of which belong to the caller. Hand the rendered
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-rendered rows in the published pagination envelope.
rendered rather than items because the projection lands on the
rows and never on the envelope: page / totalPages / hasNext
are this shape's own keys and belong to no serializer, 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.
DispatchResult¶
DispatchResult
dataclass
¶
What
dispatch_spec
resolved, pre-wire.
A transport-neutral carrier the caller formats for its own wire (an HTTP
Response, an MCP ToolResult, …). It holds the raw resolved domain value
— never a paginated page or rendered serializer output — because ordering,
pagination, and the response envelope are transport concerns. Render the value
through
render_spec_output.
instance and data are informational: a transport that only renders
reads value / kind / status and can ignore them.
Attributes:
| Name | Type | Description |
|---|---|---|
value |
Any
|
The resolved value — a single instance ( |
kind |
str
|
One of |
status |
int
|
An HTTP-ish status hint the transport may map to its wire: the
spec's success status for mutations, |
service_result |
Any
|
The service's own return value, captured before an
|
instance |
Any
|
The resolved mutation target — the row an update / destroy
acted on, or |
data |
Any
|
The validated input ( |
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 its flat input becomes callable arguments, how strict to be about
undeclared keys, and how to authorize a resolved target. The defaults reproduce
the pre-policy behaviour exactly, so a caller that passes none is unaffected.
ArgumentBinding¶
How the flat params map onto a dispatched callable's keyword arguments —
bundled as one data payload or spread as individual kwargs, and how the spread
ranks against the spec author's kwargs.
ArgumentBinding ¶
Bases: Enum
How dispatch_spec turns the flat params into a callable's kwargs.
Every dispatched callable's keyword pool always carries the request /
user seeds (and, for a mutation, data / serializer / instance
/ collection). This enum controls the one remaining question a caller
answers about its wire: can client-supplied input land as individual
keyword arguments, and do those override the spec author's kwargs(...)
invariants? It is a trust-boundary decision, not plumbing — which is why it
belongs to the caller rather than the spec. Pass the member directly; the
value never appears on a wire. Those reserved seeds are always stripped from
the spread in the SPREAD_* modes, so a client cannot poison
transport-controlled state by naming an argument after one of them.
Attributes:
| Name | Type | Description |
|---|---|---|
AUTO |
The default — resolve per spec type: |
|
BUNDLE |
Only the validated payload reaches the callable, as |
|
SPREAD_AUTHOR_WINS |
Client fields are spread into the pool as individual
kwargs, but |
|
SPREAD_CALLER_WINS |
Like |
UnknownArguments¶
How strict dispatch_spec is about params keys outside the spec's declared
set — drop them, reject them, or pass them through to the callable.
UnknownArguments ¶
Bases: Enum
How dispatch_spec treats params keys outside a spec's declared set.
The declared set is derived from the spec without any transport knowledge: a
ServiceSpec's
input_serializer fields plus the keys its nested target selectors consume; a
SelectorSpec's
selector parameters. When the set cannot be enumerated — a callable that
declares **kwargs, or a duck-typed filter_set whose fields are opaque to the
core — the spec is treated as open and this policy is a no-op (there is nothing
to call "unknown").
Members (internal knob — the value never appears on a wire):
IGNORE— undeclared keys are dropped (DRF serializers already do this to a mutation's payload, and a selector simply never receives kwargs it doesn't declare). The default, reproducing the pre-policy behaviour.REJECT— an undeclared key raisesValidationError, the same surface a strict serializer produces. Useful when the caller wants a clean correction signal (e.g. a model calling a tool with a mistyped argument).PASSTHROUGH— undeclared keys survive: they are merged onto the mutation'svalidated_databefore the keyword pool is built, so a callable that declares them (or**kwargs) receives them. The one policy that must live insidedispatch_spec— it needs the seam between validation and pool construction that a wrapper cannot reach.
Callers strip their own transport-only keys (pagination, ordering, output format) before calling, so the declared-set check sees only spec inputs.
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 (it already holds user / request / view) and calls guard(spec,
context, instance=target). 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.
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:
SelectorSpec.filter_set (when you don't hand filter_data in another way), and
any serializer that branches on request.query_params (django-restql field
selection, custom serializers). 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.
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.
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,
mirroring run_selector. 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. The
HTTP path always bridged (its own dispatch_service wrapper did this);
dispatch_spec reached this leaf directly, so the same spec resolved over
HTTP and returned a coroutine over MCP.
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 async ones.
arun_selector¶
arun_selector
async
¶
arun_selector(
fn: Callable[..., Any] | Callable[..., Awaitable[Any]], kwargs: dict[str, Any]
) -> Any
Call a selector from async code; sync ones run inline.
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, and what a RETRIEVE selector materializes from. Tested by type
rather than by hasattr(…, "first"), which would also match a domain
object that happens to expose first. QuerySet subclasses all pass.
apply_queryset_shaping¶
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.
The order is fixed: 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 |
|---|---|---|---|
request
|
Request
|
Passed to |
required |
filter_set
|
Any
|
Applied by duck typing as
|
None
|
filter_data
|
Any
|
The flat |
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 |
ValidationError
|
|
Pool seeds¶
Every kwargs pool this package builds for a dispatched spec starts from
base_pool — the HTTP view layer and off-HTTP dispatch_spec alike — so a spec
callable that declares a seed behaves the same over either.
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=…, request=…, **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 calleduserorrequestraisesTypeErrorat thebase_poolcall; in a dict literal the same entry silently outranks the value your transport authenticated.
base_pool ¶
base_pool(
*, user: Any, request: Any, progress: ProgressReporter | None = None, **extra: Any
) -> dict[str, Any]
The seeds a dispatched callable's kwargs pool carries, on every transport.
Every pool this package builds for a dispatched spec routes through here — the
HTTP view layer and off-HTTP
dispatch_spec
alike — so a spec callable that declares a seed behaves the same whichever of
them dispatched it.
That is a property of the pools built here, not a rule the framework 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.
An adapter that dispatches callables through this package must build its pool
from this function, with its own entries spread in —
base_pool(user=…, request=…, **own_entries) — rather than restating the
seeds. Routing those entries through **extra is also what makes a name
collision loud: an entry called user or request raises TypeError
here, where in a dict literal it would quietly outrank the value the transport
authenticated.
progress defaults to
null_progress
rather than to None, so a declared reporter is always callable — see
ProgressReporter.
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 HTTP request, every test,
and every off-HTTP dispatch from a transport that has no progress channel of its
own.
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.