Services¶
Protocols¶
Each Protocol is parameterised by input, instance (where applicable), and
result. **extras is typed Any, so the framework's kwargs pool flows
through without the service having to declare each key. Strict-typed
extras live on the user's function signature via **extras: Unpack[YourKw]
— see Typing services and selectors for the full pattern.
CreateService ¶
Bases: Protocol[InputT, ResultT]
Structural shape for a create-action service callable.
data carries the validated input from the framework. **extras
absorbs whatever else the framework's kwargs pool delivers — request,
user, and the ServiceSpec.kwargs / get_service_kwargs returns —
without the service having to declare each key. The Protocol types
**extras as Any so services on every major type checker (ty,
mypy, pyright) conform.
Strict-typed extras stay possible on your own function signature: declare
your extras as a TypedDict with NotRequired keys and annotate
**extras: Unpack[YourKw]. Inside the function body, extras["foo"]
is then typed by YourKw. The Protocol no longer carries a third type
argument for the kwargs shape — that cross-check only ever worked under
one minor version of one type checker (ty 0.0.32) and is not portable.
UpdateService ¶
Bases: Protocol[InputT, InstanceT, ResultT]
Structural shape for an update-action service callable.
Receives the resolved instance plus the validated data. Returning
None instructs the framework to render the in-memory instance
(mirroring DRF's UpdateAPIView shape).
See CreateService
for the extras-typing notes.
DeleteService ¶
Bases: Protocol[InputT, InstanceT, ResultT]
Structural shape for a delete-action service callable.
Receives the resolved instance. Most delete services return None;
if you need a response body, return a value and configure
ServiceSpec.output_selector_spec with an output_serializer
(and optionally a re-fetch selector).
For delete with payload — when the spec carries an input_serializer
— bind InputT to your input dataclass and declare data on the
service. data is optional in the Protocol (default Ellipsis)
so services that don't read a body can still match the shape by binding
InputT to NoInput.
See CreateService
for the extras-typing notes.
Default model service factories¶
create_model¶
create_model ¶
create_model(
model: type[ModelT],
*,
field_map: dict[str, str] | None = None,
exclude_fields: list[str] | None = None,
m2m: Mapping[str, Any] | Callable[[Any], Mapping[str, Any]] | None = None,
children: Mapping[str, ChildSpec] | None = None,
relations: Mapping[str, RelationSpec] | None = None,
) -> Callable[..., ModelT]
Return a service callable that builds model from validated input.
Equivalent to writing the canonical glue stub by hand:
def create_author(*, data: AuthorIn, **_: Any) -> Author:
return create_from_input(Author, data).instance
field_map and exclude_fields are forwarded to
create_from_input.
m2m accepts either a static mapping (passed straight through) or a
callable that receives the validated data and returns the mapping —
the common case where M2M values live on the input dataclass / dict
itself:
create_model(
Post,
m2m=lambda data: {"tags": data.tags},
)
relations (and its reverse-FK alias children) is forwarded to
create_from_input to write
nested relations declaratively (no hand-written service):
create_model(
Author,
relations={"books": ChildSpec(model=Book, fk="author")},
)
The returned closure accepts **kwargs so the framework's kwargs pool
(request, user, URL kwargs, ServiceSpec.kwargs returns) is absorbed
without the service caring — matching the unified
CreateService
Protocol's default ExtraT (open extras). That same pool is handed on as the
helper's context=, so a per-child service declared on a
ChildSpec can see who is
calling.
update_model¶
update_model ¶
update_model(
model: type[ModelT],
*,
field_map: dict[str, str] | None = None,
exclude_fields: list[str] | None = None,
m2m: Mapping[str, Any] | Callable[[Any], Mapping[str, Any]] | None = None,
update_fields: bool | list[str] = True,
children: Mapping[str, ChildSpec] | None = None,
relations: Mapping[str, RelationSpec] | None = None,
) -> Callable[..., ModelT]
Return a service callable that updates the resolved instance in place.
Equivalent to:
def update_author(*, instance: Author, data: AuthorIn, **_: Any) -> Author:
return update_from_input(instance, data).instance
model is accepted for symmetry with
create_model /
delete_model and to
bind ModelT for the type checker; the instance itself comes from the view's
get_object(). field_map, exclude_fields, m2m, update_fields, and
children are forwarded to
update_from_input.
m2m accepts either a static mapping or a callable receiving the validated
data (see
create_model for the
common shape); children reconciles reverse-FK collections from
data[relation] per its
ChildSpec.
The rest of the framework's kwargs pool is handed on as the helper's context=,
so a per-child service declared on a ChildSpec can see who is calling (see
create_model).
delete_model¶
delete_model ¶
delete_model(
model: type[ModelT],
*,
soft_delete: Callable[[ModelT], None] | None = None,
children: Mapping[str, ChildSpec] | None = None,
relations: Mapping[str, RelationSpec] | None = None,
) -> Callable[..., None]
Return a service callable that deletes the resolved instance.
Equivalent to:
def delete_author(*, instance: Author, **_: Any) -> None:
instance.delete()
model is accepted for symmetry / type binding; the instance comes
from the view's get_object().
soft_delete is an optional hook called instead of
instance.delete() — covers the common archive case:
def _archive(instance: Author) -> None:
instance.is_archived = True
instance.save(update_fields=["is_archived"])
delete_model(Author, soft_delete=_archive)
relations (and its reverse-FK alias children) declares what to
remove before the parent goes, deepest first. Use it to cascade
explicitly when the database can't: a PROTECT relation, or a
soft_delete Django never cascades through because no row is deleted.
The same map the write path takes, and the same one rule applies to every
kind: the cascade removes the rows the parent owns and leaves the rows it
merely points at alone. A reverse-FK collection, a reverse one-to-one and
a generic relation are the parent's rows and go, nullable links unlinked
and the rest deleted; a many-to-many loses only its membership, since the
targets are shared; a forward relation is left untouched, because the
column holding it goes with the parent. The specs' write-only fields
(match_key / mode / field_map / m2m) are ignored here.
The rest of the framework's kwargs pool is handed on as delete_relations's
context=, so a per-row service declared on a spec can see who is calling (see
create_model).
acreate_model¶
acreate_model ¶
acreate_model(
model: type[ModelT],
*,
field_map: dict[str, str] | None = None,
exclude_fields: list[str] | None = None,
m2m: Mapping[str, Any] | Callable[[Any], Mapping[str, Any]] | None = None,
children: Mapping[str, ChildSpec] | None = None,
relations: Mapping[str, RelationSpec] | None = None,
) -> Callable[..., Awaitable[ModelT]]
Async sibling of
create_model.
Returns an async def closure that wraps
acreate_from_input.
The framework's is_async detection
routes it through the async dispatch path automatically. children is forwarded
for declarative reverse-FK writes, and the rest of the kwargs pool as context=
(see create_model).
aupdate_model¶
aupdate_model ¶
aupdate_model(
model: type[ModelT],
*,
field_map: dict[str, str] | None = None,
exclude_fields: list[str] | None = None,
m2m: Mapping[str, Any] | Callable[[Any], Mapping[str, Any]] | None = None,
update_fields: bool | list[str] = True,
children: Mapping[str, ChildSpec] | None = None,
relations: Mapping[str, RelationSpec] | None = None,
) -> Callable[..., Awaitable[ModelT]]
Async sibling of
update_model.
adelete_model¶
adelete_model ¶
adelete_model(
model: type[ModelT],
*,
soft_delete: Callable[[ModelT], Awaitable[None]] | None = None,
children: Mapping[str, ChildSpec] | None = None,
relations: Mapping[str, RelationSpec] | None = None,
) -> Callable[..., Awaitable[None]]
Async sibling of
delete_model.
Calls await instance.adelete() by default (Django 4.1+; the package floor is
4.2, so this is always available). soft_delete is an optional async hook
called instead of adelete. relations (and its reverse-FK alias children)
removes what the parent owns first, by the one rule
delete_model states,
with the rest of the kwargs pool handed on as context=.
delete_collection¶
delete_collection ¶
delete_collection(
model: type[ModelT], *, soft_delete: Callable[[Any], None] | None = None
) -> Callable[..., None]
Return a service that deletes the resolved collection (bulk).
Pairs with ServiceSpec.collection_selector_spec, which seeds the
target set into the pool as collection. Equivalent to:
def delete_books(*, collection, **_: Any) -> None:
collection.delete()
The default calls collection.delete() — a single queryset bulk delete. An
empty collection is a no-op (deletes nothing), so the action is idempotent.
model is accepted for symmetry / type binding (like
delete_model); the
set itself comes from the spec.
soft_delete is an optional hook called with the collection instead
of delete() — e.g. lambda qs: qs.update(is_archived=True).
adelete_collection¶
adelete_collection ¶
adelete_collection(
model: type[ModelT], *, soft_delete: Callable[[Any], Awaitable[None]] | None = None
) -> Callable[..., Awaitable[None]]
Async sibling of
delete_collection.
Calls await collection.adelete() by default (Django 4.1+; the package
floor is 4.2). soft_delete is an optional async hook called with the
collection instead.
Decorators¶
implements ¶
Identity decorator: assert fn structurally matches proto.
proto is a parameterised service or selector Protocol:
@implements(CreateService[AuthorIn, Author])
def create_author(
*,
data: AuthorIn,
**extras: Any,
) -> Author: ...
Strict-typed extras stay on your function: declare a TypedDict with
NotRequired keys (so the function still conforms to a Protocol whose caller may
not supply those keys) and annotate **extras: Unpack[YourKw]. The Protocol
itself does not carry an extras-shape parameter — see
CreateService for
the rationale.
Drift between the decorated function and proto is reported at the
decorator line by ty. mypy refuses type[Protocol] arguments (the
type-abstract rule); mypy users either silence that with
# type: ignore[type-abstract] or keep using the legacy
_: CreateService[...] = create_author shim alongside the def.
Returns the function unchanged at runtime.
Helpers¶
call_service¶
call_service ¶
call_service(
service: Callable[..., ResultT],
*,
request: Request,
data: Any = UNSET,
instance: Any = UNSET,
map_errors: bool = False,
**extras: Any,
) -> ResultT
Invoke service with the framework's kwargs pool.
For a view, middleware, or custom action delegating to a service wired to a
different action: the helper builds the pool the framework would build,
filters it against the service's signature, and dispatches sync-or-async so
the caller need not know which — async services are bridged through
async_to_sync, sync services called inline. Outside HTTP scope (Celery
tasks, management commands) call the service directly with whatever kwargs
you have; this helper is not the right tool there.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
service
|
Callable[..., ResultT]
|
The service callable, sync or async. |
required |
request
|
Request
|
Required — the helper is HTTP-scoped by design. |
required |
data
|
Any
|
Passed into the pool when not |
UNSET
|
instance
|
Any
|
Passed into the pool when not |
UNSET
|
map_errors
|
bool
|
Translate a raised
|
False
|
**extras
|
Any
|
Merged into the pool; the signature filter
( |
{}
|
acall_service¶
acall_service
async
¶
acall_service(
service: Callable[..., ResultT] | Callable[..., Awaitable[ResultT]],
*,
request: Request,
data: Any = UNSET,
instance: Any = UNSET,
map_errors: bool = False,
**extras: Any,
) -> ResultT
Invoke service from async code with the framework's kwargs pool.
Same contract as
call_service,
map_errors included. Async services are awaited directly; a sync service is
called inline with no thread hop, so the caller owns any sync-side I/O safety.
**extras never overrides a seed this helper derives, exactly as in the sync
twin: a user key spread in from client-supplied data is dropped rather than
replacing request.user.