Skip to main content

django-service-specs

CI PyPI Python versions Django versions Docs Coverage Ruff License

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 framework it depends on. 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 depends on this package and is its DRF adapter; this package will never depend on it.

No agent transport reads a spec without DRF yet. The family's MCP server (djangorestframework-mcp-server) and its Pydantic AI toolset (djangorestframework-pydantic-ai) reach specs through djangorestframework-services today, so both still require DRF. What a tool-building transport reads off a spec besides dispatch - affordances, paging, the agent projection of an output, progress - is here, and transport distributions that read kernel specs with no DRF at all are planned. Until then a project without DRF can dispatch its specs from its own views, commands, tasks and agent tools, and no ready-made MCP server or agent toolset serves them.

A spec as a management command is django-spec-cli, a distribution of its own that depends on this one: one command per registry, one subcommand per spec, run through dispatch as a named user. A spec served from a plain Django view, as JSON or as the page its form renders, is django-spec-html, likewise its own distribution.

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.

Two parts need more, and each comes with an extra: the pydantic adapter, and a selector's django-filter FilterSet:

pip install "django-service-specs[pydantic]"
pip install "django-service-specs[filter]"

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; over HTTP, django-spec-html's SpecView.as_view(spec=rename_note_spec) is that view. 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() refuse a deactivated principal first, as PrincipalUnavailable, before anything about the call is checked. Then they take the same steps, in the same order, for a write and a read:

  1. Shape check and closed argument set. Every argument against the declared parameters, at every level of nesting, with no query.
  2. Class-level authorization. Every permission check's has_permission, before any row is resolved, so a refused principal learns nothing about which rows exist.
  3. Target resolution. The selector finds the row or rows. A missing row is returned as DispatchResult(kind="not_found"), never raised.
  4. Object-level authorization. has_object_permission on a retrieved row, and has_scope_permission where no single row is: a collection, a list, or the None of a create.
  5. Validation. The Validator, with the resolved row in its context, so an update's uniqueness check can exclude the row being updated.
  6. Affordances, then preconditions. A write's declared affordances, in order: the first one the row or the moment does not meet refuses the call with ActionUnavailable. Then its preconditions, the first to raise aborting the call. Both before the run's transaction opens.
  7. The run. The service, inside transaction.atomic() unless the spec says atomic=False, then the output selector if one is declared.

A read stops after step four: its selector is its run. Its affordances are answers about each row, for a list to report, and refuse nothing.

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
A deactivated principal, or an identifier that names no active user PrincipalUnavailable Dispatch
A business rule, raised by the service ServiceError, ServiceValidationError, ServiceConflict, ServiceNotFound The operation
A declared affordance the row or the moment does not meet ActionUnavailable, a ServiceConflict carrying a stable code Dispatch, before the run
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
An async def permission check, condition, provider or precondition, which nothing awaits ImproperlyConfigured, when it is declared Configuration

The first four share the base DispatchError; the next two share ServiceError, an unavailable action included, because the caller adapts to it as it does to a business rule. Neither base 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:

License

MIT

Metadata

Release files for django-service-specs 0.13.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-service-specs 0.13.0
File Size Uploaded Provenance
django_service_specs-0.13.0.tar.gz 906.6 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for django-service-specs 0.13.0
File Interpreter ABI Platform
django_service_specs-0.13.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.3 MB

Release files / django_service_specs-0.13.0.tar.gz

Download URL django_service_specs-0.13.0.tar.gz
Size 906.6 kB
Tags Source
SHA-256 checksum
How to use checksums
39cec280f314d283a56ed1ed1b9105d23bd8b94aa45198cc7b25d8282e4e5f4b
BLAKE2b-256 checksum
How to use checksums
a1285107990a9cc4497011478b127d46d04b743450a1019ed3964b404655929c
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 Oct 21, 2026.

Transparency log

Release files / django_service_specs-0.13.0-py3-none-any.whl

Download URL django_service_specs-0.13.0-py3-none-any.whl
Size 409.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
cbf66323ffe9dca7360b184dcdf8609b0c84e4313485a4700527dc5d679481ce
BLAKE2b-256 checksum
How to use checksums
bbee15b876c71fa1d401a8f16d630be3575e158fde6cff00d1b8a81e7dcf64d2
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 Oct 21, 2026.

Transparency log

Security for django-service-specs 0.13.0

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.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for django_service_specs-0.13.0.tar.gz

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 Oct 21, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page