Skip to content

Types

Most values here are django-service-specs' own, re-exported at the drfs path shown, so each drfs name is the kernel's object and the documentation rendered for it is the kernel's: SelectorKind, Affordance, ChangeResult, FieldChange, the relation declarations (ChildSpec, ForwardRelationSpec, ReverseOneToOneSpec, GenericRelationSpec, ManyToManySpec, RelationSpec) and their outcomes, UNSET, ProgressReporter, the pool seed registry (PoolSeeds, DEFAULT_POOL_SEEDS, RESERVED_POOL_SEEDS), FieldAudience, FieldMarking, ValueFormatter and AudienceProjection. The declarations that carry serializers and permission classes (ServiceSpec, SelectorSpec), the offline request and its channels, the input markers and MARKING are drfs' own.

Being the kernel's changes what a few of them refuse, each where it is built:

  • An Affordance whose callable when is async def, or reads result or queryset, is refused with ImproperlyConfigured, as one reading data or instance already was.
  • A relation declaration takes only its model, and its foreign key where it has one, by position; every other option is keyword-only. relations= is the one spelling for nested relations: the children= alias is gone.
  • PoolSeeds checks a registration against the kernel's reserved set, user, data, instance, collection, result, queryset and progress: a seed named result or queryset is refused, one named request or serializer can be registered, and an async def resolver is refused with ImproperlyConfigured.
  • ValueFormatter.timestamp() reports a string shaped like a date-time that names no real moment (2026-02-30T12:00:00Z) as it was stored, rather than raising.

SelectorKind

SelectorKind

Bases: str, Enum

The shape a selector spec returns.

LIST returns a collection, a queryset when it can be shaped. RETRIEVE returns one row: a queryset is materialized with .first(), and a missing row is reported as not found unless the spec allows None.

Inheriting from str keeps the value JSON-serializable while still behaving as a proper enum for is / ==.

SelectorSpec

SelectorSpec dataclass

Bases: Generic[ResultT, ExtraT]

All wiring for a single read action in one record.

Used as a value in action_specs on viewsets, as the spec= argument to SelectorListView / SelectorRetrieveView, and as the output_selector_spec field on ServiceSpec (where it describes the post-mutation re-fetch).

All fields are keyword-only: SelectorSpec(kind=SelectorKind.LIST, selector=fn) rather than positional. kind is required and is the only field without a default. Several fields are providers: they are resolved through the framework keyword pool, declaring any subset of the keywords listed for them (or **kwargs) and receiving only what they name.

The generic parameters both default to Any: ResultT is the selector's return type and ExtraT a TypedDict of the keys kwargs returns.

The five shaping fields (select_related / prefetch_related / annotations / extend_queryset / filter_set) require selector to be set and the selector to return a Django QuerySet. Configuring any of them with no selector raises ImproperlyConfigured at as_view() time; a non-QuerySet return raises at request time.

Attributes:

Name Type Description
kind SelectorKind

Required SelectorKind discriminator. RETRIEVE materializes a QuerySet via .first() and raises NotFound on a None / missing object; LIST returns whatever the selector returns unchanged. It also drives the fail-fast check that the spec is mounted on a compatible view — a LIST spec on SelectorRetrieveView raises at as_view(). Being explicit rather than inferred from the call site, it also carries the semantics outside a request, to a management command or any other non-DRF caller.

selector Callable[..., ResultT] | None

Callable invoked by get_queryset() (list) or get_object() (retrieve). None uses the configured queryset / default DRF behaviour.

allow_none bool

RETRIEVE-only knob for the None / missing-object case. False raises NotFound; True expresses a nullable-resource contract, where the standalone retrieve view, the retrieve viewset mixin and a RETRIEVE @selector_action render 200 with a JSON null body and skip the output serializer. Ignored when the spec is nested: ServiceSpec.output_selector_spec keeps its authoritative-None → 204 contract and ServiceSpec.instance_selector_spec always 404s.

output_serializer type | None

Renders the result: a DRF serializer class, or a bare dataclass type, which renders through a DataclassSerializer built for it — the payload its output schema already describes. It is what get_serializer_class() returns for this action. None falls back to DRF's standard serializer_class.

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

Provider for the response serializer's context=, at the most specific layer of the chain (get_serializer_context → get_output_serializer_context → get_<action>_output_serializer_context → this), so it wins on overlapping keys. None leaves the earlier layers intact. Its pool is view / request plus the resolved data being serialized — page on a LIST spec (the paginated object list, or the full queryset when pagination is off) or instance on a RETRIEVE spec — so it can run a single batched query against the exact objects being serialized and propagate the outcome through context, as in lambda *, page: {"votes": tally(page)}. It always runs after the data is resolved. Selectors do not validate input, so there is no symmetrical input_serializer_context.

select_related Sequence[str] | None

Relation names, forwarded as qs.select_related(*spec.select_related).

prefetch_related Sequence[str | Prefetch] | None

Relation names or Prefetch objects.

annotations Mapping[str, Any] | None

Mapping merged into a single .annotate(**...) call. With the two fields above, this is declarative shaping applied to the selector's return value before it leaves dispatch_selector_for_spec — reach for it whenever the same shaping applies every request, since it stays introspectable.

extend_queryset Callable[[QuerySet[Any], ServiceView, Request], QuerySet[Any]] | None

Dynamic escape hatch, invoked after the declarative fields have applied, so it always sees the fully statically-shaped queryset. Use it when the shaping depends on the request, such as prefetching only when a query string opts in. Synchronous only — it manipulates the queryset's lazy expression tree, not the database.

filter_set Any | None

Transport-neutral filtering applied to the selector's QuerySet: a django-filter FilterSet class, or any object honouring the same (data, queryset) -> .qs contract. The dispatcher calls filter_set(data=request.query_params, queryset=qs).qs, so the declaration of which fields are filterable lives on the spec while the values come from the request. Applied after the four shaping fields and before the retrieve .first(), so it composes with shaping and narrows RETRIEVE selectors too, where RetrieveModelMixin runs no filter step. On the list path it replaces DjangoFilterBackend rather than stacking with it — the values come off the same request.query_params — and wiring both for one action raises at as_view(). Replacing it means keeping its contract, so invalid filter input is rejected with a 400: the FilterSet is validated via is_valid() and its errors are raised as a DRF ValidationError, where reading .qs unvalidated would answer 200 with unfiltered rows in django-filter's default non-strict mode. That is enforced only when the duck-typed object actually exposes is_valid; a bare (data, queryset) -> .qs stand-in keeps its pass-through behaviour. The dispatcher also forwards the request into the FilterSet when its constructor declares one, so a request-scoped FilterSet — self.request.user scoping, a request-aware ModelChoiceFilter queryset — sees the same self.request it would behind DjangoFilterBackend rather than None: real on the HTTP / MCP paths, and a synthetic off-HTTP one whose user and query_params are faithful (headers / session are best-effort there). A bare (data, queryset) stand-in never receives it. None applies no filtering. Reach for it only when the selector returns a QuerySet: for an aggregate / computed return the ?param values are computation inputs, so use kwargs / get_selector_kwargs() instead.

affordances Mapping[str, AffordanceDeclarer] | None

What can be done to each row right now, answered inside the list query itself. A mapping from a name to the ServiceSpec whose affordances are being asked about -- the spec objects, not registry names, so the read path takes no registry dependency and the declaration stays the one the mutation enforces. Every condition on the row becomes one boolean annotation, merged into the same single .annotate() call as annotations, and evaluated as the same correlated Exists the mutation checks with, so a row reported available is a row the call lets through. A callable condition is answered once per dispatch and carried as a constant. The annotations are named affordance__<name>__<code>; a collision with a key of annotations, or between two entries, is refused at construction, because a generated annotation silently replacing a project's own is the worst way for this to fail. Being annotations, they read the table rather than any prefetch_related cache, whose filtered Prefetch would otherwise hide rows from the answer. Requires selector. A selector returning rows rather than a QuerySet -- a list, or a RETRIEVE selector's bare instance -- gets the same answers under the same names: model instances are answered by one query per model class and carry them as attributes, mappings come back as new mappings carrying the callable answers (a condition on the row is refused on a mapping, which has no primary key to find), and a generator is materialised into the list that flows on. None adds nothing to the query.

kwargs Callable[..., ExtraT] | None

Provider (pool: view / request) of extra kwargs merged into the pool the selector receives. Co-locating it with the spec lets each action declare its own contract, instead of if self.action == ... branching in one catch-all get_selector_kwargs.

permission_classes Sequence[type[BasePermission]] | None

Override the calling view's permissions for the action the spec backs. None inherits the view's class-level permissions; an empty sequence means none, explicitly. Forwarded through DRF's @action(permission_classes=...) for the @selector_action decorator and surfaced via get_permissions for the viewset mixins and standalone views. Ignored when the spec is nested under ServiceSpec.output_selector_spec — the surrounding mutation action's permissions apply.

progress_reporter Callable[..., Any] | None

Provider returning a ProgressReporter sink, fanned together with whatever reporter the transport supplied. For sinks that do not care which transport carries the run — a task record, an audit trail, metrics.

preconditions Sequence[Callable[..., None]] | None

State/DB rules invoked after the target resolves, seeded with instance (RETRIEVE) or collection (LIST). A selector has no validation step, so that is the one position available, and pool binding does the discrimination — a precondition declaring instance cannot be written against a LIST spec. Raise-to-abort: the return value is ignored. See ServiceSpec for the raise contract.

metadata Mapping[str, Any] | None

Consumer-owned, framework-opaque mapping with exactly one reserved key: "json_schema", which spec_to_json_schema merges onto the schema it derives — see that function for the shape, the phase keys and the precedence. Every other key is carried and never read: no defaulting, no per-key validation, no effect on the generated JSON Schema or OpenAPI. Validation at construction stays shape-only, and a non-Mapping raises ImproperlyConfigured there. Use it to attach a project's own per-operation facts — read back by its own permission class, scoping helper, or audit hook — to the spec describing the operation, rather than to a name-keyed side table that drifts the day a spec is renamed. It is reachable wherever the spec is: view.action_specs[view.action].metadata inside a permission class (which receives (request, view), so it has the spec but knows no registry or name), or entry.spec.metadata from a RegisteredSpec, whose tags handles the boolean-ish labels this is not for. It never merges or inherits — a ServiceSpec and its output_selector_spec carry independent metadata — and it is stored exactly as given: the spec is frozen, the mapping is not, and the library neither copies nor deep-freezes it, so pass something you don't mutate. None means "not declared", which stays distinguishable from a declared empty mapping.

reads Sequence[UrlKwarg | QueryParam]

The arguments a caller with no HTTP request sends in place of one, as on ServiceSpec: each UrlKwarg a route would have captured and each QueryParam a query string would have carried, compiled into the kernel spec's reads beside the parameters the selector's signature declares, and winning over a reflected one of the same name. Refused when the spec is built if it holds anything else or a name twice, and when it compiles if it names a value the pool fills, or a parameter the selector or a precondition marks NotClientInput.

field_audiences Mapping[str, FieldMarking] | None

Per-field markings layered over output_serializer's own, at its top level, compiled onto the presenter. Refused by the compile without an output_serializer. On a service spec's output_selector_spec the service spec's own declaration is layered over this one.

json_schema_registry JsonSchemaRegistry | None

The project's JsonSchemaRegistry, which describes this spec's schemas. drfs' spec_to_json_schema reads it when no registry= is passed, and a passed one wins. The compile reads its fields rules where it walks the input and output serializers, and its python_types rules where it reads a callable's annotations: a selector's, a target lookup's and a spread service's parameters. As a ServiceSpec's target lookup or output_selector_spec, this spec reads its own, else the service spec's. An affordance is a spec of its own and reads only its own. A matched fragment reaches the compiled spec split in two: the keywords a Parameter or OutputField spells set those fields and are checked off HTTP, and the rest is json_schema_extra, advertised and not checked. What the kernel would refuse is dropped from the compiled spec and kept by drfs' own builders, so a registry never stops a compile: a format outside the kernel's four (decimal, date-time, date, uuid), or a keyword the field's type has no room for. Its filters rules never reach the compiled spec, since the kernel builds a filter_set's Parameters itself. Named for the keyword drf-mcp's server and the agent toolsets take the same object by, since none of the three naming rules fits: it wraps no Django or DRF concept, configures no serialization phase and resolves no nested spec. A field rather than a setting because it is an object, and because the compile caches per spec, so a setting read at compile could not change a spec already compiled.

as_kernel_spec

as_kernel_spec() -> KernelSelectorSpec

This spec compiled to django-service-specs' SelectorSpec.

What makes it a kernel SpecSource; see as_kernel_spec.

agent_contract

agent_contract() -> OfflineContract | None

The OfflineContract this spec's reads and field_audiences declare.

What a registry entry's agent_contract answers, for the transports published against a release that took the contract at registration. None when both are empty.

ServiceSpec

ServiceSpec dataclass

Bases: AffordanceDeclarer, Generic[InputT, ResultT, ExtraT]

All wiring for a single mutation action in one record.

Used as a value in ServiceViewSet.action_specs and as the spec= argument to service_action / ServiceCreateView / ServiceUpdateView / ServiceDeleteView.

Fields group into the service callable itself, the input pipeline (input_*), the output pipeline (a nested SelectorSpec), and cross-cutting concerns. Several are providers: they are resolved through the framework keyword pool, declaring any subset of the keywords listed for them (or **kwargs) and receiving only what they name.

The generic parameters are optional and purely informational for type checkers. InputT is the validated-data type input_serializer produces (the dataclass for dataclass-based serializers, usually dict[str, Any] for a plain ModelSerializer), ResultT the service's return value and the input to output_selector_spec.selector, and ExtraT a TypedDict of the keys kwargs returns. All three default to Any, so ServiceSpec(service=fn) keeps working unchanged.

many and collection_selector_spec are the two bulk shapes and are mutually exclusive. Both run all-or-nothing under atomic=True, and both authorize per-set — the view / spec permission_classes plus the scoped selector, with no per-row check.

A spec declares at most one target lookup, and a many=True spec declares none. instance_selector_spec beside collection_selector_spec, and either lookup beside many=True, is refused at construction, because dispatch would never call it.

Attributes:

Name Type Description
service Callable[..., ResultT]

The callable the action runs.

atomic bool

Run the dispatch in a transaction.

success_status int | Callable[..., int] | None

The 2xx status. An int is used verbatim; a provider (pool: result / instance / request / view) returns one, which is what an upsert answering 201 or 200 by outcome needs — it sees the service's return value as result. None lets each consumer apply its action-appropriate default (201 create, 200 update, 204 destroy). OpenAPI cannot resolve a provider statically, so the schema documents the mixin default in that case.

idempotent bool | None

Whether repeating the call with the same arguments leaves the same state as making it once. Declaration-only: nothing in this package reads it, because idempotency is a property of the service the author writes, not something a dispatcher can arrange. It is here so the fact is stated once, on the spec, and every transport reads the same answer — a retry policy, a queue's redelivery handling, an agent tool annotation. None means undeclared and is the default: a transport that turns the signal into a published annotation must be able to tell "nothing was said" from a declared False, or every spec ever written starts claiming it is not idempotent. Note that atomic is a different question — it says a single call is all-or-nothing, not that a second call is a no-op.

partial bool | None

Override the partial-validation flag the calling surface derives (False for PUT/POST, True for PATCH). Forcing False on a partial_update entry makes a PATCH endpoint enforce required like a PUT. Applied once, in dispatch_mutation_for_spec, so the viewset mixins, the standalone views and @service_action all honour it.

many bool

Validate the request body as a list and render the result list the same way. The service receives the validated list as data and loops itself, so one call does the batch.

many_argument str

The one argument a many=True list travels under for a caller whose input is always an object of named arguments, and so can never be a bare array. "items" by default. HTTP never reads it: the request body stays the array itself. A caller that does passes many_as_argument=True to dispatch_spec, which reads the list out of that argument and keys every validation error under it, and spec_to_json_schema describes the input as an object with that one array property. Must be an ASCII identifier. Declaring a name other than the default without many=True raises ImproperlyConfigured: nothing would read it.

document_service_error bool | None

OpenAPI-only — whether the schema documents the 422 ServiceError response. No runtime effect; a service may always raise. None gates it on input_serializer is not None, so a plain delete carries no spurious 422. Only consulted when the [spectacular] extra is enabled through enable_openapi.

input_serializer type | None

Validates the request body.

input_data Callable[..., Mapping[str, Any]] | None

Provider (pool: view / request / instance, the latter None on create) returning a mapping merged on top of request.data before validation — the home for lifting URL kwargs such as a nested route's parent id into fields the serializer can cross-validate. Server-provided keys win on conflict. The get_input_data / get_<action>_input_data view hooks follow the same declare-to-receive rule. Declared with no input_serializer, it is refused with ImproperlyConfigured when the spec compiles, since nothing would read the mapping; kwargs is the home for a value such a service reads.

input_serializer_context Callable[..., Mapping[str, Any]] | None

Provider for the input serializer's context=, at the most specific layer of the chain (get_serializer_context → get_input_serializer_context → get_<action>_input_serializer_context → this), so it wins on overlapping keys. None leaves the earlier layers intact. The output twin is output_selector_spec.output_serializer_context, which may also declare result to receive the post-selector instance and run a single batched query against it.

instance_selector_spec SelectorSpec[Any, Any] | None

Nested RETRIEVE spec resolving the row an update / destroy / detail action targets, embedding the lookup in the spec rather than the view's queryset / get_object() chain. Its kwarg pool is {request, user} plus the URL kwargs, so selector=lambda *, pk: Project.objects.filter(pk=pk) resolves from the route. Resolution runs before input validation: the row is handed to the input serializer DRF-style and seeded into the service pool as instance. A missing row is always NotFound — the nested allow_none is ignored — and check_object_permissions runs against it. Queryset shaping applies; the nested output_serializer / output_serializer_context are ignored, and the nested permission_classes / preconditions are refused at as_view() — the dispatching spec's permissions are the ones checked, so declaring them here would guard nothing. Refused with ImproperlyConfigured when the spec is built beside collection_selector_spec (dispatch resolves the target through the collection lookup and never calls this one) or beside many=True (a list payload resolves no target).

collection_selector_spec SelectorSpec[Any, Any] | None

The LIST-kind twin of instance_selector_spec. Its resolved set is seeded into the pool as collection to .delete() / .update() / iterate, for an instance-less "operate on the filtered set" action where an empty set is a harmless no-op rather than a 404. The pool carries query params, body and URL kwargs, so a nested-route bulk can scope by parent_pk; route captures win on conflict, so a filter value cannot override the route scope. Its permission_classes / preconditions are refused at as_view() for the same reason as instance_selector_spec's. Refused with ImproperlyConfigured when the spec is built beside many=True: a list payload resolves no target, so dispatch never calls it.

output_selector_spec SelectorSpec[Any, Any] | None

The output pipeline as one nested spec. Its kind declares response cardinality: RETRIEVE re-fetches a single instance (the service returns the written row, the selector re-fetches it with the relations the response needs, and output_serializer renders it); LIST re-fetches and renders a set and is valid over HTTP only alongside collection_selector_spec. Either with no selector re-fetches nothing: RETRIEVE presents the service's return as one value, and LIST presents it as the set, so it must iterate rows (a mapping, a str or None raises ImproperlyConfigured after the service has run). None renders the service's return value directly. The nested kwargs is ignored — the surrounding mutation's chains apply — and the nested permission_classes / preconditions are refused at as_view() rather than silently ignored.

output_filter_from_call bool

Whether the call narrows the re-read: the output_selector_spec's filter_set is bound to the call's filter_data (a live view passes its query string), or to an empty mapping when there is none, so a ?published=false on a PATCH can hide the row just written. True by default. False binds it to an empty mapping on every call, so the re-read answers from the service's return alone and the filter narrows only by what its FilterSet does with no data. A no-op without an output filter_set, and on a many spec, whose output selector is never run.

allow_none bool

Declare that the service may present nothing: it returns None and no output re-read replaces it. The output schema spec_to_json_schema states then admits null beside the row. False by default, and an undeclared None is not refused: dispatch passes the service's return through either way, so an undeclared one is served against a schema that does not admit it. A spec whose output_selector_spec has a selector admits null whatever this says, because the re-read can find no row. A result that is a list (many=True, or an output_selector_spec declaring LIST, with a selector or without) is never None, so it is not read there. The nested output_selector_spec.allow_none is still not read; this is the declaration. One answer for all of it: can_present_nothing.

kwargs Callable[..., ExtraT] | None

Provider (pool: view / request) of extra kwargs merged into the pool the service receives. Co-locating it with the spec lets each action declare its own contract, instead of if self.action == ... branching in one catch-all. See ServiceView for what the view argument offers.

permission_classes Sequence[type[BasePermission]] | None

Override the calling view's permissions for this action. None inherits the view's; an empty sequence means none, explicitly. Forwarded through DRF's @action for @service_action and surfaced via get_permissions elsewhere.

progress_reporter Callable[..., Any] | None

Provider returning a ProgressReporter sink, fanned together with whatever reporter the transport supplied. For sinks that do not care which transport carries the run — a task record, an audit trail, metrics.

preconditions Sequence[Callable[..., None]] | None

State/DB rules invoked immediately before the service, after validation and target resolution, so each sees data / serializer alongside instance or collection / user / request. Raise-to-abort: the return value is ignored, so a predicate returning False does nothing. Raise ServiceError or ServiceValidationError — every transport maps those. DRF's ValidationError, PermissionDenied and NotAuthenticated reach the kernel's route as the kernel's classes too; any other DRF APIException (NotFound, Throttled, a project's own APIException outside those three) is mapped on HTTP only.

affordances Sequence[Affordance] | None

What must be true for this operation to be possible right now, as a sequence of Affordance declarations. Checked in declaration order after the target guard and input validation and before preconditions; the first one not met raises ActionUnavailable carrying its code, and the service does not run. Every condition on the row is answered by one query, however many are declared; a condition that is a callable runs off the event loop on the async path, as a precondition does. A condition on the row needs a single resolved row, so it is refused alongside many=True or a collection_selector_spec, and a dispatch that resolved none raises ImproperlyConfigured. It is a check at the moment of the call, not a lock: the service still re-validates whatever it relies on. None declares nothing and costs nothing -- no query, no call. Codes must be unique within a spec, because the code is how a reader tells the conditions apart.

response_finalizer Callable[..., Response | None] | None

Provider (pool: response / result / request / view / instance / data) for HTTP response side effects — cookies, headers, a swapped response. Runs on the 2xx path only, after the output serializer has built the Response and before it is returned; error paths bypass it. Return a Response to replace the built one or None to keep it. result is the service's return value, so the idiomatic pattern keeps services DRF-free: the service returns domain flags and the finalizer translates them into transport effects. HTTP-only — skipped on the transport-neutral path, which builds no Response. On the bulk path instance / data are absent and result is the post-output-selector value.

metadata Mapping[str, Any] | None

Consumer-owned and framework-opaque, with exactly one reserved key: "json_schema", which spec_to_json_schema merges onto the schema it derives — see that function for the shape, the phase keys and the precedence. Every other key is carried and never read: no defaulting, no per-key validation, no effect on the generated JSON Schema or OpenAPI. Validation at construction stays shape-only — a non-Mapping raises ImproperlyConfigured there, and the reserved key is checked when a schema is generated rather than here, so declaring metadata never costs a spec that generates no schemas. Use it to attach a project's own per-operation facts, read back by its own permission class or audit hook, to the spec describing the operation rather than to a name-keyed side table that drifts the day a spec is renamed. It never merges with a nested spec's metadata and is stored exactly as given. See SelectorSpec.

reads Sequence[UrlKwarg | QueryParam]

The arguments a caller with no HTTP request sends in place of one: each UrlKwarg a route would have captured and each QueryParam a query string would have carried. HTTP never reads it, since its URLconf and query string are the real thing. Compiled into the kernel spec's reads, one Parameter each, and on an OfflineServiceView routed back onto view.kwargs and request.query_params for the permission classes; see as_kernel_spec. Kept as a tuple; anything but those two declarations, or a name declared twice, is refused when the spec is built. A name the pool fills, or one the service or a precondition marks NotClientInput, is refused when the spec compiles.

field_audiences Mapping[str, FieldMarking] | None

Per-field markings layered over the output serializer's own, at its top level: what an agent audience sees of this operation's output, declared once on the operation rather than per mount. Compiled onto the output's presenter. Declared with no output serializer (output_selector_spec.output_serializer), it has nothing to mark, and the compile refuses it.

json_schema_registry JsonSchemaRegistry | None

The project's JsonSchemaRegistry, which describes this spec's schemas. drfs' spec_to_json_schema reads it when no registry= is passed, and a passed one wins. The compile reads its fields rules where it walks the input and output serializers, and its python_types rules where it reads a callable's annotations: a selector's, a target lookup's and a spread service's parameters. A nested selector spec (a target lookup or output_selector_spec) reads its own, else this one. An affordance is a spec of its own and reads only its own. A matched fragment reaches the compiled spec split in two: the keywords a Parameter or OutputField spells set those fields and are checked off HTTP, and the rest is json_schema_extra, advertised and not checked. What the kernel would refuse is dropped from the compiled spec and kept by drfs' own builders, so a registry never stops a compile: a format outside the kernel's four (decimal, date-time, date, uuid), or a keyword the field's type has no room for. Its filters rules never reach the compiled spec, since the kernel builds a filter_set's Parameters itself. Named for the keyword drf-mcp's server and the agent toolsets take the same object by, since none of the three naming rules fits: it wraps no Django or DRF concept, configures no serialization phase and resolves no nested spec. A field rather than a setting because it is an object, and because the compile caches per spec, so a setting read at compile could not change a spec already compiled.

as_kernel_spec

as_kernel_spec() -> KernelServiceSpec

This spec compiled to django-service-specs' ServiceSpec.

What makes it a kernel SpecSource: dispatch, authorize, the registry and the schemas take it as it is and compile it once. See as_kernel_spec.

agent_contract

agent_contract() -> OfflineContract | None

The OfflineContract this spec's reads and field_audiences declare.

What a registry entry's agent_contract answers, for the transports published against a release that took the contract at registration. None when both are empty, as an entry registered without one was.

Affordance

Affordance dataclass

What can be done to this object right now, declared once on the operation.

A condition on a ServiceSpec saying whether the operation is possible as a fact about the world: an order that shipped cannot be cancelled, a draft with no title cannot be published, refunds are closed while the books are. Not a fact about the caller - that is permissions, which refuses with NotPermitted and is always checked first. An affordance refuses with ActionUnavailable, a conflict.

ServiceSpec(
    service=cancel_order,
    affordances=[
        Affordance(
            code="order_shipped",
            reason="A shipped order cannot be cancelled.",
            when=~Q(status="shipped"),
        ),
    ],
)

when is true when the action is available, and its type decides what kind of condition it is - derived, never declared, so it cannot drift from what it describes:

  • An ORM boolean expression - a Q, an Exists, a lookup - is a condition on the row. It reads exactly as Model.objects.filter(when) does, relations included, and is evaluated in SQL: one query for a single object, however many conditions it declares. A row is available only when the condition is true; a NULL is not true.
  • A callable is a condition on nothing in particular - "refunds are closed during month-end". It is resolved through the keyword pool like any other spec callable and returns a bool. It is sync-only, declared with def: it is never awaited, so an async def one is refused when the affordance is declared. It sees the pool's seeds only: user, progress and any registered pool seed, an HTTP adapter's request among them. Never the call's target or input, and never a client argument - the caller does not get to decide whether the operation is available.

A Python callable over the row is refused, and that refusal is the design. A predicate the ORM cannot read has to run once per row, which turns every list that reports availability into one query per row, and prefetching does not fix it: a prefetched relation answers some reads from its cache and silently re-queries for others. A rule that cannot be computed for fifty rows in one query is not an affordance - it stays in the service, where it is enforced and never advertised.

Attributes:

Name Type Description
code str

Stable and machine-readable: it names the rule, never the state that tripped it, and it does not change when the sentence is reworded. What a transport or a client branches on.

reason str

The sentence people and models both read - in a rendered answer, and as the message of the ActionUnavailable a refused call raises. Write it for them: say what is not possible and, where it helps, what would make it possible, and keep internal state out of it, since every reader of the operation reads it.

when Any

The condition, as above.

Raises:

Type Description
ImproperlyConfigured

code or reason is not a non-empty string; when is an ORM expression that is not boolean; when is a callable naming data, instance, collection, result or queryset; when is an async callable - an async def, an object whose __call__ is one, or an async generator function - since a condition is never awaited; or when is neither an expression nor a callable.

AffordanceDeclarer

AffordanceDeclarer

A spec that declares the conditions an operation needs: a ServiceSpec.

A SelectorSpec maps each name in its affordances to the ServiceSpec whose conditions it answers for every row, and reads one attribute off it: affordances. It cannot name ServiceSpec to type that mapping or to refuse anything else in it, because service_spec imports selector_spec for the selector specs a service declares (its instance, collection and output selectors), and the two modules importing each other fail whichever loads first. So ServiceSpec inherits this base, which imports nothing of either, and the selector names the base.

A base rather than a protocol because SelectorSpec refuses, when it is built, any value of that mapping that is not one: an isinstance against a protocol of this one attribute would pass a SelectorSpec, which has an affordances field of its own, and the refusal would let through the spec of a read where the spec of a write is meant.

The attribute is declared as a read-only property, because ServiceSpec is a frozen dataclass and its field is read-only: a plain annotation here would declare a writable attribute the field then narrows, which a type checker refuses as an invalid override. The property never runs - the dataclass's field shadows it on every ServiceSpec - so it only states what the selector may read.

PolymorphicServiceSpec

PolymorphicServiceSpec dataclass

A single action that accepts several mutually exclusive payload shapes.

Each variant has its own input serializer and service, bundled as a full ServiceSpec under a string key. A discriminator callable inspects the request and returns the key; dispatch then proceeds through the chosen spec exactly as a plain ServiceSpec would. Usable where a view takes a ServiceSpec: an action_specs entry and the spec= of @service_action. A SpecRegistry and the other kernel entry points refuse it with a pointer at its variants, which are registered one per name.

PolymorphicServiceSpec(
    discriminator=resolve_flow,        # pool: {request, data, user, view, call_context} → key
    specs={"email": email_spec, "token": token_spec},
)

The discriminator is resolved once per request and reused across dispatch, permissions, and serializer resolution, and the resolved concrete spec flows through the shared action→spec chain — so the chosen variant's serializer context, kwargs, and output pipeline all apply.

There is no metadata field here. The wrapper is never dispatched, so metadata belongs on the variants; a permission class that reads it off the spec it finds on the view therefore finds this wrapper, which has none, and under the default permission_strategy="union" there is no variant yet to fall back to — permissions run before discrimination. Express a metadata-driven rule per variant, via each variant's own permission_classes, rather than reading the wrapper.

Attributes:

Name Type Description
discriminator Callable[..., str]

Resolved through the framework keyword pool — it declares any subset of request / data (the raw request.data) / user / view (or **kwargs), and call_context, the request's call context, by name only — and returns a key present in specs. For a no-match / rejected payload it should raise (e.g. ServiceValidationError, which the view layer maps to a 400); returning a key absent from specs is a configuration error and raises ImproperlyConfigured.

specs Mapping[str, ServiceSpec[Any, Any, Any]]

The variants, keyed by the value discriminator returns.

permission_strategy PermissionStrategy

How get_permissions behaves, given that DRF runs permissions before the body is necessarily parsed. "union" (the default) requires the union of every variant's permission_classes — no early body read, and the conservative/secure failure mode, since a mis-declared discriminator branch can't widen access; for the common "same auth, different payload shapes" case it is identical to "require_identical". "discriminate" runs the discriminator early (reading the raw body) and applies only the chosen variant's permission_classes — most precise, for the genuine "each variant is a different privilege" case. "require_identical" is validated at as_view() time to require every variant to declare the same permission_classes, sidestepping the ordering entirely.

as_kernel_spec

as_kernel_spec() -> None

Refuse compiling to one spec, naming the remedy.

Every kernel entry point (SpecRegistry.register, kernel_spec, dispatch) asks a declaration for as_kernel_spec(). A wrapper picks its variant per request, so there is no one spec to answer with, and without this method the kernel's refusal would be a bare type error that says nothing of the variants. The registry prefixes the name it was registering (test_a_polymorphic_spec_is_refused_with_a_pointer_at_its_variants).

Annotated None on purpose, never NoReturn: the bottom type would make this wrapper a structural SpecSource, so a type checker would accept registering it, where it refuses it today.

Raises:

Type Description
TypeError

always.

ChangeResult

ChangeResult dataclass

Bases: Generic[ModelT]

Outcome of a mutation helper call.

instance is the model instance after the mutation. created is True iff this came from create_from_input / acreate_from_input. changes records every field whose value actually differed from its prior value (or from UNSET for creates).

Every relation declared in relations= reports in one of two fields, chosen by the relation's shape. children carries one ChildCollectionChange per collection: a reverse-FK ChildSpec, a GenericRelationSpec and a ManyToManySpec. relations carries one RelatedObjectChange per one-row relation: a ForwardRelationSpec and a ReverseOneToOneSpec. A collection reports tuples of pks and a one-row relation reports an outcome, so they are different carriers. Both are empty when no relation is declared, and a declared relation the input omits still gets its entry, reporting nothing. A forward relation shows up twice and means two different things — here as what happened to the row it points at, and in changes as the parent's foreign-key column, which only appears if it actually changed.

The class is generic over the concrete model type: callers that pass Author into a mutation helper get back a ChangeResult[Author] whose .instance is typed as Author. The bare name ChangeResult (no parameter) resolves to ChangeResult[Model] and keeps working for callers that don't care.

changed_fields property

changed_fields: tuple[str, ...]

Names of every field present in changes.

get_field_change

get_field_change(field_name: str) -> FieldChange | None

Return the FieldChange for field_name, or None.

get_child_change

get_child_change(relation: str) -> ChildCollectionChange | None

Return the ChildCollectionChange for relation, or None.

get_relation_change

get_relation_change(relation: str) -> RelatedObjectChange | None

Return the RelatedObjectChange for relation, or None.

FieldChange

FieldChange dataclass

One field's before/after pair from a mutation.

old will be UNSET for fields populated as part of a create (no prior value existed).

ChildSpec

ChildSpec dataclass

Bases: RelationSpec

How to persist one reverse-FK ("one-to-many") child collection.

The reverse-FK member of the relation taxonomy: it writes rows whose foreign key points back at the parent, so it belongs to RelationPhase.REVERSE and is written after the parent's save().

Passed in the relations={relation_name: ChildSpec(...)} map of create_from_input / update_from_input (and their async siblings). The incoming child rows are read from data[relation_name]; each child is persisted by running it back through the same mutation helpers, so scalar / m2m / nested semantics compose recursively. The whole parent + children write runs inside the service's atomic block; validating the arguments stays with the spec's Validator, which dispatch runs before the service — the helper owns persistence only.

Pluggable services — the spec owns reconciliation, the service owns the row. Matching, mode and orphan handling never move into your code; a slot is called once per row the loop has already decided about. Each is invoked through run_service / arun_service with atomic=False, because the surrounding service's atomic block already wraps the whole tree and letting each row open its own would mean a savepoint per row. Each receives only the pool keys it declares (the library's usual signature-filtering idiom), drawn from the mutation helpers' opaque context= plus the loop's own seeds. Those seeds — data / instance / parent — are applied after the context, so a context key of the same name cannot outrank them, the precedence form of the rule RESERVED_POOL_SEEDS states for dispatch's pools. In the async loops the slot must be an async def: the async path is awaited end to end, so a sync one is refused with ImproperlyConfigured, naming the relation and the slot, before it runs.

A declared slot owns that row entirely: field_map, exclude_fields, m2m and the nested relations map configure the default mutation-helper call, so a create_service / update_service standing in for it makes them dead configuration. Declaring both raises ImproperlyConfigured at construction rather than dropping them quietly. delete_service is exempt — it replaces the unlink-or-delete rule, not the helper call, so the cascade still removes a row's grandchildren before handing the row over. The spec keeps only what it never delegates — which rows exist, which incoming row matches which existing one, and what happens to the ones left over.

Attributes:

Name Type Description
model type[Model]

The child model class.

fk str

Name of the child's forward foreign-key field pointing at the parent ("author" for Book.author). Set automatically on created children, and used to resolve the parent's reverse manager.

match_key str

Field used to pair an incoming row with an existing child. An incoming row whose match_key matches an existing child updates it; one with no match, or no key, is created. The same name is read off both the incoming mapping (item[match_key]) and the existing instance (getattr(child, match_key)), so rows keyed by "id" should set match_key="id". The one name does two jobs — an input key on the mapping side, a model field on the lookup side — which is fine while the two agree and is why field_map may not rename anything onto the primary key while match_key matches on it: there would be no single name left to read. That combination raises at construction.

mode RelationMode | str

"replace" matches incoming to existing, creates new, updates matched, and removes orphans (existing children absent from the incoming set); "merge" upserts only and never removes.

orphan RelationOrphan | str

What removing an orphan does, where mode says whether one is removed at all. "auto" derives it from the schema: unlinked (its fk set to None) when the FK is nullable, else deleted, mirroring on_delete=SET_NULL vs CASCADE. "unlink" and "delete" say it outright, for a spec that means one of them rather than whichever the column happens to allow — a later migration adding null=True would otherwise turn a destructive "replace" into a non-destructive one with nothing in the spec changing. "unlink" against a non-nullable FK raises ImproperlyConfigured when the relation is written, since there is no link to blank. The same rule governs the delete cascade (delete_relations), which disposes of the same rows.

field_map dict[str, str] | None

Forwarded to the per-child create_from_input / update_from_input call, exactly as for the parent. It shapes that write and nothing else: matching, the primary-key guard and the parent link all read the row exactly as it arrived, so renaming a key here does not change which row the payload matches.

exclude_fields list[str] | None

Forwarded to the per-child call, as field_map is. Excluding the match_key does not stop the row matching on it, and a matched row's primary key is dropped from the write for you, so there is no need to name it here.

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

The child's own many-to-many assignments — the per-child analogue of the helpers' m2m=, and like it, rows that already exist. A static mapping ({"tags": [tag1, tag2]}) gives every child the same; a callable (child_row) -> mapping derives them from each incoming row.

relations Mapping[str, RelationSpec] | None

The child's own relations, of any kind — a {relation_name: RelationSpec} map applied to each child row exactly as the top-level relations= is applied to the parent, so a ChildSpec here writes grandchildren. Recursion follows the declared tree, so depth is bounded by how deeply you nest specs.

create_service Callable[..., Any] | None

Per-row service replacing the default mutation-helper call, for a child whose write has real behaviour (side effects, derived columns, events, an external call). Called as create_service(*, data, parent, **extras), where data is the incoming row with the fk already pointing at parent, since linking the child is reconciliation. Must return the created row; the loop reads its pk for the delta.

update_service Callable[..., Any] | None

The same for updates, called as update_service(*, instance, data, parent, **extras). Returning None means "use the in-memory instance", the helpers' convention.

delete_service Callable[..., Any] | None

Called as delete_service(*, instance, parent, **extras), replacing the unlink-or-delete rule for that row — both for orphan removal and for the delete cascade (delete_relations). The loop can no longer tell an unlink from a delete, so the pk is reported under ChildCollectionChange.removed rather than guessed into one of the two. It is the disposal, so declaring it beside an explicit orphan raises at construction: the flag would decide nothing.

ChildCollectionChange

ChildCollectionChange dataclass

What a nested write did to one relation that holds a collection.

Carried in ChangeResult.children, one entry per collection declared in relations=: a reverse-FK ChildSpec, a GenericRelationSpec or a ManyToManySpec. A relation holding one row reports a RelatedObjectChange instead. The tuples hold row primary keys:

  • created — rows inserted.
  • updated — rows that already existed, matched by the spec's match_key and written: among the parent's own rows for a child or generic collection, inside scope for a many-to-many.
  • deleted — rows the relation let go and deleted.
  • unlinked — rows the relation let go and kept, with their link to the parent set to None.
  • removed — rows handed to the spec's delete_service. Deliberately a fifth tuple rather than a reuse of deleted: once a service owns the row, the loop no longer knows whether it was deleted, archived, unlinked or left standing, and folding those into deleted would report a guess as fact. What the loop does know is that the row left the relation and a service decided the rest.

Whether a row let go is deleted or unlinked is the spec's orphan (RelationOrphan), not the column alone: "delete" always deletes; "unlink" always unlinks, and is refused where the link cannot hold NULL; "auto", the default, unlinks when it can — both columns, for a generic relation — and deletes when it cannot. A many-to-many target is shared, so a member "replace" drops only loses its membership and is always unlinked; that kind has no orphan and no delete_service, and its deleted and removed stay empty.

updated records every matched row the write ran through update_from_input or the spec's update_service, whether or not that row's own columns actually changed.

RelationSpec

RelationSpec

What the nested-write driver needs from a relation spec: its phase.

The relations={name: spec} mapping of the mutation helpers takes one spec per relation, and the kinds differ in almost everything — a forward foreign key has no orphans, a reverse one-to-one has no collection, a child collection has both. What they share is that the driver must know when to write each one, and that answer belongs to the class rather than to the instance: every forward foreign key is written before the parent's save() because it is a forward foreign key, not because a particular spec asked to be.

So each concrete spec declares write_phase as a class attribute and the driver orders by it (see RelationPhase for the sequence). A spec author never sets it per-instance, and the mapping's insertion order cannot reorder the phases — only the relations within one.

Subclasses are frozen dataclasses; this base deliberately declares no fields, so each kind spells out its own and no kind inherits a knob that means nothing for it. Each takes its required fields positionally and every option by keyword only, so ChildSpec(Book, "author", mode="merge") names what it sets and an option added to a kind moves no other argument.

RelationPhase

RelationPhase

Bases: IntEnum

The slot in the write sequence a relation kind belongs to.

A nested write cannot honour the order the relations= mapping happens to be spelled in: a forward foreign key has to be resolved before the parent row exists, and a many-to-many has to be assigned after it does. So the order is a property of the relation kind — each spec class declares its phase as a write_phase class attribute (see RelationSpec) and the driver walks the phases in this enum's order, never the mapping's:

  1. FORWARD — the FK column lives on the parent, so the target row must exist before the parent is saved. Writing it first also means the assignment is an ordinary column change, picked up by the same diff and update_fields machinery as any other field.
  2. the parent's save() — not a phase; the boundary the phases are named around.
  3. REVERSE — the FK column lives on the other row (reverse FK collections and reverse one-to-ones), so the parent must have a primary key to point at.
  4. GENERIC — generic relations, which need the saved parent's content type and primary key.
  5. M2M — through-table writes, which need both rows saved.

Declaration order still decides everything the phases leave open: two relations in the same phase are written in the order they were declared.

The member values order the phases and nothing else; compare and sort by the members, never by the numbers.

RelationOrphan

RelationOrphan

Bases: str, Enum

How a relation disposes of a row it no longer holds.

Shared by the kinds that own their rows — a reverse-FK collection, a generic relation, a reverse one-to-one — so the word means the same thing on all three. It answers what happens to a row that is let go; RelationMode answers when a row is let go at all, and the two are independent knobs on purpose.

The default derives the answer from the schema. The other two exist because a derived answer is not a stated one: whether the link can hold NULL is a fact about a column, and a migration adding null=True later would silently turn a replace that deleted into one that unlinks, with nothing in the spec — or in its tests — changing to say so. A spec that means to delete says so.

Inheriting from str keeps the value JSON-serializable and means a plain string works wherever the member does, matching RelationMode and RelationOutcome.

AUTO class-attribute instance-attribute

AUTO = 'auto'

Derive it from the link: unlink when it can hold NULL, else delete.

Mirroring on_delete=SET_NULL versus CASCADE, which is the better default because it honours what the model already declares.

UNLINK = 'unlink'

Always blank the row's link to the parent and leave the row standing.

Refused when the link cannot hold NULL — there is nothing to blank, and deleting the row instead would be the opposite of what was asked.

DELETE class-attribute instance-attribute

DELETE = 'delete'

Always delete the row, whether or not its link could have been blanked.

ForwardRelationSpec

ForwardRelationSpec dataclass

Bases: RelationSpec

How to persist a forward relation — a ForeignKey or a OneToOneField declared on the parent itself.

One spec covers both: OneToOneField subclasses ForeignKey, and the column being unique changes nothing about how it is written.

Declared in relations={field_name: ForwardRelationSpec(...)} on the mutation helpers, where the name is the parent's own field (relations={"author": ...} for Post.author). The nested payload is read from data[field_name] and the resolved row is assigned to that field before the parent is saved (RelationPhase.FORWARD) — an ordinary column assignment, reported by diff_attrs and persisted by the same minimal update_fields save as any other field.

The resolved row is assigned onto the parent in memory whether or not the column moved, so a caller who read the relation before the write does not read the pre-write row back off the returned instance. A row re-matched against scope is a different Python object from the one the parent had cached, and two rows sharing a primary key are equal, so the diff correctly reports no column change and would otherwise leave the stale object behind.

The value reads three ways. Omitted leaves the relation untouched. None sets the parent's foreign key to None without removing the row it used to point at — a forward target is not owned by the parent and may be shared, so removing rows is the reverse kinds' job. A mapping writes the target row: without a match_key it creates one, with a match_key it names one, matched against scope.

A spec writes a row. To merely point the column at a row that already exists, don't declare a relation at all — pass the pk or the instance as the plain field it is.

There is no mode and no delete_service: a forward relation has no collection to reconcile and no orphans to dispose of. Clearing it is the None case, and it clears the column.

Attributes:

Name Type Description
model type[Model]

The target model class.

match_key str

The field pairing an incoming payload with an existing row (default "pk"), read off both the mapping (item[match_key]) and the queryset (filter(**{match_key: key})). It identifies a row rather than describing one, so a key matching nothing in scope raises ServiceValidationError instead of falling through to a create — unlike ChildSpec, which matches inside the parent's own manager, where a miss really does mean "a new child". The one name does two jobs — an input key on the mapping side, a model field on the queryset side — which is fine while the two agree and is why field_map may not rename anything onto the primary key while match_key matches on it: there would be no single name left to read. That combination raises at construction.

scope QuerySet[Any] | Callable[..., QuerySet[Any]] | None

The rows this caller may update — a queryset, or a callable resolved from the caller's context pool by signature (lambda user: Author.objects.filter(owner=user)), the library's usual idiom. Without it the spec is create-only, and a payload carrying a match_key raises ImproperlyConfigured rather than quietly creating a duplicate: a forward target has no owning manager to match within, so an unscoped by-key match would mean "any caller may write any row of that model by guessing a key".

field_map dict[str, str] | None

Forwarded to the target row's own create_from_input / update_from_input call, exactly as for the parent. It shapes that write and nothing else: matching and the primary-key guard both read the row exactly as it arrived, so renaming a key here does not change which row the payload matches.

exclude_fields list[str] | None

Forwarded likewise. Excluding the match_key does not stop the row matching on it, and a matched row's primary key is dropped from the write for you, so there is no need to name it here.

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

Forwarded likewise — the target's own many-to-many assignments.

relations Mapping[str, RelationSpec] | None

Forwarded likewise — the target's own relations, of any kind.

create_service Callable[..., Any] | None

Optional service replacing that call, for a target whose write has behaviour of its own. It receives data plus the caller context — but no parent: a forward target is written before the parent row exists, which is the whole point of the phase. Declaring it alongside the row-shaping fields above raises at construction, for the reason given on ChildSpec.

update_service Callable[..., Any] | None

The same, and additionally receives instance.

ReverseOneToOneSpec

ReverseOneToOneSpec dataclass

Bases: RelationSpec

How to persist a reverse one-to-one — the row that points back.

The other side of a OneToOneField: the column lives on the related row (Profile.author) and the parent (Author) reaches at most one of them through the reverse accessor. So it is the ChildSpec loop minus the collection, written in RelationPhase.REVERSE once the parent has a primary key to point at.

Declared in relations={accessor_name: ReverseOneToOneSpec(...)}, where the name is the parent's reverse accessor (relations={"profile": ...} for Author.profile). The value at data[accessor_name] reads three ways. Omitted leaves the relation untouched. None removes the existing row, if any, by the orphan rule below — unlike a forward relation, this row is the parent's, so clearing the relation has to do something about it. A mapping updates the row when the parent already has one, and creates and links one when it does not.

The existing row is found by querying fk rather than through the reverse accessor, so whatever the accessor had cached is a different Python object from the one that gets written. Each of the three cases leaves the parent agreeing with the write — pointed at the written row, or cleared where the row was removed — so the returned instance does not read a pre-write row.

There is no match_key and no scope — the parent owns at most one row here, so the relation itself is the match — and no mode: a one-row relation has no orphans beyond the None case, which is explicit.

Attributes:

Name Type Description
model type[Model]

The related model class.

fk str

The name of that model's field pointing at the parent ("author" for Profile.author). Set automatically on creation, and the field whose nullability decides unlink-versus-delete by default.

orphan RelationOrphan | str

What removing the row does, by ChildSpec's rule: "auto" (the default) derives it from fk — unlinked when that field is nullable (like on_delete=SET_NULL), deleted when it is not (like CASCADE) — while "unlink" / "delete" state it instead, and "unlink" against a non-nullable fk raises ImproperlyConfigured at write time. It covers both removals there are: the None case above and the delete cascade (delete_relations).

field_map dict[str, str] | None

Forwarded to the row's own create_from_input / update_from_input call. It shapes that write and nothing else: the row is found through fk and the parent link is written onto it raw, neither of them reading this map.

exclude_fields list[str] | None

Forwarded likewise. Shaping configures the row's write only; the row itself is found through fk, and a matched row's primary key is dropped from the write for you.

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

Forwarded likewise.

relations Mapping[str, RelationSpec] | None

Forwarded likewise — the row's own relations, of any kind.

create_service Callable[..., Any] | None

Optional service replacing that call for the row, with the contract ChildSpec states: it receives parent, and its data already carries the fk. Declaring it alongside the row-shaping fields above raises at construction.

update_service Callable[..., Any] | None

The same; returning None means "use the in-memory instance".

delete_service Callable[..., Any] | None

Replaces the unlink-or-delete rule above, so the outcome is reported as "removed" — the only thing still known — and an explicit orphan beside it raises.

GenericRelationSpec

GenericRelationSpec dataclass

Bases: RelationSpec

How to persist a GenericRelation — a collection linked by content type.

The reverse-FK collection with the foreign key replaced by a pair of columns: a ForeignKey to ContentType saying which model the row belongs to, and an id column saying which row. It reconciles exactly as ChildSpec does — matched inside the parent's own accessor, so no scope= is needed or accepted — and is written in RelationPhase.GENERIC, once the parent's save() has given it both a content type and a primary key.

Declared in relations={accessor_name: GenericRelationSpec(...)}, where the name is the GenericRelation declared on the parent (relations={"attachments": ...} for Catalog.attachments). A relation the input omits is untouched; an explicit [] in "replace" mode empties it.

This kind needs django.contrib.contenttypes in INSTALLED_APPS, and nothing else in the library does. Declaring the spec is always safe; writing one without the app installed raises ImproperlyConfigured naming the remedy.

Attributes:

Name Type Description
model type[Model]

The related model class — the one carrying the content-type and id columns, e.g. Attachment.

content_type_field str

Name of the content-type column, defaulting to Django's own "content_type".

object_id_field str

Name of the id column, defaulting to "object_id". Both mirror the GenericRelation arguments of the same name; set them when the model spells the columns differently.

match_key str

The field pairing an incoming row with an existing one (default "pk"), read inside the parent's own accessor. Read off the incoming row as an input key and off the lookup as a model field, so field_map may not rename anything onto the primary key while match_key matches on it — that combination raises at construction, on the terms ChildSpec states.

mode RelationMode | str

"replace" (the default) removes the rows the incoming set leaves out, "merge" upserts only.

orphan RelationOrphan | str

What removing a row does — ChildSpec's rule applied to the pair of link columns rather than to one, since half a link is not a state the relation has a meaning for. "auto" (the default) unlinks (both columns set to None) when both are nullable and deletes otherwise; "unlink" and "delete" state it instead of deriving it, and "unlink" raises at write time unless both columns can hold NULL. The rule also governs the delete cascade (delete_relations).

field_map dict[str, str] | None

Forwarded to the row's own create_from_input / update_from_input call. It shapes that write and nothing else: matching and the primary-key guard both read the row exactly as it arrived, so renaming a key here does not change which row the payload matches.

exclude_fields list[str] | None

Forwarded likewise. Excluding the match_key does not stop the row matching on it, and a matched row's primary key is dropped from the write for you, so there is no need to name it here.

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

Forwarded likewise.

relations Mapping[str, RelationSpec] | None

Forwarded likewise — the row's own relations, of any kind.

create_service Callable[..., Any] | None

Optional service replacing that call, with the contract ChildSpec states; its data already carries both link columns. Declaring it alongside the row-shaping fields above raises at construction.

update_service Callable[..., Any] | None

The same; returning None means "use the in-memory instance".

delete_service Callable[..., Any] | None

Replaces the unlink-or-delete rule above, so the outcome is reported as "removed" and an explicit orphan beside it raises.

ManyToManySpec

ManyToManySpec dataclass

Bases: RelationSpec

How to persist a many-to-many from nested rows rather than from keys.

Declared in relations={accessor_name: ManyToManySpec(...)}, where the name is whichever side the parent reaches the relation through — the field it declares (Post.tags) or the reverse accessor of a field declared on the other model (Tag.posts). Either works; Django hands back the same related manager.

Each row in data[accessor_name] is a payload, not a key: the target row is created or updated first, and only then is the membership written (RelationPhase.M2M, after the parent has a primary key). That is the difference from the mutation helpers' m2m= argument, which assigns rows that already exist and creates nothing. A relation named by both is refused: it would be written twice and keep whichever ran last.

An omitted relation is untouched; an explicit [] empties the membership in "replace" mode; a list of mappings writes every target and then sets the membership (or adds to it, in "merge" mode).

There is no delete_service: the row is shared, so a target dropped from the relation loses only its membership and is reported under ChildCollectionChange.unlinked, leaving deleted empty for this kind.

A many-to-many with an explicit through model is not covered. The loop writes target rows and lets Django write the through row, which cannot carry the extra columns a custom through model exists for.

Attributes:

Name Type Description
model type[Model]

The target model class.

match_key str

The field pairing an incoming payload with an existing target row (default "pk"). Read off the incoming row as an input key and off the lookup as a model field, so field_map may not rename anything onto the primary key while match_key matches on it — that combination raises at construction, on the terms ChildSpec states.

scope QuerySet[Any] | Callable[..., QuerySet[Any]] | None

The rows this caller may update, on the terms ForwardRelationSpec states: without it the spec is create-only, and a payload carrying a match_key raises ImproperlyConfigured. Matching runs against scope, not against the parent's current membership — the payload names rows to link, which is exactly the set that is not linked yet.

mode RelationMode | str

"replace" (the default) makes the incoming set authoritative and drops the members it leaves out; "merge" adds and never drops.

field_map dict[str, str] | None

Forwarded to the target row's own create_from_input / update_from_input call. It shapes that write and nothing else: matching and the primary-key guard both read the row exactly as it arrived, so renaming a key here does not change which row the payload matches.

exclude_fields list[str] | None

Forwarded likewise. Excluding the match_key does not stop the row matching on it, and a matched row's primary key is dropped from the write for you, so there is no need to name it here.

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

Forwarded likewise — the target's own many-to-many assignments, not this relation.

relations Mapping[str, RelationSpec] | None

Forwarded likewise — the target's own relations, of any kind.

create_service Callable[..., Any] | None

Optional service replacing that call for a target whose write has behaviour of its own. It receives data plus parent and the caller context, but — unlike a child collection — data carries no link to the parent: the link is a through-table row the loop writes afterwards, which a service could not write by returning one. Declaring it alongside the row-shaping fields above raises at construction, for the reason given on ChildSpec.

update_service Callable[..., Any] | None

The same, and additionally receives instance.

RelatedObjectChange

RelatedObjectChange dataclass

The delta for a relation that holds one row, not a collection.

Carried in ChangeResult.relations, one entry per singular relation declared in relations= — a forward foreign key / one-to-one, or a reverse one-to-one. ChildCollectionChange's five pk tuples cannot report a one-row relation honestly: every one of them would be either empty or a one-tuple, and "which of the five is non-empty" is a worse way to say "what happened" than saying it. So a singular relation reports one outcome and one pk.

outcome is a RelationOutcome, which documents what each value means.

pk is the primary key of the row the outcome is about, read before any delete (Django clears instance.pk afterwards), and None when no row was touched ("untouched" / "cleared").

UNSET

UNSET module-attribute

UNSET: UnsetType = UnsetType()

UnsetType

Singleton sentinel type. Always falsy; identity-equal to itself only.

Don't instantiate this directly — use the module-level UNSET singleton. UnsetType() returns that same instance, but the sentinel is the value you compare against and UnsetType is only useful as a type annotation.

NoInput

NoInput

Binds a Protocol's InputT for a service that reads no input.

Pair it with DeleteService, whose data has a default, so the service leaves data out:

@implements(DeleteService[NoInput, Note, None])
def delete_note(*, instance: Note, **extras: Any) -> None: ...

Never instantiated. It exists to fill the type variable with a name that says why it is empty, which None or Any would not.

HttpExtras

HttpExtras

Bases: TypedDict, Generic[UserT]

OfflineContext

OfflineContext dataclass

The HTTP-surrogate a spec needs when dispatched off the HTTP path.

Produced by build_offline_context. Its request / view feed the optional request= / view= arguments of dispatch_spec, and the whole value is consumed by enforce_permissions.

  • user — the acting principal (request.user is set to the same value); Any because the user model is project-defined.
  • request — the synthetic DRF Request (.user set, .data carrying the params), forwarded to spec callables that declare request.
  • view — the OfflineServiceView forwarded to callables that declare view and used as the view argument of permission checks.

InputRequired

Dispatch refuses a missing marked key as the kernel's InvalidArguments, {"code": ["This field is required."]}, wherever the key is caller input: a target lookup's, a selector's under AUTO or SPREAD_AUTHOR_WINS, and a serializer-less service's own parameters under SPREAD_AUTHOR_WINS. Under BUNDLE, which a selector view dispatches, a selector's parameters are no caller input, and under AUTO or BUNDLE neither are such a service's, so a marker on one is not enforced: a parameter nothing fills is the callable's own TypeError. A declared reads= entry is caller input under every binding. See what a caller could fill.

input_required

The InputRequired schema marker and its type.

Marks a declared input as required in the generated schema without making it required in the type system. Use it inside Annotated[...] on an extras TypedDict key (or an ordinary parameter) of a service / selector:

class WidgetExtras(HttpExtras[MyUser], total=False):
    project_pk: Annotated[int, InputRequired]

@implements(ListSelector[Widget])
def list_widgets(**extras: Unpack[WidgetExtras]) -> list[Widget]:
    return Widget.objects.filter(project_id=extras["project_pk"])

A required TypedDict key will not do: under PEP 692 it makes the function reject callers that omit it, which breaks assignability to ListSelector / RetrieveSelector / the service Protocols — the very reason HttpExtras mandates total=False. Annotated metadata carries no typing weight, so the key stays NotRequired to the type checker while spec_to_json_schema lists it in required.

The marker is advertisement plus enforcement, never delivery: it does not change the kwargs pool, the view.kwargs-over-params precedence, or the SPREAD_AUTHOR_WINS author-beats-client rule. It tells a schema-driven caller (an MCP client, an LLM tool call) that the input is mandatory, and makes dispatch_spec raise ServiceValidationError when it is absent and the caller could have sent it — instead of the bare KeyError the callable would otherwise raise from deep inside dispatch, which no transport maps to a useful error. Where no caller input reaches the callable by name, as under BUNDLE, a refusal could only be answered by the same refusal, so a marked key nothing filled fails as the callable's own error, the author's to fix.

An annotation that does not resolve at runtime does not hide the marker either. A name imported only under if TYPE_CHECKING: costs only the parameter or key it annotates; that one is read with the name standing in for Any, so Annotated[Owner, InputRequired] is still required, and where even that cannot reach the marker, an annotation whose text names InputRequired is required by that name.

Its counterpart is NotClientInput, which hides a key from the schema entirely. A key marked with both is a contradiction and raises at schema-generation time.

InputRequired is the singleton you place in the annotation; InputRequiredType is its type, exported only so it can be spelled in annotations.

InputRequiredType

Singleton marker type. Identity-equal to itself only.

Don't instantiate this directly — use the module-level InputRequired singleton. InputRequiredType() returns that same instance.

NotClientInput

not_client_input

The NotClientInput schema marker and its type.

Marks a declared input as provider-owned: it is dropped from the generated schema entirely, so a schema-driven caller never learns it exists and never supplies it. Use it inside Annotated[...] on an extras TypedDict key (or an ordinary parameter) of a service / selector:

class WidgetExtras(HttpExtras[MyUser], total=False):
    project_pk: Annotated[int, InputRequired]
    team_role: Annotated[str, NotClientInput]  # resolved by spec.kwargs

Why. Reflecting Unpack[<TypedDict>] into the input schema makes every declared key visible to an LLM / MCP client — including keys a spec.kwargs provider supplies from request state, which the caller has no business setting.

What it does. Two things, and the second is what makes the first honest:

  • It is not advertised. The key is dropped from the generated schema.
  • The caller's value never reaches the callable. A name any callable of the call marks - the selector or service, each of its preconditions, a service's instance_selector_spec / collection_selector_spec lookup - is the server's for the whole call, so the compiled spec declares no parameter of that name: dispatch_spec, adispatch_spec and the kernel's dispatch drop the caller's value under IGNORE, whatever the argument binding. An input_serializer field of that name stays the client's input, validated into data, and the client's value reaches the service only there: the parameter is not handed it by name, on either core, over HTTP or off it, nor is a precondition.

So a marked key is filled only by a channel the caller does not control: a view hook's or the spec.kwargs provider's value, a registered pool seed, input_data through an input_serializer field of that name (a HiddenField's own value where input_data names one), the parameter's own default, or, for a selector or a target lookup, a route capture (view.kwargs, or build_offline_context(kwargs=...) off HTTP). A target lookup's capture is seeded for the call, so a service parameter of the same name is handed it as well; a selector's is handed to the selector. A provider that declines with UNSET therefore leaves the default in place, never the caller's value. That moves the hazard rather than removing it: a scoping key whose default reads as "everything" must have a provider that always resolves it.

UnknownArguments.REJECT refuses a caller that supplies a marked key, since the key is no declared argument - which is exactly what it is - unless an input_serializer field declares it. Where a selector or a target lookup takes a bare **kwargs, or a service with no input_serializer takes one under SPREAD_AUTHOR_WINS, nothing can be called unknown, and the value is dropped instead.

The marker governs the callable's own parameters. A many=True service receives its rows inside one data list and their values under its many_argument, never by field, so a row's field of a marked name stays in its row and the parameter is not filled from it.

A value input_data supplies for a marked key is the server's, not the caller's. input_data (with the get_input_data view hooks) is the spec author's own code, merged over the caller's input with its keys winning, so where the caller sends the same key the server's value is the one that arrives. The kernel refuses input_data on a spec with no input_serializer, so it reaches the service through a field of that name: validated, in data and by name. A key no field declares is no argument: IGNORE drops it and REJECT, which judges the merged arguments, refuses it, as it refuses any input_data key nothing declares.

An annotation that does not resolve at runtime does not hide the marker. A name imported only under if TYPE_CHECKING: costs only the parameter or key it annotates, and every other one is read as written. The one it annotates is read with that name standing in for Any, so Annotated[Owner, NotClientInput] | None is hidden; where even that cannot reach the marker (models.Owner, or a marker inside a quoted string), an annotation whose text names NotClientInput is hidden by that name. A **kwargs: Unpack[Extras] whose TypedDict itself does not resolve hides nothing, because nothing says which keys it has: UnknownArguments.REJECT refuses to run on it, and the other policies deliver what the caller sent.

Its counterpart is InputRequired, which marks a key mandatory. A key marked with both is a contradiction and raises at schema-generation time.

NotClientInput is the singleton you place in the annotation. NotClientInputType is its type, exported only so the singleton can be spelled in annotations; you never need to instantiate it.

NotClientInputType

Singleton marker type. Identity-equal to itself only.

Don't instantiate this directly — use the module-level NotClientInput singleton. NotClientInputType() returns that same instance.

InputDescription

input_description

The InputDescription schema marker.

Carries prose for a reflected input — one the schema derives from a callable's annotations rather than from a serializer field. Use it inside Annotated[...] on an extras TypedDict key (or an ordinary parameter) of a service / selector:

class WidgetExtras(HttpExtras[MyUser], total=False):
    project_pk: Annotated[
        int, InputRequired, InputDescription("The project whose widgets to list.")
    ]

@implements(ListSelector[Widget])
def list_widgets(**extras: Unpack[WidgetExtras]) -> list[Widget]:
    return Widget.objects.filter(project_id=extras["project_pk"])

The text lands in the generated schema as description — the same key the serializer path fills from a field's help_text and the filter path from a filter's, so one project's inputs read the same way whichever side of a spec they arrive on.

Why a marker was needed at all. The other two markers, InputRequired and NotClientInput, are singletons: __new__ returns the one instance, so neither can carry per-field text even in principle. Without a third marker a reflected key reaches a schema-driven caller as a bare typed property, and the only way to say what it means was to declare the same input twice — once in the TypedDict the callable actually reads and once in a serializer written for one transport to describe it with. Two declarations of one input drift, and the second one is the one nothing executes.

Why not typing_extensions.Doc. It is importable on every supported version, so this is not a dependency question. PEP 727 was withdrawn, which makes Doc a marker with no standing standard behind it: nothing obliges it to keep its meaning, and a kernel whose public surface is built on it inherits that.

Why not a spec-level extras_descriptions={...} mapping. Keying prose by input name puts the name in two places and lets them disagree the day one is renamed — the desync FieldMarking's own docstring gives as the reason markings live on the field. A marker in the Annotated position cannot drift from what it describes, because it is attached to it.

The marker is advertisement-only: it changes nothing about delivery, requiredness, the kwargs pool, or the SPREAD_AUTHOR_WINS precedence. It composes with InputRequired in one Annotated, in either order, and with foreign metadata from other libraries. Two of them on one input is refused, and so is one beside NotClientInput — a key dropped from the schema has nowhere for the text to land, so the declaration would decide nothing. Both are read by read_input_description.

InputDescription dataclass

Prose for one reflected input, published as the schema's description.

Named for the schema key it produces rather than for DRF's help_text, which is the nearest neighbour: help_text is a Field kwarg carrying form rendering and browsable-API behaviour with it, and a reflected extras key has no field to carry any of that. Borrowing the word would promise behaviour this does not have. InputDescription instead sits with the Input* markers it composes with, and with the description= argument UrlKwarg and QueryParam already take for exactly this — the same word for the same job, whether the input is declared on the callable or registered by an adapter.

Unlike the other two markers this is not a singleton: it carries text, so each declaration is its own value. Two instances with the same text compare equal, which is what a frozen dataclass gives and what a reader would expect; identity is never what reads it.

Attributes:

Name Type Description
text str

The sentence published as description. Blank or whitespace-only is refused at construction — a marker that says nothing is a declaration with no effect, and emitting "description": "" spends tokens on every listing to publish an absence.

read_input_description

read_input_description

read_input_description(annotation: Any) -> str | None

Return the InputDescription text on a possibly-Annotated annotation.

None when there is none, which is the overwhelmingly common case: a plain annotation, or an Annotated carrying only the other markers or another library's metadata.

Separate from read_schema_markers rather than a fourth element of its tuple, because that function's shape is public and every caller unpacks it positionally; widening it would break each one for no gain. The two are read side by side wherever both matter.

Two refusals, both because the alternative decides nothing silently:

  • Two descriptions on one input. Picking the first or the last would be an arbitrary rule a reader has to know, and the schema can publish only one.
  • A description beside NotClientInput. That marker drops the key from the schema entirely, so the text has nowhere to land. Unlike the InputRequired / NotClientInput pair this is not a contradiction — it is merely useless — but a declaration that is silently ignored is exactly what this package refuses elsewhere, and the fix (delete one of the two markers) is obvious once it is named.

Placed by the rule read_schema_markers states: on the annotation itself or inside one Optional around it (Annotated[int, InputDescription("...")] | None), and refused anywhere deeper. That function is called first, so it is the one that refuses, and the two readers cannot disagree about placement.

UrlKwarg

UrlKwarg dataclass

A URL route capture exposed as a caller-supplied argument off-HTTP.

Over HTTP a nested route's captures (the project_pk of /projects/{project_pk}/widgets/) reach a spec through view.kwargs — directly, or through a spec.kwargs provider that scopes by them. Off-HTTP there is no route, so the caller supplies the value as an ordinary argument: the transport advertises it in the tool / operation schema, pops it out of the arguments, and hands it to build_offline_context(kwargs=…), from where dispatch_spec spreads it into the selector / target pools — authoritative over the spec params, below a spec.kwargs provider. It never reaches the spec as an ordinary input, so the unknown-argument policy never flags it. Adapters import this declaration and pair it with validate_channel_names.

Reach for one when the value is a URL-derived input a spec depends on that is not already an ordinary argument — most commonly a scoping spec.kwargs provider reading view.kwargs (off-HTTP that mapping is otherwise empty, so the provider mis-scopes for every caller), or a closed-surface spec whose route capture must be caller-suppliable. A selector reading the value from its own **extras: Unpack[TypedDict] needs no UrlKwarg: drf-services reflects the key into the schema and params delivers it. A key can be both reflected and registered — the explicit UrlKwarg wins the adapter's schema merge, registration pops the argument into kwargs=, and the authoritative spread still delivers it to the selector pool, so both readers see it.

An explicit null from the caller is not a supplied value. A route capture is a URL segment, so over HTTP there is no value that means null; off-HTTP, {"project_pk": null} is what a model emits for a capture it could not resolve. A transport treats that as an omitted argument — required still refuses it and default still applies — rather than spreading None onto view.kwargs, where a scoping provider would turn it into a silent IS NULL lookup that returns rows and looks successful.

Attributes:

Name Type Description
name str

The argument / view-kwarg key. Must not collide with a reserved transport key; see validate_channel_names.

type str

The JSON-Schema type advertised to the caller — "string" by default, or "integer" / "number" / "boolean" …

description str | None

Optional help text shown to the caller.

default Any

The value a transport that seeds defaults supplies when the caller omits the argument (drfs reports it and does not fill it in); also surfaced as the schema default. Left at UNSET there is no default and the schema carries no default key; default=None is a real declaration ("defaults to null") and is surfaced like any other value. Read it with is not UNSET, never with a truthiness or is not None test.

required bool

Advertise the key in the schema's required list. Use it for a route capture the spec genuinely cannot run without, so a caller is told up front instead of failing mid-dispatch. Setting both required and a default is contradictory and raises in validate_channel_names. It is the registered-declaration counterpart of InputRequired, which does the same job for a key the callable's own TypedDict declares; both end up in the schema's required. required=False, the default, does not make optional a parameter of the callable that has no default or is marked InputRequired: in the compiled parameters (kernel_spec(spec).parameters()) and the kernel's input schema (spec_input_schema) that parameter is required whatever the read says, as it is with no read declared, and only a default here, or on the parameter, makes it optional there. spec_to_json_schema is not changed by this.

json_schema

json_schema() -> dict[str, Any]

The JSON-Schema property this kwarg contributes to an input schema.

default is emitted whenever one was declared — UNSET is the "no default" sentinel, so an explicit default=None reaches the schema as "default": None instead of vanishing.

QueryParam

A QueryParam is one of the spec's arguments, and it is either a read or a shape once the spec compiles. A read is handed by name to a callable of the call. A shape shapes what is rendered, as a serializer reading ?fields= does, so the kernel hands it to the presenter alone, on PresentationContext.shapes, and to no callable. A top-level QueryParam that no callable of the call takes by name is a shape. The callables that count are:

  • the service or selector, by a named parameter or an Unpack-ed TypedDict key;
  • a precondition of either;
  • a target lookup's selector, and its kwargs provider;
  • the spec's kwargs and input_data providers, by a named parameter, or by a key a typed one is annotated to fill;
  • any of them taking a bare **kwargs, which takes every name, so one such callable makes every QueryParam of the spec a read;
  • the fields of a selector spec's filter_set.

A QueryParam declared on a nested spec (a target lookup's own reads) stays a read, and a UrlKwarg is never a shape.

A shape is still one of the call's parameters, so kernel_spec(spec).parameters() lists it and the caller's value is checked as declared; it is left out of kernel_spec(spec).reads and listed in kernel_spec(spec).shapes. Code that walked .reads to find a spec's query arguments reads .shapes for these, or .parameters() for both. A permission class still reads a shape off request.query_params, as it reads any declared QueryParam.

Because a shape may drop any key the serializer renders, the compiled output declares no field present under every value a caller may send: the SerializerPresenter a spec with shapes compiles is built with shaped=True, which marks no output field always_present at any depth. The flag is the presenter's own, with the default unchanged (see SerializerPresenter), and a presenter built by hand for a spec whose output a caller's argument shapes passes it itself. A QueryParam declares no choices, so no key can be named as present under all of them.

QueryParam dataclass

A request-level query param exposed as a caller-supplied argument off-HTTP.

Generalizes the built-in page / limit / order list-selector arguments to any read-shaping param a serializer reads off request.query_params — django-restql field selection (?query= / ?fields=), or a custom serializer that branches on the query string.

Declared in a spec's reads, it is one of the spec's parameters, of its type and with its default: the caller's value is checked as declared, handed by name to a selector, service or target lookup that takes the name, and never an unexpected argument. Over HTTP it is read from the request's query string alone, so a body value of that name is dropped; off HTTP it is the caller's argument, and is put onto the request's query_params where they lack it, so a serializer or provider reading them finds it.

A SelectorSpec filter_set does not need this — its fields are already generated into the schema and flow through as ordinary params.

Declared here rather than in each adapter for the same reason as UrlKwarg: it is the same declaration whichever transport carries it. Pair it with validate_channel_names.

  • name — the argument / query-string key. Must not collide with a reserved transport key; see validate_channel_names.
  • type — the JSON-Schema type advertised to the caller ("string" by default; "integer" / "number" / "boolean" / "array" …).
  • description — optional help text shown to the caller.
  • default — the value a transport that seeds defaults supplies when the caller omits the argument (drfs reports it and does not fill it in); also surfaced as the schema default. Left at UNSET there is no default, and the schema carries no default key; default=None is a real declaration ("defaults to null") and is surfaced like any other value. Read it with is not UNSET, never with a truthiness or is not None test.

An explicit null from the caller is not a supplied value. Over HTTP a query param is always a string, so there is no value a caller can send that means null; off-HTTP, {"fields": null} is the shape a model emits for a param it chose not to fill. A transport treats it as an omitted argument — the default still applies — rather than routing None onto request.query_params.

No required flag, deliberately. A query param is read-shaping — omitting one is legitimate by construction, and the spec runs correctly without it. Requiredness belongs to inputs the spec cannot run without, which is UrlKwarg and the InputRequired marker. In the compiled parameters (kernel_spec(spec).parameters()) and the kernel's input schema (spec_input_schema), a param naming a parameter with no default of a callable of the call (the selector, a precondition, the service, a target lookup), or one marked InputRequired, is still required, as that parameter is: the read declares where the value comes from, not that the callable can run without it. Only a default here, or on the parameter, makes it optional there. spec_to_json_schema is not changed by this.

json_schema

json_schema() -> dict[str, Any]

The JSON-Schema property this param contributes to an input schema.

default is emitted whenever one was declared — UNSET is the "no default" sentinel, so an explicit default=None reaches the schema as "default": None instead of vanishing.

validate_channel_names

validate_channel_names

validate_channel_names(
    *,
    label: str,
    kind: str,
    declarations: Sequence[_ChannelDeclaration],
    reserved: frozenset[str] = frozenset(),
) -> None

Raise ImproperlyConfigured on a bad channel declaration set.

A UrlKwarg / QueryParam is popped out of the caller's arguments and routed to a side channel, so its name must not collide with a key the transport controls, and must not be declared twice.

Three failure modes, all caught at registration time rather than on a call:

  • Reserved-name collision. RESERVED_POOL_SEEDS is always included — those are the dispatcher's authoritative seeds, and letting a caller route a value onto one is the spoofing footgun the spread modes strip. reserved adds the transport's own keys on top; pass the pagination names the transport reserves (page / limit and whichever of order / ordering it uses), since those genuinely differ per transport while the seed set does not.
  • Duplicate names within the set — the later declaration would silently shadow the earlier one.
  • required together with a default — contradictory: a default means the argument can always be satisfied without the caller, so demanding it is either a no-op or a lie. Only checked on declarations that carry a required attribute (QueryParam deliberately has none). "Has a default" is default is not UNSET: None is a declarable default ("defaults to null"), so pairing it with required is contradictory too.

Adapters should call this once per tool / operation, with label identifying the offending registration site and kind naming the parameter the consumer passed ("url_kwargs", "query_params"), so the message points at something the consumer can act on.

ProgressReporter

ProgressReporter

Bases: Protocol

How a long-running service reports how far it has got.

A dispatched callable receives one under the reserved pool seed progress, exactly as it receives user - declare the parameter and it arrives:

def export_invoices(*, data, progress: ProgressReporter):
    rows = list(build_rows(data))
    for index, row in enumerate(rows):
        write(row)
        progress(index + 1, total=len(rows), message="writing rows")

Reporting is always safe and never required. A transport with somewhere to send progress passes its reporter to dispatch(progress=...); one with nowhere to send it passes nothing, and the pool carries null_progress instead. So a service that declares the parameter runs unchanged on every transport and in tests. The call takes four arguments:

  • progress - how far along, in whatever unit the service chose. It must increase across calls within one dispatch; a transport that forwards it is entitled to treat a decrease as a bug.
  • total - the denominator, when it is known. Omit it rather than guessing: a receiver renders an indeterminate bar for a missing total and a wrong percentage for a wrong one.
  • message - a short human-readable status. For a person watching, not for a machine to parse.
  • meta - structured detail about this update (which stage, which file, how many rows have failed), so that structure need not be stringified into message and parsed back out at the far end.

meta is the part a receiver may not understand, and each decides for itself: a websocket consumer forwards it into the frame the UI renders; a receiver with nowhere to put it drops it. Never encode something the operation's correctness depends on - it is telemetry, not a channel. And namespace the keys if the far end might be MCP: a progress notification carries the structure under the protocol's _meta, whose key-naming rules reserve unprefixed names and anything under a modelcontextprotocol / mcp prefix, so {"com.example/stage": ...} is safe and {"stage": ...} is not portable.

Implementations must not raise. A reporter is called from inside domain code that has no reason to defend against it, and dispatch hands the caller's reporter over as it is, so a transport failing mid-report would take the service run down with it.

RESERVED_POOL_SEEDS

reserved_pool_seeds

RESERVED_POOL_SEEDS is django-service-specs' own, re-exported at drfs' path.

RESERVED_POOL_SEEDS module-attribute

RESERVED_POOL_SEEDS: Final[frozenset[str]] = frozenset(
    {
        "user",
        "data",
        "instance",
        "collection",
        "result",
        "queryset",
        "progress",
        "call_context",
    }
)

The names dispatch seeds into a callable's keyword pool, which no argument may use.

user is the principal, progress the caller's progress reporter, data the validated values, instance and collection the resolved target, result the service's return (in an output selector's pool, where instance is that return too, as djangorestframework-services binds it) and queryset what extend_queryset shapes. call_context is the call's context: the one plain dict the transport handed dispatch, or a fresh one per call when it handed none, which every callable dispatch resolves - a kwargs or input_data provider, a selector and its extend_queryset, an affordance condition, a precondition, the service, the run's progress_reporter - receives as the same object by naming it, and never through **kwargs. Never a copy, and the kernel writes nothing to it. Seed resolvers do not see it: a seed is resolved before anything the call adds. The other seven are the names djangorestframework-services seeds for the same values, so a callable written for it runs under this kernel unchanged.

progress is in every pool, and live only where there is work to report on: a service's run, and a selector spec's own selector. In a target or output selector's pool it is null_progress, because a lookup has nothing to report, and one reporting after the service finished would read to a watching client as the work restarting.

A parameter declaring one of these names is refused, because a spread argument must never be able to outrank the value dispatch seeded - a caller naming itself user is the case that matters. request and view are not here: they are an HTTP adapter's, and it registers them through PoolSeeds, which reserves them the same way.

PoolSeeds

pool_seeds

DEFAULT_POOL_SEEDS, PoolSeeds are django-service-specs' own, re-exported at drfs' path.

DEFAULT_POOL_SEEDS module-attribute

DEFAULT_POOL_SEEDS: PoolSeeds = PoolSeeds()

The empty registry every dispatch entry point falls back to.

A project that registers nothing gets the pool dispatch builds on its own: the principal as user, a reporter as progress, and each step's own values (data, instance, collection, result, queryset) where that step has them.

PoolSeeds dataclass

Names a project adds to every dispatched callable's kwargs pool.

The names in RESERVED_POOL_SEEDS are the dispatcher's own. Everything else a service legitimately needs — a tenant, a correlation id, a locale, a clock, a feature-flag reader — has no channel, because off HTTP there is no request for it to hang off. This is that channel.

A registration contributes two things:

  • the value, resolved into every pool base_pool builds;
  • reservation: dispatch refuses a spec whose Parameters declare the name, and a Validator that returns a value under it, so an argument never meets a seed in one pool. A caller sending the name is then refused as an unknown argument, or dropped under UnknownArguments.IGNORE, like any other name the spec does not declare.

The reason the two travel together is that either alone is a trap. A seed with a value and no reservation shares the pool with an argument of the same name, and which of the two a callable receives would depend on spreading order; a reserved name with no value makes a callable that declares it fail with a TypeError instead.

Immutable, and extend returns a new registry rather than mutating, so there is no shared global state to leak between mounts or across tests. Pass one per dispatch rather than installing it process-wide: a project that mounts two operations with different ambient context needs them to differ, and a module-level default cannot.

seeds = DEFAULT_POOL_SEEDS.extend(tenant=lambda *, user: user.tenant)
dispatch(spec, principal=user, arguments=arguments, pool_seeds=seeds)

A resolver is called through the same declare-to-receive rule as every other spec callable: it names the pool entries it wants (user, an adapter's own entry) and receives only those, or takes **kwargs for all of them.

A resolver is sync-only. A seed is a resolver - a callable whose return is the value - and resolvers are called from sync code and never awaited. An async def one would seed a coroutine, which is truthy, so an affordance condition or a precondition reading the seed would read an answer nobody computed. One is refused when the registry is built, with ImproperlyConfigured; a plain def handing back an awaitable is refused where it is resolved, by base_pool.

Attributes:

Name Type Description
seeds tuple[_Seed, ...]

The registrations, in registration order.

names property

names: frozenset[str]

The registered names, without their resolvers.

reserved property

reserved: frozenset[str]

Every name an argument may not occupy: the dispatcher's plus these.

Dispatch reads this rather than RESERVED_POOL_SEEDS directly, which is what makes a registered seed as protected as a built-in one.

extend

extend(**seeds: Callable[..., Any]) -> PoolSeeds

Return a new registry with seeds appended.

Raises:

Type Description
ImproperlyConfigured

A resolver is async - an async def, an object whose __call__ is one, or an async generator function. A resolver is never awaited.

ValueError

A name is one of the dispatcher's own, or is already registered here. Both are refused at registration rather than at dispatch, because a silent last-wins is the exact failure the reservation exists to prevent — and a collision discovered on the hot path is one a test suite can miss.

resolvers

resolvers() -> Mapping[str, Callable[..., Any]]

The registrations as a mapping, for a caller that resolves them.

Resolution itself lives in base_pool rather than here: it needs the declare-to-receive helper (resolve_callable_kwargs), and this module stays a plain value carrier so nothing in pool/ depends on it in a cycle.

FieldAudience

FieldAudience

Bases: str, Enum

Whether an output field is content, a name, a handle, or plumbing.

An operation's output is often read by more than one kind of consumer: a frontend or a command that decides its own presentation, and a model that will read the payload aloud unless told otherwise. The two want different subsets of the same fields, and the difference is not a transport difference - an MCP server and an in-process toolset want the same thing as each other and something different from a browser.

So the axis this names is audience, not protocol. Declared per field through FieldMarking, on the field's OutputField, and applied only for a caller that names an audience; one that names none keeps presenting every field.

Inheriting from str keeps the value JSON-serializable and print-friendly while still behaving as a proper enum for is / ==.

CONTENT class-attribute instance-attribute

CONTENT = 'content'

The default: ordinary data, shown to every consumer.

LABEL class-attribute instance-attribute

LABEL = 'label'

The field that names this record for a human. At most one per output, and two are refused when the output is read into a projection.

HANDLE class-attribute instance-attribute

HANDLE = 'handle'

An opaque identifier. Passed to other tools, never read out to a user, and never re-spelled by a choice's display - a handle is somebody else's input.

HIDDEN class-attribute instance-attribute

HIDDEN = 'hidden'

Plumbing. Dropped from the projected payload and from the projected schema.

MARKING

MARKING module-attribute

MARKING: Final = 'drf_marking'

Key under which an :class:FieldMarking is declared in a DRF field's style.

Namespaced rather than bare ("agent") because style is a shared bag any library may write to. The namespacing is courtesy, not correctness: readers match on the value being an FieldMarking, so a marking under another key still takes effect and another library's data under MARKING is refused loudly rather than silently misread.

FieldMarking

FieldMarking dataclass

How one output field is presented to an agent audience.

Carried on the OutputField it marks, so it belongs to the declaration of what an operation returns rather than to whichever library rendered it. That is what lets a transport's projection for an audience work over a dataclass, a pydantic model and a DRF serializer alike: each adapter reads its own library's declaration into Output, marking included, and the projection reads only Output.

Applied only for a caller that names an audience: audience_projection_for_spec reads the markings into an AudienceProjection, which present_for_audience applies to the presented value and spec_output_schema to the schema. A caller that names none, which is every HTTP response, is presented every field whatever its marking.

The marking lives on the field, not in a list beside the output. That is what lets it travel into a nested output with no hoisting rule, and what stops a rename from silently desyncing it from a name the parent maintains.

description class-attribute instance-attribute

description: str | None = None

Audience-facing description, replacing the field's help text for this audience only.

Help text is shared with every human reader, so it cannot say "opaque handle, never read this out". This can, without changing a word of what a human reader sees.

formatter class-attribute instance-attribute

formatter: ValueFormatter | None = None

How this field's value is rendered for an agent audience, if not verbatim.

A ValueFormatter transforms the value and declares the JSON type it produces, so the payload and the schema move together. Unset, the default, changes nothing.

An explicit formatter wins over the substitution of a choice's display, which is a real collision: a status field can have choices and still be something an author wants spelled their own way. Only one transform can apply, and the one written by hand is the one that was asked for; a derived default losing to an explicit declaration is the ordinary direction.

HANDLE suppresses it, exactly as it suppresses choice substitution: a handle is another tool's input, and a formatted machine identifier is a broken one. Declaring both is honoured as HANDLE and the formatter never runs. A field a second tool takes as input therefore wants handle, or that tool receives a display string its own input schema rejects.

handle classmethod

handle(description: str | None = None) -> FieldMarking

An opaque identifier: passed to other tools, never spoken to a user.

hidden classmethod

hidden() -> FieldMarking

Plumbing: dropped from the projected payload and the projected schema.

label classmethod

label(description: str | None = None) -> FieldMarking

The field that names this record for a human.

formatted classmethod

formatted(formatter: ValueFormatter, description: str | None = None) -> FieldMarking

Ordinary content, rendered through formatter.

The generic constructor: any transform that declares what it produces. timestamp is one of these with the formatter filled in, and a field that is both formatted and something else - a formatted label, say - is written as FieldMarking(FieldAudience.LABEL, formatter=...).

timestamp classmethod

timestamp(fmt: str | None = None, description: str | None = None) -> FieldMarking

A date-time read as a formatted local string rather than raw ISO-8601.

due_at: Annotated[datetime, FieldMarking.timestamp()]

The zone is Django's active one and cannot be passed here; fmt is a strftime string and defaults to a day-first, 24-hour rendering. ValueFormatter.timestamp holds the transform and the reasoning behind both of those.

ValueFormatter

ValueFormatter dataclass

One field's value, transformed for an agent audience, plus what that yields.

Attached to a FieldMarking and applied by the same walk that drops hidden fields and speaks choice displays, on both sides at once: project_payload gives the payload render's result, and annotate_output_schema gives the schema produces and schema, from this one declaration.

This is generic on purpose. A formatted local timestamp is the case that asked for it, and building that alone would have left two hard-coded, type-specific value transforms where there was one. Money with its currency, a duration, a percentage and a quantity with its unit are all visible from this same spot; adding them one at a time is how a small marking type becomes a switch statement. timestamp is a named constructor over the mechanism rather than a branch inside it.

money = ValueFormatter(
    lambda amount: f"EUR {amount}",
    produces="string",
    schema={"examples": ["EUR 1240.00"]},
)

The declaration carries what it produces, and the kernel writes that into the schema. Choice substitution cannot lie, because both sides are derived from the same choices; a caller-supplied render can, so the type is declared next to it rather than inferred or left to a fragment. schema merges over the written type for description, examples or format, and is refused if it names type: a formatter that could contradict its own advertisement would put the divergence between schema and payload that the projection exists to prevent back inside a single declaration.

Naming the type without describing the string it produces was the other rejected shape: what a formatted field looks like is most of what makes it discoverable, so both halves are here.

Nothing checks render's result when it runs. produces is a promise the author makes once, not a per-value assertion: the guarantee is that a renderer cannot advertise one thing and declare another, not that a misdeclared renderer is caught mid-call.

Raises:

Type Description
ImproperlyConfigured

produces is not one of JSON's scalar types, or schema names type.

render instance-attribute

render: Callable[[Any], Any]

The transform, applied to the presented value.

Called with what the presenter returned, not with a model attribute: the projection runs after presenting, so a date-time a DataclassPresenter presented arrives as its ISO-8601 string, and a decimal as its string. Never called with None; see apply.

produces instance-attribute

produces: Literal['string', 'number', 'integer', 'boolean']

The JSON type render returns. Written into the schema by the kernel.

schema class-attribute instance-attribute

schema: Mapping[str, Any] | None = None

Extra JSON Schema keywords, merged over the written type.

For saying what the produced value looks like: description, examples, format. May not contain type.

apply

apply(value: Any) -> Any

render(value), except that None passes straight through.

A null is the absence of the value the formatter formats, and every transform would otherwise have to repeat the same guard: a nullable field is ordinary, and strftime on None raises. The schema keeps the null on its side as well, so a nullable field stays nullable once formatted.

json_schema

json_schema() -> dict[str, Any]

The declared type, with schema merged over it.

type is written last on purpose. __post_init__ already refuses a fragment that names it, so this can never be the thing that decides, and that is the point: the guarantee is structural as well as validated, rather than resting on a check somebody may one day relax.

timestamp classmethod

timestamp(fmt: str | None = None) -> ValueFormatter

A date-time as a formatted local string, with an example of the shape.

OutputField(
    "due_at",
    "string",
    format="date-time",
    marking=FieldMarking(formatter=ValueFormatter.timestamp()),
)

The zone is Django's active one, and there is no way to pass another. It is what a per-tenant middleware calling timezone.activate() is already for, and a worker serving an agent activates the zone itself.

A callable zone is not merely unsupported, it is impossible. A projection is built once, where a transport registers the spec, by audience_projection_for_spec, and the schema it advertises is described then, before any request exists to describe. A per-request callable would therefore resolve differently on the two sides, which is the divergence between schema and payload that the projection exists to prevent. The schema says a formatted string and never names a zone, and that is what keeps it honest.

fmt is a strftime format string and defaults to a day-first, 24-hour rendering. The example in the schema is rendered from it, so it cannot drift from what the field actually carries.

A bare date string is read as midnight, so a date can be formatted too: give it a fmt without a time, or the rendering invents one. A naive date-time, as a project with USE_TZ = False has, is already local and is formatted as it is. A value that is not a date-time, and a string that does not read as one, passes through unchanged, for the same reason an unrecognised choice value does: a stale or oddly-typed row should still be reported rather than fail the call. So is a string shaped like a date-time that names no real moment, the thirtieth of February say, which Django's parser refuses with a ValueError: paging would otherwise turn one row's bad data into a failed page. Declaring this on a field that never carries a date is a misdeclaration nothing here can detect.

AudienceProjection

AudienceProjection dataclass

How one operation's output is shaped for an agent audience.

Read off the FieldMarkings and the choices an Output declares, by audience_projection_for_spec. Nothing here depends on the value being presented, so a transport builds it once, where it registers the spec, and passes it to every present_for_audience and spec_output_schema rather than reading the declaration again per call.

One projection drives both project_payload and annotate_output_schema, which is what keeps the advertised schema describing the payload sent.

fields class-attribute instance-attribute

fields: Mapping[str, FieldMarking] = field(default_factory=dict)

Every explicitly marked field, by name. Unmarked fields are absent.

label class-attribute instance-attribute

label: str | None = None

The field naming this record for a human, if one is marked.

choice_labels class-attribute instance-attribute

choice_labels: Mapping[str, Mapping[Any, str]] = field(default_factory=dict)

Per field with choices, the {value: display} pairs whose display differs from the value. Absent for a field whose displays only repeat its values.

nested class-attribute instance-attribute

nested: Mapping[str, AudienceProjection] = field(default_factory=dict)

Child projections, by field name, for a nested object and for the objects of an array.

is_empty

is_empty() -> bool

True when applying this projection would change nothing.

The fast path: an unmarked output with no labelled choices anywhere costs a caller one boolean rather than a walk of the payload.

A value formatter needs no term of its own: it is carried by a marking, so an output that declares one has a non-empty fields already. A term that can never be the deciding one goes stale without failing.

One or-chain, so each term is held by its own case of test_each_term_alone_makes_it_non_empty.

audience

audience(name: str) -> FieldAudience

The audience declared for name, defaulting to CONTENT.

formatter

formatter(name: str) -> ValueFormatter | None

The formatter that applies to name, or None.

None for a HANDLE however the two were declared together: a handle is another tool's input and a formatted machine identifier is a broken one. The suppression lives here rather than in each walk, so the payload and the schema cannot end up formatting different fields, which is the one failure the projection exists to make impossible.