Skip to content

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 ServiceSpec or SelectorSpec to run.

required
user Any

The acting user, the kernel's principal. None acts as DRF's unauthenticated user (UNAUTHENTICATED_USER), which is what every callable reading user is handed; the guard still sees None.

required
params Mapping[str, Any] | list[Any]

The caller's arguments, one flat mapping. A QueryDict is read as a query string is: a repeated key as a list where the parameter is an array, and each value coerced to the type declared. A list on a many spec, unless many_as_argument.

required
request Any

Registered as the call's request seed, so a provider, a selector, extend_queryset and the serializer context see it.

None
view Any

Registered as the view seed. Its kwargs are the route: a declared UrlKwarg, or a name the selector or target lookup reads, is taken from them when there, over params; a declared UrlKwarg sent in params that the view lacks is put onto them, and a declared QueryParam onto request.query_params, so a provider reading either finds it. That is for a view with no route of its own: on a live DRF view a declared UrlKwarg is the route's alone and a declared QueryParam the query string's alone, a params value of either name being dropped. A capture of a name the selector, or a service spec's target lookup, marks NotClientInput fills it: the lookup's is seeded for the call, beneath a seed of that name and the lookup's own provider, and an author's value for the name is seeded in its place. A route capture nothing declares, reads or hides is no argument.

None
pool_seeds PoolSeeds

The project's seeds. Registering request or view is refused, since the edge seeds both; a resolver declaring either is handed this call's.

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 input_serializer takes the caller's values for its own parameters: only under SPREAD_AUTHOR_WINS. AUTO is BUNDLE for a service, as it always was, so a caller's value reaches it only through what reads it - the Validator, the target lookup and the declared reads - and a request body never fills def destroy(*, instance, hard=False)'s hard. Under every binding the Validator's values reach a service by name beside data, save one named like a key a callable of the call marks NotClientInput: the client's value for that is in data alone, and the parameter gets a view hook's, the spec's kwargs provider's or input_data's value for it, or its default. A selector spec's parameters are its caller's under every binding. SPREAD_CALLER_WINS is refused with ValueError.

AUTO
unknown_arguments UnknownArguments

IGNORE (the default here; the kernel's is REJECT) or REJECT. PASSTHROUGH is refused with ValueError. REJECT names every undeclared key in one "Unexpected argument(s): ..." message under non_field_errors, a row's inside that row. A seed's name - a built-in one, request or one pool_seeds registers - is never one, and the caller's value for it is dropped; a caller's view is refused. Where a target lookup will query, an undeclared key is refused before it does, alone. 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.

IGNORE
on_target_resolved TargetGuard | None

The guard, called guard(spec, context, instance=target) with an OfflineContext of the user, request and view passed here. A service spec's target is the row the caller handed as instance, else the row or set its lookup found (a missing row is not_found and asks nothing), else None for a create; a many spec's is None. A selector spec's is the row or the set its selector found, after the lookup and before the spec's own preconditions, and never a missing row.

None
progress ProgressReporter | None

The transport's progress reporter, combined with view_hooks.progress.

None
view_hooks ViewHooks | None

The calling view's resolved hook chains: an extra_kwargs value for a name the caller fills (a read of the spec's callable or target lookup, or a field of a single call's input serializer, by its name or its source) is put into the arguments over the client's value and checked as it would be; a value naming a seed is dropped; the rest of a single service call's outranks every key the input serializer hands back, a source="*" field or one its validate() adds, while the client's value for it stays in data and the bound serializer stays bound; on every other call the rest goes to dispatch(extra_kwargs=). The spec's kwargs provider is called here, from view and request, and its answer is split the same way over the hooks', a key it names a seed refused; a key its TypedDict fills is never the caller's or a hook's. input_data is merged over the arguments, beneath the spec's input_data; progress joins progress.

None
instance Any

The row the caller resolved itself (a view's get_object()), handed to dispatch as its target; no lookup runs. None is a create on a spec that declares no target lookup; on one that declares a lookup it is not_found, since that lookup admits no missing row.

UNSET
filter_data Mapping[str, Any] | None

The values the filter_set reads - the selector spec's own, or a service spec's target lookup's - restricted to its filter names, and the filter's only source for them when passed. A service spec's output_selector_spec.filter_set is bound to it whole, as passed, after the service has run, or to an empty mapping without it or under output_filter_from_call=False.

None
many_as_argument bool

Whether a many spec's rows arrive under spec.many_argument in an object, rather than as params itself. Rows arriving bare are refused in the shape DRF's list serializer uses; under the argument, as the kernel keys them. A no-op on every other spec.

False
partial bool | None

The request's partial flag (a PATCH); None reads the spec's own.

None
call_context dict[str, Any] | None

The call's 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 naming call_context - the spec's kwargs provider and input_data, which this edge calls itself, a target lookup, and every stage of the kernel's dispatch. None is a fresh dict for this call alone. The edge sets request and view to the ones passed here, format to None, and action, the operation's name, to view.action where view has a non-empty one and to None otherwise, each only where the dict has no entry of that name, so a caller's own entries win. A view passes none, so a context per dispatch is a context per request.

None

Returns:

Type Description
DispatchResult

The kernel's DispatchResult. A required row that does not exist is

DispatchResult

kind="not_found", for the transport to answer. A service spec whose

DispatchResult

output_selector_spec is a LIST with no selector answers

DispatchResult

kind="list", its value the service's own return, presented per row.

Raises:

Type Description
ImproperlyConfigured

under that LIST declaration, the service returned something that is not a set of rows (a mapping, a str or bytes, a non-iterable, None); its write stands.

TypeError

spec is neither a ServiceSpec nor a SelectorSpec.

ValueError

a retired argument_binding or unknown_arguments.

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.data iterates the value. For many=True that evaluates the queryset; per row, every relation a field traverses is another query unless it was select_related in.
  • The spec's output_serializer_context provider 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

rows is neither a queryset nor a sized, sliceable sequence, so there is nothing to count and nothing to slice.

DEFAULT_PAGE_SIZE

DEFAULT_PAGE_SIZE module-attribute

DEFAULT_PAGE_SIZE: Final[int] = 100

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

total_pages: int

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.

has_next property

has_next: bool

Whether asking for page + 1 would return anything.

envelope

envelope(rendered: Any) -> dict[str, Any]

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

base_serializer_context(*, view: Any, request: Any) -> dict[str, Any]

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 / request the caller passed: the synthetic pair from build_offline_context off HTTP, or None when the caller supplied neither. format is always None — 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

renderable_serializer_class(declared: None) -> None
renderable_serializer_class(declared: type) -> type[BaseSerializer[Any]]
renderable_serializer_class(declared: type | None) -> type[BaseSerializer[Any]] | None

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']

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

value Any

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

service_result Any

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

instance Any

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

data Any

The Validator's data the service received: the validated mapping, or the instance a dataclass Validator builds.

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 shapes, which present hands to Presenter.presented(). None on a result dispatch did not build, and on a not-found, which is never presented. It takes no part in equality: two results of one value are one result.

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, is BUNDLE for a service and SPREAD_AUTHOR_WINS for a selector. A selector's declared reads are the caller's under every member.
  • Under BUNDLE a selector's own parameters are not the caller's either: a route capture, an author's value, a seed and its declared reads fill them, its filter_set still reads the caller's values, and a parameter nothing fills is the callable's own TypeError rather than a 400 no client could answer. A selector view dispatches BUNDLE.
  • Under BUNDLE a serializer-less service's own parameters are never the caller's: it receives data={}, its declared reads and the target lookup's row, and nothing else the client sends.
  • Under SPREAD_AUTHOR_WINS its signature is the caller's input, still below an author's value on a conflict.
  • SPREAD_CALLER_WINS is refused with ValueError naming AUTO: leave a key out of the kwargs provider 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: ServiceSpec → BUNDLE, SelectorSpec → SPREAD_AUTHOR_WINS, so a selector takes the caller's value for each parameter its signature names.

BUNDLE

A service's own parameters are never the caller's: the validated payload reaches it as data=, and each validated value by its key as well (one a callable of the call marks NotClientInput only inside data), beside its declared reads by name; nothing else the client sends reaches it. A service with no input_serializer takes data={}. The safe choice for a mutation whose kwargs scopes the write. A selector takes none of the caller's values for its parameters either: only a route capture, an author's value, a seed and its declared reads fill them, the filter still reads the caller's values, and a parameter nothing fills is the callable's own TypeError. A selector view dispatches this member.

SPREAD_AUTHOR_WINS

A service with no input_serializer declares its own parameters as the caller's input, and the author's values still replace the caller's on a conflict, so an author-scoped tenant_id cannot be reset by the client. A service with an input serializer keeps the serializer as its input.

SPREAD_CALLER_WINS

Retired. dispatch_spec refuses it with ValueError: one pool has no place for a caller's value to beat the author's. The member stays importable.

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

REJECT = 'reject'

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

IGNORE class-attribute instance-attribute

IGNORE = 'ignore'

Drop the undeclared key and carry on.

PASSTHROUGH class-attribute instance-attribute

PASSTHROUGH = 'passthrough'

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 spec.kwargs. The same carrier serves the selector chain (get_selector_kwargs / get_<action>_selector_kwargs) — only the view-method names differ, not the layering.

input_data Mapping[str, Any] | None

Merges onto the client payload before validation, beneath spec.input_data; server-provided keys win over the client's.

input_serializer_context Mapping[str, Any] | None

Layers onto the baseline serializer context (base_serializer_context), beneath spec.input_serializer_context.

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

Lazy — a callable taking the final post-selector result and returning the context — because the output context provider is documented to see the exact instance being serialized, which does not exist until after the service and output selector have run.

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 request.user, so callables and permissions that read it behave as on HTTP.

required
params Mapping[str, Any] | list[Any] | None

Seeds request.data, straight into DRF's parsed-data cache (a synthetic request has no stream to parse). It is not validated here — that is dispatch_spec's job, which takes params directly and never touches request.data. Pass both the same value.

None
http_request HttpRequest | None

An ambient Django request to wrap, so its headers / META are available (the MCP server passes its real one); otherwise an OfflineHttpRequest is created. Either way the method is forced to POST, because mutation callables often branch on it. The caller keeps ownership: a request passed here is never written to. What gets wrapped is a shallow copy, so the method / GET / user this function sets land on the copy alone, while META, the session, the upload handlers and any body already read stay shared — headers and session writes behave as they do on HTTP, and the live request keeps its own method, query string and user for the rest of its cycle. Pass the request you are serving; dispatching several specs from one request is safe, and neither leaks into the next.

None
action str | None

The view action name, exposed on the OfflineServiceView.

None
kwargs Mapping[str, Any] | None

The channel for route-derived values — the off-HTTP counterpart of a view's URL captures. dispatch_spec spreads it into the selector / target pools exactly where the HTTP path spreads extra_url_kwargs=view.kwargs: authoritative over params on a key conflict, below a spec.kwargs provider, which also sees it as view.kwargs. Pass every route capture a spec depends on.

None
query_params Mapping[str, Any] | None

Seeds the request's GET QueryDict, the source request.query_params reads — how read-shaping params that are not spec inputs reach the serializer offline: a serializer branching on query_params, and a request-scoped FilterSet reading self.request.query_params itself. It is not what a filter_set is bound to: dispatch_spec binds a SelectorSpec's own, and a target lookup's, to filter_data, else to params, and a service's output_selector_spec.filter_set to filter_data, else to an empty mapping (always to an empty mapping under output_filter_from_call=False). To filter from this query string, pass filter_data=context.request.query_params. Values are stringified as on HTTP; a list / tuple becomes a multi-valued param. Replaces a wrapped http_request's GET — on the copy that is wrapped, so the caller's own GET still reads its real query string afterwards.

None
host str | None

The origin the synthesized request reports, so build_absolute_uri — which DRF's FileField / HyperlinkedIdentityField call whenever a request is in the serializer context — returns real absolute URLs off HTTP. Accepts "example.com", "example.com:8000", or a full origin whose scheme decides whether links are https. There is no default: unset, absolute-URI building degrades to the relative URL rather than raising (see OfflineHttpRequest). Ignored when http_request is supplied, whose own headers are authoritative, so a caller can pass both unconditionally.

None

The

Type Description
OfflineContext
OfflineContext

to hand to dispatch_spec.

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_uri returns 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 first ALLOWED_HOSTS entry, 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

offline_host: str | None = None

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

build_absolute_uri(location: str | None = None) -> str

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

get_host() -> str

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

operation_affordances(
    spec: ServiceSpec | SelectorSpec | SpecSource,
) -> tuple[Affordance, ...]

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

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

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

provider_keys(provider: Callable[..., Any] | None) -> ProviderKeys | None

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, NotRequired ones included, since the provider owns them. A transport need not ask its caller for any of them, and dispatch drops a value the caller sends for one once it has been checked, so the provider's answer is the only one: a key it then leaves out, or returns as UNSET, is absent rather than the caller's.

declinable frozenset[str]

The keys whose value admits UnsetType (tenant: str | UnsetType). The provider may return UNSET for one, which dispatch removes from the pool, so the caller's value is the one the callable receives. Such a key stays the caller's to send: a transport keeps offering it, and dispatch lets the caller leave it out and checks a required one once the provider has answered, refusing it when nobody filled it. A filled key is relaxed where the provider's values reach it - a selector's kwargs that selector's reads, a service spec's kwargs the spec's reads, and input_data the Validator's parameters, at the top level or, on a many spec, inside each row - and a ServiceSpec whose provider fills a parameter declared anywhere else is refused.

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

server_owned_keys(
    spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
) -> frozenset[str]

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

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

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

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

Service side

run_service

run_service

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

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

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

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

arun_service

arun_service async

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

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 data is validated.

required
input_serializer type | None

A bare dataclass type (wrapped in a DataclassSerializer on the fly), a DataclassSerializer subclass, or any other Serializer subclass such as ModelSerializer. The first two produce a dataclass instance as validated_data, the third a dict.

required
partial bool

Validate partially, as DRF's serializer(partial=…).

False
extra_data Mapping[str, Any] | None

Merged on top of request.data before the serializer is constructed, server-provided keys winning on overlap — the seam the input_data resolver chain uses to lift URL kwargs into serializer input. The merge goes through apply_input_data, which keeps a form-encoded / multipart QueryDict's scalars from flattening into one-element lists.

None
context dict[str, Any] | None

Forwarded to the serializer's context= so DRF-style self.context["request"] / ["view"] lookups work inside validators and fields.

None
instance Any

The resolved mutation target on update / destroy flows. The serializer is constructed DRF-style, so self.instance is populated inside validate() / field validators and instance-aware validators (UniqueValidator excluding the current row) behave as under DRF's own update flow.

None
many bool

Validate data as a list — the bulk list-payload path; validated_data is then a list of items.

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_mutation_instance(view: Any, spec: ServiceSpec[Any, Any, Any]) -> Any

Resolve the mutation target, or defer to the core.

``None`` for a **bulk** spec (``many=True`` or a

Name Type Description
Any

collection_selector_spec): there is no single instance, and the

Any

get_object() lookup would 404 a body-only bulk endpoint. UNSET when the

Any

spec carries an instance_selector_spec — the core resolves it, with the

Any

right kwarg pool, error label, and reserved-seed strip. Object permissions still

run Any

the core fires on_target_resolved against the resolved target and the

Any

HTTP caller passes check_view_object_permissions. Otherwise the view's

Any

get_object() chain (an action_specs["retrieve"] selector via

Any
Any

else DRF's queryset / lookup_field lookup, else a user override) — the

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

run_selector(fn: Callable[..., Any], kwargs: Mapping[str, Any]) -> Any

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

is_queryset(obj: Any) -> bool

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

materialize_retrieve(result: Any) -> Any

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

amaterialize_retrieve(result: Any) -> Any

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_set must be a django-filter FilterSet. One duck-typed on (data, queryset) -> .qs is refused with ImproperlyConfigured. 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 the FilterSet keys 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, with translate_dispatch_errors. dispatch_spec, and every view that dispatches through it, shapes inside the kernel's dispatch and 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 extend_queryset(qs, view, request), by position, as it always was; the kernel binds its own by name.

required
filter_set Any

A django-filter FilterSet, validated before .qs is read, and handed request through its constructor, so a request-scoped one sees the self.request it would behind DjangoFilterBackend.

None
filter_data Any

The mapping the filter's arguments are read from, each of its filters' names and nothing else. None falls back to request.query_params, the HTTP view path. A QueryDict is read flat as dispatch reads a query string: a repeated key as a list where the filter takes several values, and each value coerced to the type the filter declares.

None
source_label str

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

required

Returns:

Type Description
Any

The shaped queryset, or qs unchanged when nothing is configured.

Raises:

Type Description
ImproperlyConfigured

Shaping is configured but qs is not a Django queryset; filter_set is not a django-filter FilterSet (one duck-typed on (data, queryset) -> .qs is refused); or extend_queryset answered with an awaitable.

InvalidArguments

The kernel's, where filter_set refused its data or a value no integer column can hold, keyed as the FilterSet keys its errors. It reaches the caller as the kernel raised it, not as DRF's ValidationError: nothing here translates it, so a caller inside a DRF view that wants a 400 translates it itself.

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_kwargs forwards only the keys the pool has, so a callable declaring the missing seed raises a TypeError at call time: the same spec works when your adapter dispatches it one way and fails another. progress is the usual casualty — it is the seed with a default, so a callable can declare it in the documented progress: ProgressReporter form and never see that anything is missing until a pool without it comes along.
  • Spreading your own entries through **extra makes a name collision loud. An entry called user raises TypeError at the base_pool call; 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.