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 packagerun_service / arun_service now live in services/ and is_async at the package root; downstreams re-point their imports here when they bump past 0.17.

Deliberately not part of this surface: dispatch_mutation_for_spec and dispatch_selector_for_spec. They are view-coupled orchestrators (they take a view, read its URL kwargs, and walk the get_<action>_*_kwargs hook chains). The transport-neutral spec dispatcher that composes the leaves below — dispatch_spec — is documented first.

Transport-neutral dispatch

The single execution path a non-HTTP transport drives: hand a spec, the acting user, and a flat params mapping, get back a DispatchResult to format for your wire. No view, no request required.

Each primitive comes as a sync / async pair — dispatch_spec / adispatch_spec, render_spec_output / arender_spec_output. The async member is not a thin alias: a spec's callables (selector, service, kwargs provider, extend_queryset, filter_set, serializer-context providers) are written once for both transports and so are never async def, and rendering walks the ORM. The async members run all of that in Django's thread-sensitive executor, which is what keeps SynchronousOnlyOperation out of an async transport. Await the pair together; don't mix an a-prefixed call with a sync one on the same result.

dispatch_spec

dispatch_spec

dispatch_spec(
    spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
    *,
    user: Any,
    params: Mapping[str, Any] | list[Any],
    request: Any = None,
    view: Any = None,
    success_status: int | None = None,
    argument_binding: ArgumentBinding = ArgumentBinding.AUTO,
    unknown_arguments: UnknownArguments = UnknownArguments.IGNORE,
    on_target_resolved: TargetGuard | None = None,
) -> DispatchResult

Execute spec without a DRF view, returning a :class:DispatchResult.

The single transport-neutral execution path: an HTTP view, the MCP server, or any other caller hands the flat params mapping (the role request.data / query_params / URL kwargs play on HTTP) plus the acting user, and gets back the resolved domain value to format for its wire. Composes the blessed dispatch leaves; no pagination, ordering, or output rendering happens here (those are transport concerns — render the result with :func:~rest_framework_services.render_spec_output).

  • A :class:ServiceSpec runs the mutation flow: resolve the target via instance_selector_spec (from params) → validate input_serializer → run the service → re-fetch through output_selector_spec → result. A missing instance yields kind="not_found".
  • A :class:SelectorSpec runs the read flow: invoke the selector → apply queryset shaping (select_relatedfilter_set, with params as the filter data) → for RETRIEVE materialize via .first() and honour allow_none / not-found; LIST returns the shaped + filtered queryset.

request / view are optional and only forwarded to user callables that declare them (extend_queryset, the context providers, kwargs); a pure non-HTTP caller passes neither. The view's kwargs (a route's captures, seeded by build_offline_context(kwargs=…)) are additionally spread into the selector / target pools — the off-HTTP counterpart of the HTTP extra_url_kwargs=view.kwargs, authoritative over params on a conflict, below the spec.kwargs provider — so a selector reading a URL kwarg from its extras works off-HTTP. success_status overrides the mutation status hint (else spec.success_status or 200).

Three caller-side policies tune how the wire maps onto the spec; the defaults reproduce the pre-policy behaviour exactly:

  • argument_binding (:class:ArgumentBinding) — whether client input lands as a single data bundle or is spread as individual kwargs, and how it ranks against the author's kwargs. AUTO resolves per spec type (service → bundle, selector → spread).
  • unknown_arguments (:class:UnknownArguments) — strictness about params keys outside the spec's declared set: IGNORE (drop), REJECT (raise), PASSTHROUGH (forward to the callable).
  • on_target_resolved (:class:TargetGuard) — a hook invoked with the resolved mutation target before the service runs. Pass :func:~rest_framework_services.enforce_permissions directly to enforce object-level permissions; dispatch_spec itself stays authz-agnostic.

On a many=True bulk spec unknown_arguments is honoured per list element (REJECT raises on the first item with an undeclared key, PASSTHROUGH folds each item's extras into its data, IGNORE drops them). argument_binding has no meaning there — the service receives the whole list as one data argument, so there is nothing to spread — and a non-default binding with many=True raises ValueError rather than being silently ignored.

adispatch_spec

adispatch_spec async

adispatch_spec(
    spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
    *,
    user: Any,
    params: Mapping[str, Any] | list[Any],
    request: Any = None,
    view: Any = None,
    success_status: int | None = None,
    argument_binding: ArgumentBinding = ArgumentBinding.AUTO,
    unknown_arguments: UnknownArguments = UnknownArguments.IGNORE,
    on_target_resolved: TargetGuard | None = None,
) -> DispatchResult

Async :func:~rest_framework_services.dispatch_spec.

Same contract, :class:DispatchResult shape, and argument_binding / unknown_arguments / on_target_resolved policies; async selectors / services are awaited and sync ones run in Django's thread-sensitive executor so the ORM stays safe off the event loop. A LIST result is returned as the (lazy) shaped queryset — the async transport materializes / paginates it in a thread, exactly as on the sync path.

That rule covers every callable a spec carries, not just the selector / service: kwargs providers, extend_queryset, filter_set, input_serializer_context, a callable success_status, and the on_target_resolved guard all run in the executor (see :func:~rest_framework_services.dispatch.utils.arun_off_loop). None of them can be async def — a spec is written once for both transports — so any that queries would otherwise raise SynchronousOnlyOperation here and nowhere else.

As on :func:~rest_framework_services.dispatch_spec, a many=True bulk spec honours unknown_arguments per list element and rejects a non-default argument_binding (there is no per-item kwarg to spread into a single list-payload service call).

render_spec_output

render_spec_output

render_spec_output(
    spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
    value: Any,
    *,
    many: bool = False,
    view: Any = None,
    request: Any = None,
    extras: Mapping[str, Any] | None = None,
) -> Any

Render value to a JSON-shaped payload using spec's output serializer.

The blessed render step that pairs with :func:~rest_framework_services.dispatch_spec: every transport renders the same way instead of re-implementing serializer + context plumbing. Reads the output serializer from spec.output_serializer (selector) or spec.output_selector_spec.output_serializer (service); when none is set the value passes through (list-coerced when many=True so a queryset evaluates).

The serializer context always carries DRF's baseline — request / format / view, from :func:~rest_framework_services.base_serializer_context — so a serializer that reads self.context["request"] renders identically here and behind a DRF view. The spec's output_serializer_context provider, if any, is resolved with view / request plus the resolved-data extras it declares (page for a list, instance for a retrieve, result for a mutation) and merged over that baseline.

Pagination is the caller's job — pass the already-sliced page as value (and the same page object under the matching extras key) so an id-keyed batched context query reuses the page's result cache.

arender_spec_output

arender_spec_output async

arender_spec_output(
    spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
    value: Any,
    *,
    many: bool = False,
    view: Any = None,
    request: Any = None,
    extras: Mapping[str, Any] | None = None,
) -> Any

Async :func:~rest_framework_services.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 :func:~rest_framework_services.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 :func: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.

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 :meth:~rest_framework.generics.GenericAPIView.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 :func:~rest_framework_services.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 :func:~rest_framework_services.dispatch.utils.resolve_output_context / :func:~rest_framework_services.dispatch.utils.resolve_input_context, so a provider keeps the final say on every key — including these three.

DispatchResult

DispatchResult dataclass

What :func:~rest_framework_services.dispatch_spec resolved, pre-wire.

A transport-neutral carrier the caller formats for its own wire (an HTTP Response, an MCP ToolResult, …). It holds the raw resolved domain value — never a paginated page or rendered serializer output — because ordering, pagination, and the response envelope are transport concerns. Render the value through :func:~rest_framework_services.render_spec_output.

Fields:

  • value — the resolved value: a single instance (RETRIEVE / mutation result), a queryset / iterable (LIST), or None (a nullable retrieve under allow_none, a missing instance, or a side-effect-only mutation).
  • kind — one of "instance" (a single value, possibly None), "list" (a collection to order / paginate / render many=True), or "not_found" (a required instance could not be resolved).
  • status — an HTTP-ish status hint the transport may map to its wire: the spec's success status for mutations, 200 for reads, 404 for "not_found".

Input policies

Three optional, caller-side policies let a transport map its wire onto a spec without dispatch_spec baking in HTTP's implicit answers. The spec declares what (its inputs, filters, output shape, permissions); the caller declares how its flat input becomes callable arguments, how strict to be about undeclared keys, and how to authorize a resolved target. The defaults reproduce the pre-policy behaviour exactly, so a caller that passes none is unaffected.

ArgumentBinding

How the flat params map onto a dispatched callable's keyword arguments — bundled as one data payload or spread as individual kwargs, and how the spread ranks against the spec author's kwargs.

ArgumentBinding

Bases: Enum

How dispatch_spec turns the flat params into a callable's kwargs.

Every dispatched callable's keyword pool always carries the request / user seeds (and, for a mutation, data / serializer / instance / collection). This enum controls the one remaining question a caller answers about its wire: can client-supplied input land as individual keyword arguments, and do those override the spec author's kwargs(...) invariants? It is a trust-boundary decision, not plumbing — which is why it belongs to the caller rather than the spec.

Members (the value never appears on a wire — pass the member directly):

  • AUTO — resolve per spec type to reproduce the pre-policy behaviour: :class:ServiceSpecBUNDLE (a mutation takes its validated payload as one data bundle), :class:SelectorSpecSPREAD_AUTHOR_WINS (a read spreads its params so the selector can declare them as parameters). The default.
  • BUNDLE — only the validated payload reaches the callable, as data=; individual client fields are not spread as kwargs. The safe default for a mutation whose kwargs scopes the write — the client cannot inject arbitrary keyword arguments.
  • SPREAD_AUTHOR_WINS — client fields are spread into the pool as individual kwargs, but spec.kwargs(...) overrides on conflict. An author-scoped tenant_id cannot be reset by the client. (drf-mcp's former MERGE.)
  • SPREAD_CALLER_WINS — like SPREAD_AUTHOR_WINS but the client wins on conflict: spec.kwargs(...) supplies defaults the client may override. (drf-mcp's former REPLACE.)

Reserved pool seeds (request / user / data / serializer / instance / collection) are always stripped from the spread in the SPREAD_* modes, so a client cannot poison transport-controlled state by naming an argument after one of them.

UnknownArguments

How strict dispatch_spec is about params keys outside the spec's declared set — drop them, reject them, or pass them through to the callable.

UnknownArguments

Bases: Enum

How dispatch_spec treats params keys outside a spec's declared set.

The declared set is derived from the spec without any transport knowledge: a :class:ServiceSpec's input_serializer fields plus the keys its nested target selectors consume; a :class:SelectorSpec's selector parameters. When the set cannot be enumerated — a callable that declares **kwargs, or a duck-typed filter_set whose fields are opaque to the core — the spec is treated as open and this policy is a no-op (there is nothing to call "unknown").

Members (internal knob — the value never appears on a wire):

  • IGNORE — undeclared keys are dropped (DRF serializers already do this to a mutation's payload, and a selector simply never receives kwargs it doesn't declare). The default, reproducing the pre-policy behaviour.
  • REJECT — an undeclared key raises :exc:~rest_framework.exceptions.ValidationError, the same surface a strict serializer produces. Useful when the caller wants a clean correction signal (e.g. a model calling a tool with a mistyped argument).
  • PASSTHROUGH — undeclared keys survive: they are merged onto the mutation's validated_data before the keyword pool is built, so a callable that declares them (or **kwargs) receives them. The one policy that must live inside dispatch_spec — it needs the seam between validation and pool construction that a wrapper cannot reach.

Callers strip their own transport-only keys (pagination, ordering, output format) before calling, so the declared-set check sees only spec inputs.

TargetGuard

The object-permission hook invoked with the resolved mutation target before the service runs. Its signature matches enforce_permissions, so that primitive is passed directly — by name, not wrapped in a lambda.

TargetGuard

Bases: Protocol

Invoked with the resolved target before a mutation runs; raise to abort.

dispatch_spec deliberately does not consult permission_classes (authorization is the caller's job — which is why :func:~rest_framework_services.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 :func:~rest_framework_services.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 :class:OfflineContext itself (it already holds user / request / view) and calls guard(spec, context, instance=target). instance is the resolved row for an update, the resolved set for a collection (bulk) mutation, and None for a create — so the guard fires uniformly on every resolved target, running the class-level check when there is no object.

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 :class:OfflineContext for dispatching a spec outside an HTTP request.

:func:~rest_framework_services.dispatch_spec forwards request / view to spec callables (kwargs providers, extend_queryset, context providers) that declare them, and :func:~rest_framework_services.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.

  • user is set on the synthetic request (request.user) so callables and permissions that read it behave as on HTTP.
  • params seeds request.data for callables that read it. It is not validated here — that is :func:dispatch_spec's job, which takes params directly and never touches request.data. Pass the same value to both. Seeded into DRF's parsed-data cache directly: params is already structured, so there is nothing to parse and a synthetic request has no WSGI stream to read.
  • http_request is wrapped when supplied (e.g. the MCP server passes its real Django request so headers / META are available); otherwise an :class:~rest_framework_services.OfflineHttpRequest is created. The method is forced to POST because mutation callables often branch on it.
  • host gives the synthesized request an origin, so build_absolute_uri — which DRF's FileField / HyperlinkedIdentityField call whenever a request is in the serializer context — returns real absolute URLs off the HTTP path. Accepts "example.com", "example.com:8000", or a full origin like "https://example.com" (the scheme sets whether links are https). There is no default: only the project knows its public origin, and guessing one — the first ALLOWED_HOSTS entry, say, which is an authorization list and is routinely a wildcard or an internal load-balancer name — would emit confidently-wrong links. Left unset, absolute-URI building degrades to returning the relative URL rather than raising; see :class:~rest_framework_services.OfflineHttpRequest. Ignored when http_request is supplied — a real request's own headers are authoritative (configure Django's USE_X_FORWARDED_HOST if it sits behind a proxy), which lets a caller pass both unconditionally: the ambient request when there is one, this host when there isn't.
  • query_params seeds the request's GET :class:~django.http.QueryDict — the source request.query_params reads. This is how read-shaping params that aren't spec inputs reach the serializer over the offline path: drf-services' own SelectorSpec.filter_set (otherwise dead off-HTTP), and any serializer that branches on request.query_params (django-restql field selection, custom serializers). Values are stringified as on HTTP; a list / tuple value becomes a multi-valued param (getlist). Defaults to empty → no behaviour change. When both query_params and http_request are given, this replaces the wrapped request's GET.
  • action / kwargs populate the :class:OfflineServiceView. kwargs is the off-HTTP counterpart of a view's URL captures (the parent_pk of a nested route): it is the channel for route-derived values, and :func: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. A spec.kwargs provider that reads view.kwargs (e.g. to scope by tenant) also sees it here. Pass every route capture a spec depends on; it defaults to {} (no URL context).

Read-shaping over the offline path. Pass query_params= to seed the synthetic request's GET QueryDict — the source request.query_params reads. That is how read-shaping params that are not spec inputs reach the serializer off-HTTP: SelectorSpec.filter_set (when you don't hand filter_data in another way), and any serializer that branches on request.query_params (django-restql field selection, custom serializers). It does not make DRF filter_backends (SearchFilter / OrderingFilter) run — the offline path never calls filter_queryset; filter_set is the drf-services-native equivalent.

OfflineHttpRequest

The synthetic request build_offline_context creates when there is no ambient one. Relevant when you're deciding what build_absolute_uri() should return off the HTTP path — see Absolute URLs off the HTTP path.

OfflineHttpRequest

Bases: HttpRequest

An :class:~django.http.HttpRequest with no ambient host, made safe to use.

Built by :func:~rest_framework_services.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 hostbuild_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.

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 :func: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 :exc:~rest_framework.exceptions.PermissionDenied (403) on the first failing permission, carrying that permission's message / code when it declares them — the same surface a DRF view produces.

Permission classes that read DRF APIView attributes beyond request / action / kwargs (e.g. DjangoModelPermissions, which inspects view.queryset) are not supported off-HTTP.

Shared

resolve_callable_kwargs

resolve_callable_kwargs

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.

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), optionally inside transaction.atomic().

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.

input_serializer may be:

  • a bare dataclass type — wrapped in a DataclassSerializer on the fly; validated_data is a dataclass instance;
  • a DataclassSerializer subclass — instantiated directly; validated_data is a dataclass instance;
  • any other Serializer subclass (e.g. ModelSerializer) — instantiated directly; validated_data is a dict.

extra_data (when supplied) is merged on top of request.data before the serializer instantiates — server-provided keys win on overlap. This is the seam used by the input_data resolver chain to lift URL kwargs into serializer input. A form-encoded / multipart body arrives as a QueryDict ({key: [values]} internally), so the merge goes through :func:_merge_extra_data to avoid flattening scalars into one-element lists — see there.

context (when supplied) is forwarded to the serializer's context= kwarg so DRF-style self.context["request"] / ["view"] lookups work inside validators and fields.

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

many (when True) validates data as a list — the bulk list-payload path; validated_data is then a list of items.

The serializer is returned validated (is_valid(raise_exception=True) has run) but never saved; the service owns persistence.

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 :func: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 :func:build_input_serializer for the input_serializer / partial / context / instance / many 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 :func:build_input_serializer (see there for the parameter semantics) returning only validated_data — kept 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 instance an update / destroy / detail action targets.

Precedence: spec.instance_selector_spec (when set with a selector) → the view's get_object() chain (an action_specs["retrieve"] selector via :class:SelectorRetrieveMixin, else DRF's default queryset / lookup_field lookup, else a user get_object() override). Used by the update / destroy viewset mixins, the standalone update / delete views, and @service_action detail actions so the precedence lives in one place.

The spec path dispatches through :func:dispatch_selector_for_spec (the standard selector call shape: {request, user} + the view's URL kwargs + the selector extras chain, queryset shaping applied, RETRIEVE materialization via .first()). The nested spec's allow_none flag is ignored — a mutation against a missing row is always a 404, so a None resolution raises :exc:~rest_framework.exceptions.NotFound regardless. Object-level permissions run against the resolved instance (view.check_object_permissions), matching DRF's own get_object() contract.

Returns None for a bulk spec (many=True or a collection_selector_spec): there is no single instance, and the get_object() lookup would 404 a body-only bulk endpoint. The bulk path resolves its target inside :func:dispatch_mutation_for_spec instead.

Selector side

run_selector

run_selector

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

Call a selector from sync code, transparently bridging async ones.

arun_selector

arun_selector async

arun_selector(
    fn: Callable[..., Any] | Callable[..., Awaitable[Any]], kwargs: dict[str, Any]
) -> Any

Call a selector from async code; sync ones run inline.

is_queryset

is_queryset

is_queryset(obj: Any) -> bool

True for Django QuerySet objects and Manager instances.

These are the queryset-shaping targets: the things the four shaping fields can be applied to, and the things a RETRIEVE selector / output selector should be materialized from via .first(). Centralizes the "is this a queryset?" decision so the selector and mutation dispatch paths agree on one definition instead of duck-typing on a method name (hasattr(..., "first")), which would also match an unrelated domain object that happens to expose first. QuerySet subclasses (.values(), .values_list(), polymorphic querysets, …) all pass.

apply_queryset_shaping

apply_queryset_shaping

apply_queryset_shaping(
    qs: Any,
    view: Any,
    request: Request,
    *,
    select_related: Any,
    prefetch_related: Any,
    annotations: Any,
    extend_queryset: Any,
    filter_set: Any = None,
    filter_data: Any = None,
    source_label: str,
) -> Any

Apply the five shaping fields to qs.

Declarative fields apply first (in declaration order), then extend_queryset runs so the user callable always sees the fully statically-shaped queryset, and finally filter_set narrows it via the transport-neutral filter_set(data=filter_data, queryset=qs).qs contract — validated first (see :func:_raise_on_invalid_filter, mirroring DjangoFilterBackend's 400-on-invalid-filter) — so filtering composes with shaping and runs before the retrieve .first() materialization the caller does next. Returns qs unchanged when no shaping is configured.

Raises :exc:ImproperlyConfigured when shaping is configured but qs is not a Django QuerySet (no annotate method) — loud failure beats a stale AttributeError deep in DRF rendering. source_label is included in the error to point at the misuse ("SelectorSpec.selector" vs "ServiceSpec.output_selector_spec.selector").

filter_set defaults to None so existing callers of this blessed surface keep working unchanged. filter_data is the data the FilterSet reads (a flat {field: value} mapping); it defaults to None, in which case the value falls back to request.query_params — the HTTP view path. A transport-neutral caller (dispatch_spec) passes its own params here. request is forwarded into the FilterSet when its constructor declares it (see :func:_filter_set_accepts_request), so a request-scoped FilterSet sees the same self.request it would behind DjangoFilterBackend instead of None — real on the HTTP / MCP paths, a faithful-user / -query_params synthetic off-HTTP; a bare (data, queryset) stand-in that doesn't declare request is unaffected.