Skip to content

Spec registry

A named, taggable home for a project's spec set, so each transport reads one source instead of enumerating the same specs again. Both symbols are importable from the top-level package.

For the task-shaped walkthrough — where the instance lives, multiple registries, filtered views — see the recipe Declare specs once, project them to many transports.

SpecRegistry

SpecRegistry

A name → spec map with tags, stable ordering, and filtered views.

A project that exposes the same operations over more than one transport otherwise writes the list once per transport, and the lists drift. A registry gives that set one declaration site: each transport reads the same source instead of enumerating specs again.

It holds only the invariant part of an operation — which spec, its canonical name, its tags. Per-transport configuration stays at the binding that already owns it, so this is a source for a transport's own registry, never a replacement for one. Nothing consults a registry per request: adapters read it at configuration time to build their binding tables.

registry = SpecRegistry()
registry.register("list_orders", orders_spec, tags=("read", "public"))
registry.register("refund_order", refund_spec, tags=("write", "admin"))

public = registry.by_tag("public")   # a new registry — a snapshot

Adoption needs no adapter support: :meth:specs returns the dict[str, spec] today's adapters already accept.

There is no global registry. A consumer holds its own instances — a module attribute in its app, or built in a server's constructor — and may hold as many as it likes, with no shared state between them. Two shapes both work: independent registries (separate name namespaces, full isolation, one per mount) and one registry with many projections (:meth:by_tag / :meth:subset views feeding several toolsets). Both are what a multi-instance deployment needs; a blessed global would nudge every project toward a single surface instead.

Registries are mutable containers (:meth:register adds), but every derivation — :meth:by_tag, :meth:subset, :meth:merge — returns a new registry holding a snapshot of the selected entries. The spec objects themselves are shared, not copied. So a derived view never mutates its source, and a later register() on the source does not appear in a view derived earlier.

register

register(
    name: str,
    spec: ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any],
    *,
    tags: Iterable[str] = (),
) -> None

Add a spec under name.

Parameters:

Name Type Description Default
name str

The canonical name. Must be unused in this registry — a duplicate raises rather than overwriting, so a copy-paste declaration fails at import time instead of silently shadowing an operation.

required
spec ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any]

A ServiceSpec (mutation) or SelectorSpec (read).

required
tags Iterable[str]

Free-form labels, deduplicated into a frozen set.

()

Raises:

Type Description
ValueError

name is already registered here.

TypeError

spec is neither a ServiceSpec nor a SelectorSpec. A PolymorphicServiceSpec is rejected with a pointer at the supported shape: register each of its variants under its own name, since transports project one operation per variant rather than a union.

get

get(name: str) -> RegisteredSpec | None

Return the entry registered under name, or None.

all

all() -> tuple[RegisteredSpec, ...]

Every entry, in registration order.

Order is stable so a transport's tool listing is stable — a listing that reshuffles between processes is noise for clients that diff it.

mutations

mutations() -> tuple[RegisteredSpec, ...]

The ServiceSpec entries, in registration order.

queries

queries() -> tuple[RegisteredSpec, ...]

The SelectorSpec entries, in registration order.

by_tag

by_tag(*tags: str) -> SpecRegistry

A new registry holding the entries carrying any of tags.

Union rather than intersection, because intersection composes from this (reg.by_tag("read").by_tag("public")) while union cannot be composed from an intersection. Passing no tags matches nothing and returns an empty registry.

subset

subset(*names: str) -> SpecRegistry

A new registry holding the named entries, in the order given.

Raises:

Type Description
KeyError

A name is not registered here. Naming entries explicitly means a typo is a configuration error, not a quietly smaller surface.

merge

merge(*others: SpecRegistry) -> SpecRegistry

A new registry combining this one with others, in order.

Names are unique per registry, so independent registries may reuse one; merging is the single place that reuse becomes a conflict.

Raises:

Type Description
ValueError

The same name appears in more than one input.

specs

specs() -> dict[str, ServiceSpec[Any, Any, Any] | SelectorSpec[Any, Any]]

A fresh name → spec dict — the shape adapters already accept.

This is what makes a registry usable before any adapter grows registry-aware sugar: pass registry.specs() wherever a dict[str, spec] goes today. Mutating the returned dict does not affect the registry.

RegisteredSpec

RegisteredSpec dataclass

A spec under its canonical name, with free-form tags.

The value type held by :class:~rest_framework_services.registry.spec_registry.SpecRegistry. It carries only the part of a "tool" that is invariant across transports — which spec, what it is called, and how it is grouped. Per-transport knobs (MCP annotations and output schemas, Pydantic-AI pagination or query-param registrations, …) stay at the binding that configures them.

Fields:

  • name — the canonical name for this operation. Unique within the registry that holds it; two independent registries are separate namespaces and may reuse a name.
  • spec — a :class:~rest_framework_services.types.service_spec.ServiceSpec (a mutation) or a :class:~rest_framework_services.types.selector_spec.SelectorSpec (a read). The kind is deliberately not stored: it is derived by isinstance wherever it is needed, so a stored discriminator can never drift from the object it describes.
  • tags — a frozen set of free-form labels used to derive filtered views (registry.by_tag("public")). Tags carry boolean-ish facts — "read", "admin", "destructive" — that every transport can interpret in its own vocabulary. Structured, transport-specific payloads do not belong in a tag string; they belong at the binding.