django-service-specs
A service contract for Django: declare an operation's parameters, permission check, validation and output once, and dispatch it from any transport - an HTTP view, an MCP tool, an agent tool, a management command or a background task.
Django is the base and the only dependency. Django REST Framework is not a
dependency, and nothing in the package imports it, so a project with business
logic and no API framework can hand its operations to an agent, a command or a
queue with Django alone.
djangorestframework-services
will depend on this package and become its DRF adapter; this package will
never depend on it.
Status: early. The public API may still change between minor releases, and every change is recorded in the changelog.
Install
pip install django-service-specs
The package has no models and needs no entry in INSTALLED_APPS. A principal
is a user of django.contrib.auth, and a generic-relation write needs
django.contrib.contenttypes. Django 4.2 or later, on Python 3.10 or later.
The pydantic adapter is the one part that needs more, and it comes with an extra:
pip install "django-service-specs[pydantic]"
Quickstart
One write, declared once: what it takes (a dataclass behind a Validator),
which row it acts on (an instance selector), who may run it
(permissions=[...]), and what it returns (a Presenter). Note is a model
with a title and an owner who is a user.
from dataclasses import dataclass
from typing import Any
from django_service_specs import (
DataclassPresenter,
DataclassValidator,
Parameter,
Parameters,
PermissionCheck,
SelectorKind,
SelectorSpec,
ServiceSpec,
dispatch,
present,
)
from notes.models import Note # a title, and an owner who is a user
@dataclass
class Rename: # what the operation takes
title: str
@dataclass
class NoteOut: # what it returns
id: int
title: str
class IsOwner(PermissionCheck):
message = "Only the note's owner may rename it."
def has_permission(self, principal: Any, spec: Any) -> bool:
return principal.is_authenticated
def has_object_permission(self, principal: Any, spec: Any, target: Any) -> bool:
return target.owner_id == principal.pk
def rename_note(*, instance: Note, title: str) -> Note:
instance.title = title
instance.save(update_fields=["title"])
return instance
rename_note_spec = ServiceSpec(
service=rename_note,
permissions=[IsOwner()],
validator=DataclassValidator(Rename),
instance_selector_spec=SelectorSpec(
kind=SelectorKind.RETRIEVE,
selector=lambda *, pk: Note.objects.filter(pk=pk), # the row, or none
reads=Parameters.of(Parameter("pk", "integer", required=True)),
),
presenter=DataclassPresenter(NoteOut),
)
def rename(user: Any, arguments: dict[str, Any]) -> Any:
result = dispatch(rename_note_spec, principal=user, arguments=arguments)
if result.kind == "not_found":
return None # a transport answers this in its own terms: a 404, an exit code
return present(rename_note_spec, result)
rename(user, {"pk": 1, "title": "Final"}) returns {"id": 1, "title": "Final"}
for the note's owner, None when there is no note 1, and raises NotPermitted
for anyone else. The same rename_note_spec can be dispatched from an HTTP
view, an MCP tool, a management command or a task: each supplies a principal
and the arguments, and answers the result in its own terms. This code is
docs/examples/quickstart.py, where Note comes from the test suite's own
app, and the test suite runs it.
The order dispatch runs in
dispatch() and adispatch() take the same steps, in the same order, for a
write and a read:
- Shape check and closed argument set. Every argument against the declared parameters, at every level of nesting, with no query.
- Class-level authorization. Every permission check's
has_permission, before any row is resolved, so a refused principal learns nothing about which rows exist. - Target resolution. The selector finds the row or rows. A missing row is
returned as
DispatchResult(kind="not_found"), never raised. - Object-level authorization.
has_object_permissionon a retrieved row. - Validation. The Validator, with the resolved row in its context, so an update's uniqueness check can exclude the row being updated.
- The run. The service, inside
transaction.atomic()unless the spec saysatomic=False, then the output selector if one is declared.
A read stops after step four: its selector is its run.
present() renders the result afterwards, and only when the transport asks,
so one that hands the row to a template never pays for rendering it.
What it refuses
| Refused | Raised | Who refused |
|---|---|---|
| An argument of the wrong type, missing, or not declared | InvalidArguments, with every problem in .detail |
Dispatch |
| Arguments the Validator rejects | InvalidArguments |
Dispatch |
A permission check says no, or a Grant does not cover the call |
NotPermitted |
Dispatch |
| An identifier that names no active user | PrincipalUnavailable |
Dispatch |
| A business rule, raised by the service | ServiceError, ServiceValidationError, ServiceConflict, ServiceNotFound |
The operation |
| A required row that does not exist | Nothing: DispatchResult(kind="not_found") |
- |
| An operation that declares no permission check | ImproperlyConfigured, at registration and at dispatch |
Configuration |
The first four share the base DispatchError; the service's own share
ServiceError. Neither subclasses the other. A consumer reads a service
refusal as something the caller adapts to - an agent routes around it and
keeps going - so a denial declared as one becomes something a model retries.
And an MCP server tells arguments of the wrong shape from a business rule on
well-shaped ones by exactly this type difference.
An operation that is open to anyone says so with permissions=[Unrestricted()].
Off HTTP there is no view whose policy it could inherit, so leaving the list
out is refused rather than read as "no restriction".
Documentation
artui.github.io/django-service-specs:
- Declaring an operation - specs, Parameters, Validators, Output and Presenters
- Dispatching - the order, results, grants, the async rule and pool seeds
- Arguments and refusals - the shape check, the error tree and the two error families
- Relation writes - nested writes through the five relation specs
- The registry - one named set of operations for several transports
- The forms adapter - a Django form class as the Validator,
ModelFormincluded - The pydantic adapter - a pydantic model as the Validator and the Presenter
- JSON Schema - the input and output schema a transport describes an operation with
- API reference
License
MIT
Metadata
Release files for django-service-specs 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| django_service_specs-0.2.0.tar.gz | 379.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_service_specs-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 553.7 kB
Release files / django_service_specs-0.2.0.tar.gz
| Download URL | django_service_specs-0.2.0.tar.gz |
|---|---|
| Size | 379.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
731d3bd2dbe879e878c2b72552beb3648f95e29c832fa3bdd94406d1edb9ca4a
|
|
BLAKE2b-256 checksum How to use checksums |
27ffc19ce31b308e6bcfb1ad1f5a177e85d63955091b05ed7463b5d08e17103e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.
Transparency logRelease files / django_service_specs-0.2.0-py3-none-any.whl
| Download URL | django_service_specs-0.2.0-py3-none-any.whl |
|---|---|
| Size | 173.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9308571cbe7b1a2237137c915f037126922cbfa2a856ab0403a35af8c73373e3
|
|
BLAKE2b-256 checksum How to use checksums |
ea6ff9967d11e750f4e1154634d0b02e4b6017312645422953d6f8e954b5558d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.
Transparency log