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
Affordancewhose callablewhenisasync def, or readsresultorqueryset, is refused withImproperlyConfigured, as one readingdataorinstancealready 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: thechildren=alias is gone. PoolSeedschecks a registration against the kernel's reserved set,user,data,instance,collection,result,querysetandprogress: a seed namedresultorquerysetis refused, one namedrequestorserializercan be registered, and anasync defresolver is refused withImproperlyConfigured.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 |
selector |
Callable[..., ResultT] | None
|
Callable invoked by |
allow_none |
bool
|
RETRIEVE-only knob for the |
output_serializer |
type | None
|
Renders the result: a DRF serializer class, or a bare
dataclass type, which renders through a |
output_serializer_context |
Callable[..., Mapping[str, Any]] | None
|
Provider for the response serializer's
|
select_related |
Sequence[str] | None
|
Relation names, forwarded as
|
prefetch_related |
Sequence[str | Prefetch] | None
|
Relation names or |
annotations |
Mapping[str, Any] | None
|
Mapping merged into a single |
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 |
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
|
kwargs |
Callable[..., ExtraT] | None
|
Provider (pool: |
permission_classes |
Sequence[type[BasePermission]] | None
|
Override the calling view's permissions for the
action the spec backs. |
progress_reporter |
Callable[..., Any] | None
|
Provider returning a |
preconditions |
Sequence[Callable[..., None]] | None
|
State/DB rules invoked after the target resolves, seeded
with |
metadata |
Mapping[str, Any] | None
|
Consumer-owned, framework-opaque mapping with exactly one
reserved key: |
reads |
Sequence[UrlKwarg | QueryParam]
|
The arguments a caller with no HTTP request sends in place of
one, as on
|
field_audiences |
Mapping[str, FieldMarking] | None
|
Per-field markings layered over |
json_schema_registry |
JsonSchemaRegistry | None
|
The project's |
as_kernel_spec ¶
This spec compiled to django-service-specs' SelectorSpec.
What makes it a kernel SpecSource; see
as_kernel_spec.
agent_contract ¶
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 |
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. |
partial |
bool | None
|
Override the partial-validation flag the calling surface
derives ( |
many |
bool
|
Validate the request body as a list and render the result list
the same way. The service receives the validated list as |
many_argument |
str
|
The one argument a |
document_service_error |
bool | None
|
OpenAPI-only — whether the schema documents the
422 |
input_serializer |
type | None
|
Validates the request body. |
input_data |
Callable[..., Mapping[str, Any]] | None
|
Provider (pool: |
input_serializer_context |
Callable[..., Mapping[str, Any]] | None
|
Provider for the input serializer's
|
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 |
collection_selector_spec |
SelectorSpec[Any, Any] | None
|
The LIST-kind twin of
|
output_selector_spec |
SelectorSpec[Any, Any] | None
|
The output pipeline as one nested spec. Its
|
output_filter_from_call |
bool
|
Whether the call narrows the re-read: the
|
allow_none |
bool
|
Declare that the service may present nothing: it returns
|
kwargs |
Callable[..., ExtraT] | None
|
Provider (pool: |
permission_classes |
Sequence[type[BasePermission]] | None
|
Override the calling view's permissions for this
action. |
progress_reporter |
Callable[..., Any] | None
|
Provider returning a |
preconditions |
Sequence[Callable[..., None]] | None
|
State/DB rules invoked immediately before the service,
after validation and target resolution, so each sees |
affordances |
Sequence[Affordance] | None
|
What must be true for this operation to be possible right
now, as a sequence of
|
response_finalizer |
Callable[..., Response | None] | None
|
Provider (pool: |
metadata |
Mapping[str, Any] | None
|
Consumer-owned and framework-opaque, with exactly one
reserved key: |
reads |
Sequence[UrlKwarg | QueryParam]
|
The arguments a caller with no HTTP request sends in place of
one: each |
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 ( |
json_schema_registry |
JsonSchemaRegistry | None
|
The project's |
as_kernel_spec ¶
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 ¶
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, anExists, a lookup - is a condition on the row. It reads exactly asModel.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; aNULLis 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 withdef: it is never awaited, so anasync defone is refused when the affordance is declared. It sees the pool's seeds only:user,progressand any registered pool seed, an HTTP adapter'srequestamong 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 |
when |
Any
|
The condition, as above. |
Raises:
| Type | Description |
|---|---|
ImproperlyConfigured
|
|
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 |
specs |
Mapping[str, ServiceSpec[Any, Any, Any]]
|
The variants, keyed by the value |
permission_strategy |
PermissionStrategy
|
How |
as_kernel_spec ¶
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.
get_field_change ¶
Return the
FieldChange for
field_name, or None.
get_child_change ¶
Return the
ChildCollectionChange
for relation, or None.
get_relation_change ¶
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 ( |
match_key |
str
|
Field used to pair an incoming row with an existing child.
An incoming row whose |
mode |
RelationMode | str
|
|
orphan |
RelationOrphan | str
|
What removing an orphan does, where |
field_map |
dict[str, str] | None
|
Forwarded to the per-child |
exclude_fields |
list[str] | None
|
Forwarded to the per-child call, as |
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' |
relations |
Mapping[str, RelationSpec] | None
|
The child's own relations, of any kind — a
|
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
|
update_service |
Callable[..., Any] | None
|
The same for updates, called as
|
delete_service |
Callable[..., Any] | None
|
Called as
|
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'smatch_keyand written: among the parent's own rows for a child or generic collection, insidescopefor 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 toNone.removed— rows handed to the spec'sdelete_service. Deliberately a fifth tuple rather than a reuse ofdeleted: once a service owns the row, the loop no longer knows whether it was deleted, archived, unlinked or left standing, and folding those intodeletedwould 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:
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 andupdate_fieldsmachinery as any other field.- the parent's
save()— not a phase; the boundary the phases are named around. 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.GENERIC— generic relations, which need the saved parent's content type and primary key.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
¶
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
class-attribute
instance-attribute
¶
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
¶
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 |
scope |
QuerySet[Any] | Callable[..., QuerySet[Any]] | None
|
The rows this caller may update — a queryset, or a callable
resolved from the caller's |
field_map |
dict[str, str] | None
|
Forwarded to the target row's own |
exclude_fields |
list[str] | None
|
Forwarded likewise. Excluding the |
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 |
update_service |
Callable[..., Any] | None
|
The same, and additionally receives |
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 ( |
orphan |
RelationOrphan | str
|
What removing the row does, by
|
field_map |
dict[str, str] | None
|
Forwarded to the row's own |
exclude_fields |
list[str] | None
|
Forwarded likewise. Shaping configures the row's
write only; the row itself is found through |
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 |
update_service |
Callable[..., Any] | None
|
The same; returning |
delete_service |
Callable[..., Any] | None
|
Replaces the unlink-or-delete rule above, so the outcome
is reported as |
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. |
content_type_field |
str
|
Name of the content-type column, defaulting to
Django's own |
object_id_field |
str
|
Name of the id column, defaulting to |
match_key |
str
|
The field pairing an incoming row with an existing one
(default |
mode |
RelationMode | str
|
|
orphan |
RelationOrphan | str
|
What removing a row does —
|
field_map |
dict[str, str] | None
|
Forwarded to the row's own |
exclude_fields |
list[str] | None
|
Forwarded likewise. Excluding the |
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
|
update_service |
Callable[..., Any] | None
|
The same; returning |
delete_service |
Callable[..., Any] | None
|
Replaces the unlink-or-delete rule above, so the outcome
is reported as |
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 |
scope |
QuerySet[Any] | Callable[..., QuerySet[Any]] | None
|
The rows this caller may update, on the terms
|
mode |
RelationMode | str
|
|
field_map |
dict[str, str] | None
|
Forwarded to the target row's own |
exclude_fields |
list[str] | None
|
Forwarded likewise. Excluding the |
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 |
update_service |
Callable[..., Any] | None
|
The same, and additionally receives |
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¶
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.useris set to the same value);Anybecause the user model is project-defined.request— the synthetic DRF Request (.userset,.datacarrying the params), forwarded to spec callables that declarerequest.view— theOfflineServiceViewforwarded to callables that declareviewand used as theviewargument 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'sinstance_selector_spec/collection_selector_speclookup - is the server's for the whole call, so the compiled spec declares no parameter of that name:dispatch_spec,adispatch_specand the kernel'sdispatchdrop the caller's value underIGNORE, whatever the argument binding. Aninput_serializerfield of that name stays the client's input, validated intodata, 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 |
read_input_description¶
read_input_description ¶
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 theInputRequired/NotClientInputpair 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
|
type |
str
|
The JSON-Schema type advertised to the caller — |
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 |
required |
bool
|
Advertise the key in the schema's |
json_schema ¶
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-edTypedDictkey; - a precondition of either;
- a target lookup's selector, and its
kwargsprovider; - the spec's
kwargsandinput_dataproviders, 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 everyQueryParamof 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; seevalidate_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 schemadefault. Left atUNSETthere is no default, and the schema carries nodefaultkey;default=Noneis a real declaration ("defaults to null") and is surfaced like any other value. Read it withis not UNSET, never with a truthiness oris not Nonetest.
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 ¶
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_SEEDSis 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.reservedadds the transport's own keys on top; pass the pagination names the transport reserves (page/limitand whichever oforder/orderingit 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.
requiredtogether with adefault— 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 arequiredattribute (QueryParamdeliberately has none). "Has a default" isdefault is not UNSET:Noneis a declarable default ("defaults to null"), so pairing it withrequiredis 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 intomessageand 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
¶
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_poolbuilds; - 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. |
reserved
property
¶
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 ¶
Return a new registry with seeds appended.
Raises:
| Type | Description |
|---|---|
ImproperlyConfigured
|
A resolver is async - an |
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. |
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
¶
The default: ordinary data, shown to every consumer.
LABEL
class-attribute
instance-attribute
¶
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
¶
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
¶
Plumbing. Dropped from the projected payload and from the projected schema.
MARKING¶
MARKING
module-attribute
¶
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
¶
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
¶
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
¶
An opaque identifier: passed to other tools, never spoken to a user.
hidden
classmethod
¶
Plumbing: dropped from the projected payload and the projected schema.
label
classmethod
¶
The field that names this record for a human.
formatted
classmethod
¶
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
¶
A date-time read as a formatted local string rather than raw ISO-8601.
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
|
|
render
instance-attribute
¶
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
¶
The JSON type render returns. Written into the schema by the kernel.
schema
class-attribute
instance-attribute
¶
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 ¶
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 ¶
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
¶
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
¶
Every explicitly marked field, by name. Unmarked fields are absent.
label
class-attribute
instance-attribute
¶
The field naming this record for a human, if one is marked.
choice_labels
class-attribute
instance-attribute
¶
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
¶
Child projections, by field name, for a nested object and for the objects of an array.
is_empty ¶
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 ¶
The audience declared for name, defaulting to CONTENT.
formatter ¶
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.