Skip to content

Types

SelectorKind

SelectorKind

Bases: str, Enum

Whether a SelectorSpec returns many objects or a single one.

The kind is what tells the framework — and any future caller that reuses a spec outside an HTTP request — whether to materialize the selector's return as a collection (LIST) or as a single instance with retrieve-flavoured 404 semantics (RETRIEVE). Mounting a spec on a mismatched view (e.g. a LIST spec on a SelectorRetrieveView) raises ImproperlyConfigured at as_view() time.

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

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 and the retrieve viewset mixin 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[Serializer] | None

DRF Serializer subclass used by get_serializer_class() 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_contextget_output_serializer_contextget_<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 FilterSetself.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.

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.

ServiceSpec

ServiceSpec dataclass

Bases: 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.

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.

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.

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_contextget_input_serializer_contextget_<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.

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.

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 only alongside collection_selector_spec. 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.

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, whereas a DRF APIException is mapped on HTTP only.

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.

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 anywhere a ServiceSpec is: an action_specs entry and the spec= of @service_action.

PolymorphicServiceSpec(
    discriminator=resolve_flow,        # pool: {request, data, user, view} → 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 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.

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). children carries one ChildCollectionChange per reverse-FK collection written via the children= argument — empty for the common no-nested-write case.

relations carries one RelatedObjectChange per singular relation written via relations= (forward FK / one-to-one, reverse one-to-one). The split is by shape, not by keyword: a collection reports tuples of pks and a one-row relation reports an outcome, so they are different carriers, and a reverse-FK collection declared through relations= still reports under children exactly as it does through children=. A forward relation shows up twice and means two different things — here as the row that was created or matched, 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) — or in children=, which is the same thing under the name it shipped as — and forwarded by the default create_model / update_model / delete_model services. 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; field-level validation stays in the input serializer / dataclass — 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 the dispatcher's pools. In the async loops the slot must be an async def: the async path is awaited end to end.

A declared slot owns that row entirely: field_map, exclude_fields, m2m and the nested children / relations maps 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 serializers emitting "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.

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

Callable (child_row) -> mapping deriving the child's many-to-many assignments from its incoming row — the per-child analogue of create_model's m2m.

children Mapping[str, ChildSpec] | None

Nested {relation_name: ChildSpec} map for grandchildren; recursion follows the declared tree, so depth is bounded by how deeply you nest specs.

relations Mapping[str, RelationSpec] | None

The same nesting for every other relation kind — a {relation_name: RelationSpec} map applied to each child row exactly as the top-level relations= is applied to the parent.

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 framework's existing 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_model cascade. 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.

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_model cascade, which disposes of the same rows.

ChildCollectionChange

ChildCollectionChange dataclass

What a nested write did to one reverse-FK child collection.

Carried in ChangeResult.children, one entry per children= relation. The tuples hold child primary keys:

  • created — children inserted.
  • updated — existing children whose row was updated (matched by the ChildSpec's match_key).
  • deleted — orphaned children removed because their FK is non-nullable.
  • unlinked — orphaned children detached (FK set to None) because their FK is nullable.
  • removed — children 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.

updated records every matched child the helper ran through update_from_input, regardless of whether that child'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.

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, as it always has. 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 (a 400) 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.

children Mapping[str, ChildSpec] | None

Forwarded likewise.

relations Mapping[str, RelationSpec] | None

Forwarded likewise.

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.

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.

children Mapping[str, ChildSpec] | None

Forwarded likewise.

relations Mapping[str, RelationSpec] | None

Forwarded likewise.

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 below, so the outcome is reported as "removed" — the only thing still known — and an explicit orphan beside it raises.

orphan RelationOrphan | str

What removing the row does, by ChildSpec's rule: "auto" (the default) derives it from fkunlinked 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_model cascade.

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.

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.

children Mapping[str, ChildSpec] | None

Forwarded likewise.

relations Mapping[str, RelationSpec] | None

Forwarded likewise.

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 below, so the outcome is reported as "removed" and an explicit orphan beside it raises.

orphan RelationOrphan | str

What removing a row doesChildSpec'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_model cascade.

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.

children Mapping[str, ChildSpec] | None

Forwarded likewise.

relations Mapping[str, RelationSpec] | None

Forwarded likewise.

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 four 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 four 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

The UNSET sentinel and its type.

Used to distinguish "field omitted from input" from "field explicitly set to None". Critical for partial updates where None must not stomp on an existing value.

UNSET is the singleton value you compare against (value is UNSET). UnsetType is its type, exported so callers can spell it in annotations — e.g. bio: str | None | 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

Sentinel type for the InputT slot when a service expects no body.

Pair with DeleteService when the spec has no input_serializer:

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

The class itself is never instantiated — it exists purely to bind the InputT type variable in a way that is searchable in IDEs and docs.

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

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 — instead of the bare KeyError the callable would otherwise raise from deep inside dispatch, which no transport maps to a useful error.

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. Advertising those is merely a wart on the scoping keys, because the selector default SPREAD_AUTHOR_WINS plus an always-resolving provider makes a client-supplied value dead on arrival. NotClientInput removes the wart at the source rather than relying on that invariant to absorb it.

The marker is advertisement-only, never delivery or enforcement: a marked key is still spread into the kwargs pool exactly as before, and the provider still resolves it. It also does not make the key safe on its own — the security property is still the author-wins precedence documented in resolve_provider. Marking a key hidden while opting into SPREAD_CALLER_WINS on a scoped spec voids that guarantee just as it did before, and a provider owning a scoping key must still never decline via UNSET.

A key marked NotClientInput is also excluded from declared_input_keys, so UnknownArguments.REJECT treats a caller that supplies it as passing an unknown argument — which is exactly what it is.

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.

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

Value seeded when the caller omits the argument; 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.

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

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. The transport advertises it, pops it from the arguments, and hands it to build_offline_context(query_params=…); it never reaches the spec as an input, so the unknown-argument policy never flags 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 — value seeded when the caller omits the argument; 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.

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 request and 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. Every transport seeds a reporter — the ones with nowhere to send progress seed a no-op — so a service that declares the parameter runs unchanged over HTTP, off-HTTP, 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 a transport failing mid-report should not take the service run down with it.

RESERVED_POOL_SEEDS

reserved_pool_seeds

RESERVED_POOL_SEEDS — pool keys carrying transport-controlled seeds.

FieldAudience

FieldAudience

Bases: str, Enum

Whether a serializer field is content, a name, a handle, or plumbing.

A serializer is often read by more than one kind of consumer: a frontend 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 via FieldMarking; read by agent transports and ignored entirely by the DRF view path, which keeps rendering every field exactly as before.

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 serializer.

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 label — 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 serializer field is presented to an agent audience.

Declared in DRF's per-field style bag, which is the only door Meta.extra_kwargs opens onto a field constructor — so a ModelSerializer keeps auto-generating its fields instead of being rewritten field by field:

class InvoiceSerializer(serializers.ModelSerializer):
    class Meta:
        model = Invoice
        fields = ["id", "number", "status", "etag"]
        extra_kwargs = {
            "id":     {"style": {MARKING: FieldMarking.handle("Invoice handle.")}},
            "etag":   {"style": {MARKING: FieldMarking.hidden()}},
            "number": {"style": {MARKING: FieldMarking.label()}},
        }

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

Invisible to the DRF view path: style is read only by DRF's HTMLFormRenderer, and only for its own keys, so a REST response is byte-identical whether or not a serializer is marked up.

description class-attribute instance-attribute

description: str | None = None

Audience-facing description, replacing help_text for this audience only.

help_text is shared with the frontend and the browsable API, 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 the agent, 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 — is the whole existing behaviour, unchanged down to the byte.

An explicit formatter wins over the choice substitution derived from a ChoiceField, which is a real collision: a status field can be both a choice and 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. That precedence used to fall out of the order of an elif.

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.

extra_kwargs = {"due_at": {"style": {MARKING: 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 labels, on both sides at once: the payload gets render's result and the schema gets produces and schema, from this one declaration.

This is generic on purpose. The request that produced it was for formatted local timestamps, and taking that as filed 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 framework writes that into the schema. Choice substitution cannot lie because both sides are derived from the same ChoiceField; 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 / format and is refused if it names type — a formatter that could contradict its own advertisement would put the schema/payload divergence this whole layer 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 at render time. 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.

render instance-attribute

render: Callable[[Any], Any]

The transform, applied to the value DRF rendered.

Called with a JSON-ready value, not a model attribute: the projection runs after render_spec_output, so a DateTimeField arrives as its ISO-8601 string and a DecimalField as whatever DRF's COERCE_DECIMAL_TO_STRING made of it. 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 framework.

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 likedescription, 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 re-implement the same guard — a nullable field is ordinary, and strftime on None raises. Doing it here also keeps the schema exactly as complete as an unformatted field's: the walk declares no nullability for either.

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.

due_at = serializers.DateTimeField(
    style={MARKING: FieldMarking(formatter=ValueFormatter.timestamp())}
)

The zone is Django's active one, and there is no way to pass another. DRF's DateTimeField.to_representation calls enforce_timezone, which reads django.utils.timezone.get_current_timezone(), so the HTTP path already renders in whatever zone is active. Reading the same source makes the two transports agree by construction rather than by discipline, and it is what a per-tenant middleware calling timezone.activate() is already for. A worker activates the zone itself, as it must for the ORM anyway.

A callable zone is not merely unsupported, it is impossible. build_audience_projection is pure in the serializer class and built once, and the schema side is built with view=None, request=None by construction — a schema is described before any request exists to describe (serializer_for_schema says why at length). A per-request callable would therefore resolve differently on the two paths, which is the schema-versus-payload divergence the audience layer 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 is read as midnight, so a DateField can be formatted too — give it a fmt without a time, or the rendering invents one. Anything else passes through unchanged, for the same reason an unrecognised choice constant does: a stale or oddly-typed row should still be reported rather than take the call down. Declaring this on a field that never carries a date is a misdeclaration nothing here can detect.

AudienceProjection

AudienceProjection dataclass

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

Derived from the serializer's FieldMarking markings plus its own ChoiceField definitions. Nothing here depends on the instance being rendered, so a consumer builds it once at registration and passes it to every render rather than re-deriving it per call.

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 ChoiceField, the {value: display} pairs whose display differs from the value. Empty for a field whose labels only repeat its constants.

nested class-attribute instance-attribute

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

Child projections, by field name, for nested and list serializers.

is_empty

is_empty() -> bool

True when applying this projection would change nothing.

The fast path: an unmarked serializer with no labelled choices anywhere should cost a caller one boolean rather than a full payload walk.

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

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 field sets — which is the one failure this whole layer exists to make impossible.