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 |
selector |
Callable[..., ResultT] | None
|
Callable invoked by |
allow_none |
bool
|
RETRIEVE-only knob for the |
output_serializer |
type[Serializer] | None
|
DRF |
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 |
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: |
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 |
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 |
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
|
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 |
response_finalizer |
Callable[..., Response | None] | None
|
Provider (pool: |
metadata |
Mapping[str, Any] | None
|
Consumer-owned and framework-opaque, with exactly one
reserved key: |
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 |
specs |
Mapping[str, ServiceSpec[Any, Any, Any]]
|
The variants, keyed by the value |
permission_strategy |
PermissionStrategy
|
How |
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.
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) — 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 ( |
match_key |
str
|
Field used to pair an incoming row with an existing child.
An incoming row whose |
mode |
RelationMode | str
|
|
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
|
Callable |
children |
Mapping[str, ChildSpec] | None
|
Nested |
relations |
Mapping[str, RelationSpec] | None
|
The same nesting for every other relation 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
|
orphan |
RelationOrphan | str
|
What removing an orphan does, where |
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 theChildSpec'smatch_key).deleted— orphaned children removed because their FK is non-nullable.unlinked— orphaned children detached (FK set toNone) because their FK is nullable.removed— children 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.
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:
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, 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
¶
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. |
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 |
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 ( |
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. |
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 |
update_service |
Callable[..., Any] | None
|
The same; returning |
delete_service |
Callable[..., Any] | None
|
Replaces the unlink-or-delete rule below, so the outcome
is reported as |
orphan |
RelationOrphan | str
|
What removing the row does, by
|
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
|
|
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. |
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
|
update_service |
Callable[..., Any] | None
|
The same; returning |
delete_service |
Callable[..., Any] | None
|
Replaces the unlink-or-delete rule below, so the outcome
is reported as |
orphan |
RelationOrphan | str
|
What removing a row does —
|
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. |
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 |
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
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.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¶
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 |
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.
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
|
Value seeded when the caller omits the argument; 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¶
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; 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— value seeded when the caller omits the argument; 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.
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 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 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 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
¶
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 serializer.
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 label — 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 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
¶
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
¶
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
¶
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 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
¶
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
¶
The JSON type render returns. Written into the schema by the framework.
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 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 ¶
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.
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
¶
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 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
¶
Child projections, by field name, for nested and list serializers.
is_empty ¶
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 ¶
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 field sets —
which is the one failure this whole layer exists to make impossible.