Skip to content

DRF seats

A kernel spec has three seats DRF can fill: its validator, its presenter and its permissions. This page is the adapters that fill them, the DRF refusal classes the kernel's refusals become, and the helpers a caller off HTTP uses to give a serializer the context it reads.

None of these is exported from the package root; import each from its leaf module:

from django_spec_rest.service_specs.drf_permission_check import DRFPermissionCheck
from django_spec_rest.service_specs.serializer_presenter import SerializerPresenter
from django_spec_rest.service_specs.serializer_validator import SerializerValidator

The seats

SerializerValidator

Bases: Validator

A Validator whose declaration is a DRF serializer, or a dataclass read through one.

It takes a Serializer subclass, ModelSerializer and DataclassSerializer included, or a dataclass, which is validated through a DataclassSerializer built for it. Anything else is refused with ImproperlyConfigured when the Validator is built.

parameters() walks the serializer's fields, in order, as the field walk in service_specs/utils.py reads them: a nested serializer is an object with Parameters of its own, many=True and a ListField an array, a file field a file, choices with their labels; a HiddenField and a read-only field are no parameter. It is read on each call rather than when the Validator is built, because specs are built at import time, when a ModelSerializer cannot yet read its model, and so that help is rendered in the language active when it is asked for. It never queries.

validate() builds the serializer as a DRF view would:

  • over the body the call was sent, where a DRF edge call names this Validator (validator_input): the caller's own data, a QueryDict kept one, with what the edge put over it, as a DRF view's serializer was always handed request.data. Anywhere else, the kernel's checked arguments.

  • with the target as instance, so an update's uniqueness checks exclude the row being updated. The queryset a collection selector resolves is no row, so a call over one binds as a create; the target is handed over as it is, since a serializer, unlike a ModelForm, writes nothing onto its instance while it validates.

  • partial as the call is, the context's partial, so a partial call may leave out a required field and returns only what was sent.
  • under the serializer context DRF builds on HTTP, request, format and view. The request and view the call registered as pool seeds are the serializer's own, read from ValidationContext.ambient as the kernel resolved them, and where a seed is absent or None, the entry of that name in ValidationContext.call_context. A call that carries neither gets an offline request whose user is the principal and whose data is the arguments, built by build_offline_context, so a CurrentUserDefault reads the principal and a field or a validate() reading self.context["request"] finds a request rather than a KeyError. What a registered request's user is, is the transport's to make the principal; the Validator never writes to a call's own objects. Over that, the input half of the view hooks a DRF edge carries for the call (input_view_layers), then this Validator's own serializer_context, each winning on a key it shares with what it layers over (see serializer_context in service_specs/utils.py). The callable declares what it reads from the call's pool: request, view, user, call_context, a registered seed, or an argument. It is asked once per call, a many call's included, except where the call builds the serializer twice, for a body the server puts a value into or a row a target lookup resolves, and what the callable is handed differs between the two.

A refusal is one DRFInvalidArguments, the kernel's refusal that is DRF's ValidationError as well, carrying the serializer's errors unchanged: DRF's ErrorDetail leaves, each with its code, cross-field messages under non_field_errors, and a nested list's errors in whichever shape the installed DRF writes them.

validated() hands the service data, the serializer's validated_data - the validated mapping, or the dataclass instance a DataclassSerializer builds - and the bound serializer is bound for the call as serializer, its save() the DRF one. values follow what validated_data is: a mapping's entries, keyed as DRF keys them, by each field's source; a dataclass instance's __init__ fields, whether a dataclass was declared or a DataclassSerializer subclass was; and nothing for any other object a serializer's validate() returns, which reaches the service only as data. A key named like a seed of the call is a value only in data: DRF's HiddenField(default=CurrentUserDefault()) named user, or a key a validate() adds named result, stays in validated_data as the serializer built it, and the service's user is the call's principal (see _unseeded). So is a key named in server_owned, the names the caller declares the server's: the client's value is the service's in data alone, unless the edge put the server's value into the field it comes from (see _unowned). Where the edge did, that field's validation of the value the edge put is the key's value, validated on its own once the serializer has accepted the body, never read back off the body (see _server_values), whatever the field reads from the body, and whatever a later field of the same source, a source="*" nested serializer, a validate() or a to_internal_value override did to the key: it is the parameter's, by the field's source or, where that is no single key, by the field's name, and by the key an author named it under (see _handed), and it is laid back over the key in a mapping data (see _lay_back). A dataclass instance is data as built, and a many call's rows are neither validated apart nor laid back. A validated None for a file field is "", what the model field stores for no file, in data and the values, where the input is no form (see _cleared). save(), create and update take it as it is and the kernel's update_from_input reads it as a clear, so a null a field's allow_null lets through clears the file however a service writes it. The same holds for a field DRF fills with its None default on a whole call (test_a_put_leaving_out_a_file_defaulting_to_none_clears_it).

A partial call over a dataclass is refused, a DataclassSerializer subclass included: one builds its instance from every field, and a field a partial call left out fails as a TypeError from inside the library. supports_partial is therefore answered per instance, False for either declaration and True for any other serializer, so a spec declaring partial=True over a dataclass is refused when it is built. A partial call made on the Validator directly is refused with ImproperlyConfigured as well.

validated_many() validates a many spec's rows as DRF's own list serializer does, once: the serializer's child class under many=True (so a Meta.list_serializer_class and its list-level validate() run), with the call's context and partial flag. The one list serializer is bound for the call as serializer, data is the list of each row's validated_data and the values are the rows' values under many_argument. A refusal is an DRFInvalidArguments whose detail keys each failing row by the index the caller's list gave it, whichever of DRF's two shapes the installed version reports item errors in, and whose list-level message sits under non_field_errors. Over HTTP as off it, the list bound is the rows the kernel passed: where it refused a row, the list's own checks judge the rows left, and their refusal sits beside the refused row's (test_bulk_rows_over_http's test_a_lists_own_checks_judge_the_rows_the_kernel_passed).

unsendable property

unsendable: frozenset[str]

The required input fields no call on the kernel route can send.

A field named like a seed (RESERVED_POOL_SEEDS, BOUND or excluded) is no parameter (see parameters()), so the kernel has nowhere to take its value from, while an HTTP body carries it by name. Where the serializer requires such a field, a call through the kernel's dispatch is refused for it however it is made. This names those fields, by the name parameters() would have declared them under, so a transport can refuse such a spec before a call reaches it::

compiled.validator.unsendable
  • Only what parameters() omits. A field the Validator declares a parameter for is never named, even one a seed, a server argument or a call seed then fills: a field named like a registered seed is one unless the caller names it in excluded. A required field called serializer or user is named.
  • Required only. An optional field, one with a default, a read_only field and a HiddenField are no input to require (test_an_optional_field_is_not_unsendable, test_a_read_only_field_is_not_unsendable, test_a_hidden_field_is_not_unsendable), and a many spec's row fields are read through the row serializer this Validator holds, so a row's required user is named and a row's tenant is not (test_a_many_specs_row_fields_are_read_through_the_row_serializer).
  • Not partial. Whether a call may leave a required field out is the spec's partial, not this attribute's question; it names the field whether or not the spec is partial (test_a_partial_spec_names_the_same_fields).
  • Not lenient. The declaration relaxes what each parameter accepts and not which names are parameters (test_a_lenient_validator_names_the_same_fields).

Computed on each read, from the serializer parameters() describes, for the reason parameters() is: a ModelSerializer cannot read its model when specs are built.

parameters

parameters(*, call_context: dict[str, Any] | None = None) -> Parameters

The serializer's input fields, as Parameters.

Read off describe_serializer: inside an edge call, the serializer the call builds once and validates with, so a serializer that queries as it is built queries once in the call (test_its_queries_run_once); outside one, a serializer built afresh for each read, as a schema or a registration reads it. call_context is the dict of the call asking, which the kernel hands a Validator declaring the keyword on every ask inside a call and none outside one; only the edge's own call is described as the edge's (describe_serializer), so the same declaration dispatched by a service for another principal is described unbound (test_a_kernel_call_of_the_view_s_own_spec_for_another_principal_is_never_described_under_the_view_s_request).

A field named like a seed of the call is no parameter: one of the dispatcher's own names (RESERVED_POOL_SEEDS, the user case of test_a_writable_field_named_like_a_seed_validates_into_data) and the serializer this Validator binds (BOUND, test_an_input_field_named_serializer_validates_into_data) always, and each of excluded, the names a caller's registered seeds take (test_a_field_the_validator_excludes_is_unsendable_when_required). The kernel refuses a parameter a seed occupies, where a serializer was always free to declare such a field: the client sent its value, the serializer validated it into data, and the seed kept its own. So the field is the serializer's alone, read from the body the edge hands it (validator_input), and never meets the seed in the call's pool.

lenient declares each field by name alone - any value, optional, nullable - so the kernel's shape check passes every value through and the serializer is the one to answer it, as a live DRF view's request always was answered. The edge dispatches a lenient copy of a spec's Validator (recording_spec) for a form body, and for a JSON body where the view's lenient_json_body says so.

validate

validate(arguments: Mapping[str, Any], context: ValidationContext) -> dict[str, Any]

The validated values, keyed by source, or DRFInvalidArguments carrying DRF's detail.

validated

validated(arguments: Mapping[str, Any], context: ValidationContext) -> Validated

The values, validated_data as the call's data, and the bound serializer.

validated_many

validated_many(
    rows: Mapping[int, Mapping[str, Any]],
    context: ValidationContext,
    *,
    many_argument: str,
) -> Validated

The rows validated once as DRF's list serializer, the one serializer bound for the call.

The rows are the ones the kernel's shape check passed, as it hands them: the edge holds no body for a many call (validator_input), so a row the kernel refused is not bound twice, and the list's own checks judge the rows left.

SerializerPresenter

Bases: Presenter

An operation's output declared as a DRF serializer, rendered through it.

output() reads the serializer's fields into the kernel's Output, in declaration order:

  • A write_only field is never rendered, so it is not declared.
  • Type: JSON's own, with decimal, date-time and date as formats. A nested serializer is an object carrying its own Output; a many=True serializer, and a ListField whose child is one, is an array of that Output; a MultipleChoiceField is an array of its choices. A field DRF gives no type, a SerializerMethodField or a JSONField say, is declared any, so a schema describes it with no type rather than as a string it may not be.
  • Label: the author's, when one was written. DRF derives Vat id from vat_id when none was, and that is None here: a derived label says nothing the name does not.
  • Choices: every (value, display) pair a ChoiceField holds, typed by the values when they share a type.
  • Presence: always_present is true where DRF is bound to emit the key: a required field, one with a default, and one allowing null. A read-only field with neither is dropped when DRF cannot read it. Under shaped=True, no field at any depth is: the spec compiles a shape, an argument such as a fields selector that the serializer reads off the request, and a key is always present only if it is present under every value the caller may send. Such an argument declares no choices, so the values are any string, and which keys each one drops is the serializer's to decide when it renders, so the keys present under all of them are none that can be named. The default, False, is the presence above.
  • Marking: the FieldMarking found in the field's style, under MARKING or any other key, and a value under MARKING that is not one refused. Nested outputs carry their own.

field_audiences is the spec's own output declaration, a field name to a FieldMarking, layered over the serializer's markings at the top level only. The layered marking is the compiled OutputField.marking, which is the seat audience_projection_for_spec reads, so every engine projects the one declaration with no overrides=, which stays a mount's. A name that is no output field is refused when the output is read: a hidden marking that silently applies to nothing is a field shown to an agent.

presented(value, context) is what the kernel's present asks for: the serializer's .data for one value, under the serializer context derived from the call's PresentationContext. The request and view the call registered as pool seeds, read from ambient, are the serializer's own, and where a seed is absent or None, the entry of that name in call_context; a call that carries neither gets an offline request whose user is the principal, so a SerializerMethodField reading self.context["request"].user renders for the principal the call ran for, and whose query string is the call's shapes, so a serializer reading a fields selector off request.query_params finds the one the caller sent. A request the call carries is left as it is. Over that, the output half of the view hooks a DRF edge adds to the result's context (output_view_layers), resolved with the value, then this Presenter's own serializer_context, each winning on a key it shares with what it layers over (see serializer_context in service_specs/utils.py). The callable declares what it reads from the call's pool, request, view, user or a registered seed, and the value being presented under value_keyword: result for a service's output, instance for a retrieve, nothing where it is None.

presented_rows(values, context) is what the kernel's present asks for a list: a page's rows over HTTP, a many service's return. The callable is asked once for them all, offered the rows under page, the name drfs gave a list's, and under value_keyword too where it names one, so a many service's result is its whole return, as the view's output hook is handed it. A lookup it batches over the rows runs once a page, not once a row. The rows are a list, already evaluated: not a queryset to filter further, nor DRF's Page. They render as one many=True serializer, so a Meta.list_serializer_class and its to_representation run as behind a DRF view, and its answer must keep one row per value, in order, which the kernel checks: it adds affordances to each row by its position. A None row stays None in its place, as presented answers it, and the serializer renders the others.

present(value) is the context-free form, and delegates to presented(value, None): a None context is the serializer context a serializer is described with outside any call (request, view and format present, each None), so a get_fields reading self.context["request"] neither raises there nor sees a principal. It is what a DispatchResult a transport built by hand is presented with, and with no call to resolve it against, serializer_context is not asked (test_present_with_no_call_runs_no_callable).

A dataclass type is read and rendered through a DataclassSerializer built for it, and a BaseSerializer that is not a Serializer declares no fields, so its output is empty while it still presents.

output

output() -> Output

The serializer's fields as an Output, with field_audiences layered on. Never queries.

present

present(value: Any) -> Any

One value as the serializer's .data, with no call to render for.

presented

presented(value: Any, context: PresentationContext | None) -> Any

One value as the serializer's .data, under the context of the call it belongs to.

None is presented as None: it is JSON's null, and a serializer handed none renders a row of blank fields indistinguishable from a real one.

A serializer refusing to render, DRF's ValidationError, leaves as the dual-base DRFInvalidArguments, raised from it, so a route over the kernel's present reads the kernel's refusal of the arguments, a read-shaping fields naming no field among them. A mapping detail is kept as it is, and a list, which is all DRF makes of a message, goes under the kernel's NON_FIELD_ERRORS; every message keeps its code. One that is already the kernel's InvalidArguments leaves as raised. A view presents through the kernel's present, so the refusal reaches HTTP as a 400 whose body is the mapping (tests/service_specs/test_a_presenter_s_refusal.py).

presented_rows

presented_rows(values: Sequence[Any], context: PresentationContext | None) -> list[Any]

A list's rows as one many=True render, its context asked for once.

A None row is presented as None in its place, and the serializer renders the rest. A list_serializer_class answering another count for them is refused here when a None is to be placed among its answer; otherwise the kernel refuses it, comparing the answer with the rows it handed over (test_a_list_serializer_answering_another_count_beside_a_none_is_refused).

A serializer's refusal leaves as presented's does (test_a_refusal_on_a_page_leaves_as_the_kernel_s).

DRFPermissionCheck

Bases: PermissionCheck

DRF permission classes asked the kernel's three questions, off HTTP.

The kernel asks a principal and a spec; a DRF permission class asks a request and a view. So each question asks every class in order, a fresh instance each time, as a DRF view's get_permissions does, against the request and view the call carries, else the offline pair build_offline_context builds for the principal. The first refusal raises what DRF's view raises for it, permission_denied's answer for the request asked: the dual-base DRFNotAuthenticated for a caller the request's authenticators did not recognise, else DRFNotPermitted with that class's own message and code, so a client is told what a DRF view would have told it; returning False instead would leave the kernel to raise its own class with the generic wording. A class that raises its own refusal is asked inside translate_drf_refusals, so DRF's PermissionDenied leaves as that DRFNotPermitted with the class's message and code, and NotAuthenticated as DRFNotAuthenticated, which DRF still answers 401 (tests/service_specs/test_drf_refusals_on_the_kernel_route.py).

The view's kwargs are filled from the call's arguments before the class-level check. A permission scoping by a route capture reads view.kwargs["project_pk"] in has_permission, and off HTTP there is no URLconf to put it there; asked against an empty mapping it would refuse every caller it means to admit. Each method opts in to the kernel's arguments channel by declaring the keyword, and the names that go onto view.kwargs are the ones a route would have captured: route_reads, the arguments the spec's row lookup reads (pk of an update's instance lookup, or of a retrieve). Where a transport asks with no arguments at all (None), nothing is filled, and the classes decide what an empty route means, as they would for a URL that captured nothing.

The request and view the call carries come first. The class-level and object-level methods declare request and view, so a call registering either as a seed hands it over, and call_context, so a call that puts either in its context does: a transport running the kernel's dispatch carries the request a run arrived on, or a role view built for the operation, that way. Each is the seed, else the context's entry, and None in either place is no value. A carried view, or DRF Request, is asked as it is, with nothing filled onto it. Where the call carries one and not the other, the missing half is the offline pair's, built as below, so no class is ever handed None for a request or a view; where it carries neither, the whole offline pair is asked. The offline view's action is the call's context's action, where that is a non-empty string, so a class branching on view.action reads the operation's name; a carried view keeps its own. A carried Django request that DRF has not wrapped, the request a run arrived on as the kernel's call context holds it, is asked as a DRF Request around a copy of it, built as the offline pair is: its user the principal and its query string empty, replacing its own, which is the endpoint's the run arrived on. Asked with no arguments at all, nothing is routed and its own query string stays.

has_object_permission asks only about a model instance; any other target is allowed here, because a DRF class written for a row reads attributes off it.

has_scope_permission allows. The kernel asks it of a bulk call's set, a collection's rows and a create's None, and DRF has no such question: on HTTP, a create and a collection action run the class-level check and no object check, and a DRF has_object_permission handed a QuerySet or None would raise AttributeError or decide on the wrong object. The class-level check has already run, with the view's kwargs filled, by the time the kernel asks this one.

No state is kept between questions: the compiled spec holding this check is shared by every call and every thread, so each question builds its own request and view.

has_permission

has_permission(
    principal: Any,
    spec: Any,
    *,
    arguments: Mapping[str, Any] | None = None,
    call_context: dict[str, Any] | None = None,
    request: Any = None,
    view: Any = None,
) -> bool

Whether every class's has_permission admits principal, raising on the first that does not.

has_object_permission

has_object_permission(
    principal: Any,
    spec: Any,
    target: Any,
    *,
    arguments: Mapping[str, Any] | None = None,
    call_context: dict[str, Any] | None = None,
    request: Any = None,
    view: Any = None,
) -> bool

Whether every class's has_object_permission admits principal to a model target.

has_scope_permission

has_scope_permission(
    principal: Any, spec: Any, scope: Any, *, arguments: Mapping[str, Any] | None = None
) -> bool

Allows: DRF has no question for a set or a create, as the class docstring says.

Refusals that are DRF's as well

Each class is the kernel's refusal and DRF's exception at once, so a caller of the kernel's dispatch catches it by the kernel's class and DRF's exception handler answers it with DRF's status.

DRFInvalidArguments

Bases: InvalidArguments, ValidationError

The arguments were refused, as both the kernel's InvalidArguments and DRF's ValidationError.

Every published consumer of this package catches DRF's ValidationError by name - an MCP server answering "invalid arguments", an agent tool asking the model to retry - and DRF's exception handler answers it 400. None of them catches the kernel's class, so what dispatch refuses leaves as this, and an except written against either side catches it.

Deliberately not a service validation error, which the kernel's class is not either: a business rule stated by a service about well-shaped input is a different refusal, and an MCP server answers the two differently.

detail is the kernel's tree - fields, nested objects, rows under their int index - with every message an ErrorDetail. A message that carries a code (the kernel's CodedMessage does) keeps it, and one that carries none takes code, "invalid" unless given.

DRF's __init__ runs on purpose. By the order of the bases the kernel's runs, and it leaves detail as plain strings: DRF's handler still answers 400 with a byte-identical body, but get_codes() raises, so a test asserting the body passes on the broken class. test_get_codes_keeps_the_kernels_code_and_defaults_the_rest and test_every_message_is_an_error_detail are what fail without it.

from_kernel classmethod

from_kernel(exc: InvalidArguments) -> DRFInvalidArguments

The kernel's exc as this class: the same tree, every message's code and message.

The kernel's class takes no message, so one a subclass declares is carried across by hand (test_a_kernel_refusal_keeps_a_subclass_s_message). Shared by the kernel route's refusal map and translate_dispatch_errors, so the two cannot build the twin differently.

DRFNotPermitted

Bases: NotPermitted, PermissionDenied

A permission check refused the principal, as both the kernel's NotPermitted and DRF's PermissionDenied.

Every published consumer of this package catches DRF's PermissionDenied by name and ends the attempt on it, and DRF's exception handler answers it 403. None of them catches the kernel's class, so a denial dispatch makes leaves as this, and an except written against either side catches it.

Deliberately not a service error, which the kernel's class is not either: an agent reads a service refusal as something to route around and keeps trying, where a denial should end the attempt.

message is the kernel's, and detail is the same words as DRF's ErrorDetail, coded "permission_denied" unless code is given.

Both __init__s run by name, so neither depends on the order of the bases, which is a choice (the kernel's first, as the class is named) and no test can tell from the other. The kernel's sets message: test_the_default_is_the_kernels_message fails without it. DRF's sets detail with the code given, which the kernel's super() chain never passes: test_a_code_given_is_kept fails without it.

from_kernel classmethod

from_kernel(exc: NotPermitted) -> DRFNotPermitted

The kernel's denial exc as this class, with its message.

Shared by the kernel route's refusal map and translate_dispatch_errors.

DRFNotAuthenticated

Bases: NotPermitted, NotAuthenticated

No principal was authenticated, as both the kernel's NotPermitted and DRF's NotAuthenticated.

What an author callable's or a permission class's DRF NotAuthenticated leaves the kernel route as, through translate_drf_refusals, and what a refusal of a caller nobody authenticated is, by permission_denied. A transport reading the kernel's classes ends the attempt on it as a denial, and DRF's exception handler still answers it 401, with the authenticator's challenge, rather than the 403 the dual DRFNotPermitted would be.

message is the kernel's, and detail is the same words as DRF's ErrorDetail, coded "not_authenticated" unless code is given. The default is DRF's wording, not the kernel's: the refusal is that nobody signed in, which the kernel's generic denial does not say (test_the_default_is_drf_s_message).

Both __init__s run by name, as NotPermitted's do. The kernel's sets message: test_a_message_given_is_the_kernel_s fails without it. DRF's sets detail with the code given, which the kernel's super() chain never passes: test_a_code_given_is_kept fails without it.

DRFPrincipalUnavailable

Bases: PrincipalUnavailable, PermissionDenied

The principal cannot act, as both the kernel's PrincipalUnavailable and DRF's PermissionDenied.

Raised for an identifier naming nobody or a deactivated account, before any permission check. It reaches every published consumer's ladder as the denial DRF names, answered 403 and ending the attempt, and an except written against the kernel's class catches it too.

Deliberately not a service error, which the kernel's class is not either: a refused principal is terminal, never something to route around.

message is the kernel's, and detail is the same words as DRF's ErrorDetail, coded "permission_denied" unless code is given. The default is the kernel's wording, not DRF's, since there is nobody to refuse a permission to.

Both __init__s run by name, as NotPermitted's do, so the order of the bases is a choice no test can tell from the other. The kernel's sets message: test_the_default_is_the_kernels_message_not_a_permission_denial fails without it. DRF's sets detail with the code given, which the kernel's super() chain never passes: test_a_code_given_is_kept fails without it.

from_kernel classmethod

from_kernel(exc: PrincipalUnavailable) -> DRFPrincipalUnavailable

The kernel's refusal exc as this class, with its message.

Shared by the kernel route's refusal map and translate_dispatch_errors.

DRFServiceValidationError

Bases: ServiceValidationError, ValidationError

A service refused its input, as both the kernel's ServiceValidationError and DRF's ValidationError.

What an author callable's DRF ValidationError leaves the kernel route as, through translate_drf_refusals: a service, a selector, a precondition, a provider or an extend_queryset refusing well-shaped input by a rule of its own. A transport reading the kernel's classes answers it as the service's refusal, and an except written against DRF's still catches it.

Named apart from the package root's ServiceValidationError, which is the kernel's own class re-exported, so neither is mistaken for the other. Deliberately not an InvalidArguments: the kernel's refusal of the arguments' shape is a different refusal, which an MCP server answers differently.

detail is DRF's, any shape, with every message an ErrorDetail keeping its code; message is the kernel's, the detail where that is a string and the kernel's default otherwise. DRF makes a list of a single message, so a refusal translated from DRF's carries the default (test_a_translated_message_is_the_kernel_s_default).

Both __init__s run by name, as NotPermitted's do. The kernel's sets message: test_a_string_detail_is_the_message fails without it. DRF's makes the detail ErrorDetails with the code given, which the kernel's super() chain never passes: test_a_code_given_is_kept fails without it.

Translating refusals

A permission check the spec declares denying a caller nobody authenticated, an adapted DRF class or a kernel PermissionCheck answering False, is answered as DRF's view answers it, permission_denied below: 401 with the first authenticator's challenge, or 403 where it sends none. A service's own NotPermitted, an inner dispatch its check refused, and a check that raises NotPermitted itself, stay 403 with their message and no challenge.

permission_denied

permission_denied(
    request: Any, message: str | None = None, code: str | None = None
) -> DRFNotAuthenticated | DRFNotPermitted

The refusal of a caller request carries, decided as DRF's view decides it.

A request whose authenticators recognised nobody is DRFNotAuthenticated, with DRF's wording, which the view answers 401 with the first authenticator's WWW-Authenticate, or 403 where that authenticator gives no header. Any other, a request with no authenticator at all among them, is DRFNotPermitted with message and code, 403. So a permission's message reaches only a caller who is signed in or could not have been, as in DRF.

The rule every permission check the spec declares is answered by: DRFPermissionCheck raises it for a class answering False, and the kernel route answers with it a kernel PermissionCheck's denial, which the kernel marks as PermissionCheckDenied, where the denied spec is the one the route dispatched. A service's own NotPermitted, and a refused inner dispatch's, are not decided by it: they stay 403 with their message (tests/views/test_an_anonymous_caller_refused.py). An offline request has no authenticator, so off HTTP a refusal is always the denial.

A request that is not DRF's has no authenticators and is read as having none, so a carried stand-in is refused as an offline request is.

translate_dispatch_errors

translate_dispatch_errors() -> Generator[None]

Re-raise the kernel's DispatchErrors inside the block as this package's dual-base classes.

Every published consumer of this package catches DRF's ValidationError and PermissionDenied by name, and DRF's exception handler answers only those; the kernel's own refusals reach none of them, and a view serves one as a 500. So the DRF edge runs the kernel's dispatch inside this block:

  • the kernel's InvalidArguments leaves as DRFInvalidArguments, with the same tree and every message's code;
  • its DRFNotPermitted as DRFNotPermitted, and its DRFPrincipalUnavailable as DRFPrincipalUnavailable, each with the same message;
  • a subclass of any of the three leaves as that class's twin, with the subclass's message.

The new exception is raised from the kernel's, so the traceback keeps where dispatch refused. Each dual-base class is still the kernel's, so an except DispatchError around the block catches what leaves it as well.

Left exactly as raised:

  • anything that is not a DispatchError, a service's own refusals among them;
  • a DispatchError that is already a DRF exception, such as a dual-base class a validator raised itself, which both ladders already catch. An author callable's DRF PermissionDenied and NotAuthenticated reach here as such a class, translated by translate_drf_refusals as the compile hands the kernel the callable, and the DRF edge restores the author's exception itself (its ValidationError reaches here as a service's refusal, not a DispatchError);
  • a DispatchError with no twin here, which keeps the kernel's class rather than be given a DRF answer nobody declared.

Use it as a with block, around await too. Not as a decorator on an async def: that would wrap the creation of the coroutine, not its run.

translate_drf_refusals

translate_drf_refusals() -> Generator[None]

Re-raise the DRF refusals raised inside the block as the kernel's classes, still DRF's.

The reverse of translate_dispatch_errors: an author writes a service, a selector, a precondition or a permission class against DRF and refuses with DRF's exceptions, which a transport over the kernel's dispatch reads as nothing it knows. So the compile runs each author callable it hands the kernel inside this block, and DRFPermissionCheck asks each permission class inside it:

  • ValidationError leaves as DRFServiceValidationError, with the same detail and codes;
  • PermissionDenied as DRFNotPermitted, with its message and code;
  • NotAuthenticated as DRFNotAuthenticated, which DRF still answers 401;
  • a subclass of any of the three as that class's twin, with the subclass's detail and status_code.

Each is still DRF's class, so an except written against DRF's still catches it, and DRF's handler answers it with the status the author's exception had. It is raised from the author's, which it carries under TRANSLATED_FROM too, so the DRF edge re-raises the exception itself and every route that answered DRF's exception answers it as before.

Left exactly as raised:

  • a refusal that is already the kernel's, the dual-base DRF classes and a twin translated by an inner block among them, which a kernel route already reads;
  • any other exception, DRF's NotFound, AuthenticationFailed and Throttled included, which have no kernel class to leave as.

Use it as a with block, around await too, for the reason translate_dispatch_errors gives.

translating_drf_refusals

translating_drf_refusals(fn: None) -> None
translating_drf_refusals(fn: Callable[..., Any]) -> Callable[..., Any]
translating_drf_refusals(fn: Callable[..., Any] | None) -> Callable[..., Any] | None

fn, its DRF refusals leaving as the kernel's classes; None for no callable.

What the compile hands the kernel in place of each author callable that runs within a call: the service or selector, the preconditions, the kwargs and input_data providers and extend_queryset. fn is called as it was, inside translate_drf_refusals.

It keeps what a reader of the compiled spec takes from the callable: the name, qualname, module, annotations and signature, and, of a function or a method, the docstring and the attributes (a tool description is read off the docstring). An async def stays one, since the kernel awaits it by its kind (test_an_async_service_s_refusal_is_translated_too); the signature is fn's, which the kernel binds the call by; the annotations are fn's and __wrapped__ is fn, so a provider's return TypedDict is resolved in its own module and still names the keys it fills (tests/service_specs/test_translating_drf_refusals.py).

Identity is not kept: the compiled callable is a wrapper, so inspect.unwrap is what reaches the author's own. A functools.partial or a callable object gets neither docstring nor attributes, as its __doc__ is its class's and a reader that tells a function from an object by inspect.isfunction would otherwise be handed that class's words. fn itself is never stamped, as it is the author's own function.

register_drf_row_errors

register_drf_row_errors() -> None

Register DRF's ValidationError as a refusal the kernel re-roots under the row that raised it.

A nested write runs a row service per related row, and one written for DRF refuses its row with DRF's ValidationError, which the kernel cannot name, so it would otherwise escape the write naming no row. Registered, it is re-addressed as the kernel's own refusals are - a collection's row under its int index, a message about the row itself under non_field_errors - and rebuilt as DRF's ValidationError, with every message's code kept, so DRF still answers it 400.

Process-wide and for the life of the process, as the kernel's registry is. Running it again does nothing, because the reader is one module-level function; a second registration of the class with any other reader is refused by the kernel.

Not a home for this package's dual-base InvalidArguments: the kernel reads its own InvalidArguments before any registered class, so that one leaves a nested row as the kernel's class, and reaches DRF's ladders through translate_dispatch_errors.

Serializer context

base_serializer_context

base_serializer_context(*, view: Any, request: Any) -> dict[str, Any]

Build the baseline serializer context a spec-driven render starts from.

Over HTTP every serializer DRF builds carries get_serializer_context — {"request", "format", "view"} — so serializers routinely read self.context["request"] unguarded (build_absolute_uri, request.user, a permission check in a SerializerMethodField). What a call layers on - the serializer_context a SerializerValidator or SerializerPresenter declares, and a view's get_input_serializer_context / get_output_serializer_context - is additive, not a replacement for that baseline, so off HTTP — where there is no DRF view to ask — the baseline has to be synthesized rather than skipped. Without it the same serializer that renders over HTTP raises KeyError: 'request' when the spec is dispatched from an MCP tool call, a Pydantic-AI toolset, or a management command.

Two sources, in order:

  • view.get_serializer_context() when the view has it — a real DRF view (the HTTP bulk path renders through the same helper). It is the view's own documented extension point and may already be overridden, so it wins.
  • Otherwise DRF's shape, synthesized from the view / request the caller passed: the synthetic pair from build_offline_context off HTTP, or None when the caller supplied neither. format is always None — content negotiation is an HTTP-only concern.

The keys are always present, mirroring HTTP: a serializer reading self.context["request"] off HTTP sees the synthetic request (and None only when the caller passed no request at all), never a KeyError. Absolute-URI fields additionally need real headers — pass the ambient http_request to build_offline_context when the transport has one, as the MCP server does.

A seat's own serializer_context is merged over this by serializer_context in service_specs/utils.py, so a provider keeps the final say on every key — including these three.

build_offline_context

build_offline_context(
    user: Any,
    params: Mapping[str, Any] | list[Any] | None = None,
    *,
    http_request: HttpRequest | None = None,
    action: str | None = None,
    kwargs: Mapping[str, Any] | None = None,
    query_params: Mapping[str, Any] | None = None,
    host: str | None = None,
) -> OfflineContext

Build the OfflineContext for dispatching a spec outside an HTTP request.

A spec written for the HTTP transport may read a DRF request or view: a kwargs provider, a request-scoped FilterSet, a serializer reading its context, and the DRF permission check evaluating permission_classes. Off HTTP there is neither, so this synthesizes both; hand them to dispatch as call_context={"request": context.request, "view": context.view}, and the spec keeps working when driven from an agent toolset, a management command or a task runner.

Parameters:

Name Type Description Default
user Any

Set as request.user, so callables and permissions that read it behave as on HTTP. request.auth is None, set without running DRF's authenticators, so reading it never replaces this user: a scope check off DRF is the transport's to make.

required
params Mapping[str, Any] | list[Any] | None

Seeds request.data, straight into DRF's parsed-data cache (a synthetic request has no stream to parse), for a callable that reads it. It is not what the spec validates: the kernel validates the arguments handed to dispatch and never reads request.data.

None
http_request HttpRequest | None

An ambient Django request to wrap, so its headers / META are available (the MCP server passes its real one); otherwise an OfflineHttpRequest is created. Either way the method is forced to POST, because mutation callables often branch on it. The caller keeps ownership: a request passed here is never written to. What gets wrapped is a shallow copy, so the method / GET / user this function sets land on the copy alone, while META, the session, the upload handlers and any body already read stay shared — headers and session writes behave as they do on HTTP, and the live request keeps its own method, query string and user for the rest of its cycle. Pass the request you are serving; dispatching several specs from one request is safe, and neither leaks into the next.

None
action str | None

The view action name, exposed on the OfflineServiceView.

None
kwargs Mapping[str, Any] | None

The view's kwargs, the off-HTTP counterpart of a route's URL captures, read by any callable that declares view and reads view.kwargs. Never merged into the call's arguments: a value the spec validates is an argument to dispatch.

None
query_params Mapping[str, Any] | None

Seeds the request's GET QueryDict, the source request.query_params reads — how read-shaping params that are not spec inputs reach the serializer offline: a serializer branching on query_params, and a request-scoped FilterSet reading self.request.query_params itself. Values are stringified as on HTTP; a list / tuple becomes a multi-valued param. Replaces a wrapped http_request's GET — on the copy that is wrapped, so the caller's own GET still reads its real query string afterwards.

None
host str | None

The origin the synthesized request reports, so build_absolute_uri — which DRF's FileField / HyperlinkedIdentityField call whenever a request is in the serializer context — returns real absolute URLs off HTTP. Accepts "example.com", "example.com:8000", or a full origin whose scheme decides whether links are https. There is no default: unset, absolute-URI building degrades to the relative URL rather than raising (see OfflineHttpRequest). Ignored when http_request is supplied, whose own headers are authoritative, so a caller can pass both unconditionally.

None

The

Type Description
OfflineContext
OfflineContext

to hand to dispatch.

renderable_serializer_class

renderable_serializer_class(declared: None) -> None
renderable_serializer_class(declared: type) -> type[BaseSerializer[Any]]
renderable_serializer_class(declared: type | None) -> type[BaseSerializer[Any]] | None

The class a serializer or dataclass declaration renders through.

A bare dataclass type is wrapped in a DataclassSerializer — the payload the kernel's schema already describes for it, field by field — and anything else comes back exactly as declared, None included. Instantiating the declaration directly works for a serializer and fails for a dataclass, whose own __init__ is handed many= / context=; resolving it here first is what lets both shapes render.

Every render site in this package resolves through it: a spec's SerializerPresenter, which a view, viewset or action presents through and whose output() audience_projection_for_spec reads, so an agent is shaped by the markings of the class it is actually rendered with; the OpenAPI schema; and the browsable API's form serializer. A transport that instantiates a declaration itself calls this and instantiates what it returns:

serializer_class = renderable_serializer_class(declared)
payload = serializer_class(
    value, many=many, context=base_serializer_context(view=view, request=request)
).data

A dataclass gets one class for the life of the process, so repeated calls return the same object.

The pass-through is deliberate rather than a check. A BaseSerializer subclass is the ordinary case, and a declaration that is neither is refused where its schema is derived; refusing it here as well would break a caller that renders through a serializer-shaped factory without ever deriving one.