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 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:

  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.
  5. Validation. The Validator, with the resolved row in its context, so an update's uniqueness check can exclude the row being updated.
  6. 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.

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:

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)

Source distribution for django-service-specs 0.2.0
File Size Uploaded
django_service_specs-0.2.0.tar.gz 379.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-service-specs 0.2.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

0.3.0

2 release files

This release

0.2.0 This release

2 release files

0.1.0

2 release files

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