Actions, services, and workflows
fastsprout.actions defines application operations with typed inputs,
results, and dependencies. Call an action directly, group related actions
in a service, or compose them into a workflow. The same operation can be
used from an HTTP handler, CLI command, or event subscriber. This keeps
application checks and behavior in one callable operation as the number of
entry points grows. See Features for the problems the layers
address together. For a guided introduction, start with
one action, then add checks, services, persistence,
and a workflow through the seven tutorial steps.
Install FastSprout 0.2.0 from PyPI with the standard extra, or follow the tutorial setup:
| Use | Start with |
|---|---|
| One async operation | @action or Action[Input, Output] |
| Related operations with shared dependencies | Service or an @action class |
| CRUD operations for an entity | DataS |
| A pipeline of typed operations | Workflow |
Function actions
Decorate an async function with @action. Its parameter and result types
remain available to the type checker. Calling it runs its dependencies and
returns an awaitable result:
"""Action — function with Pydantic objects."""
import asyncio
from typing import assert_type
from fastsprout.actions import action
from fastsprout.core import BaseSchema, Field
class HeroIn(BaseSchema):
name: Field[str]
class HeroOut(BaseSchema):
name: Field[str]
@action
async def create_hero(payload: HeroIn) -> HeroOut:
return HeroOut(name=payload.name)
async def main() -> None:
hero = await create_hero(HeroIn(name="Batman"))
assert_type(hero, HeroOut)
print("HeroOut:", hero)
if __name__ == "__main__":
asyncio.run(main())
Save it as action_example.py and run it in the environment where you
installed FastSprout:
For a class with a single operation, inherit from Action[Input, Output]
and implement async __call__. Its input and output types must inherit
Schema: use BaseSchema, or inherit Schema in your dataclass or custom
class. See the runnable
class action,
dataclass action,
and custom object action
examples.
Services and dependency injection
Use an @action class to group public async methods. Depends resolves
collaborators, including dependencies declared as attributes:
"""Action service — standard."""
import asyncio
from typing import assert_type
from fastsprout.actions import action
from fastsprout.core import BaseSchema, Depends, Field
class Document(BaseSchema):
content: Field[str]
class SignedDocument(BaseSchema):
content: Field[bytes]
signature: Field[str]
class DocumentRenderer:
async def render(self, document: Document) -> bytes:
return document.content.encode()
class Signer:
async def sign(self, content: bytes) -> str:
return f"signed:{len(content)}"
@action
class DocumentService:
renderer: DocumentRenderer = Depends()
signer: Signer = Depends()
async def render(self, document: Document) -> bytes:
return await self.renderer.render(document)
async def sign(self, document: Document) -> SignedDocument:
content = await self.render(document)
return SignedDocument(
content=content,
signature=await self.signer.sign(content),
)
async def main() -> None:
documents = DocumentService()
document = Document(content="FastSprout")
content = await documents.render(document)
assert_type(content, bytes)
signed = await documents.sign(document)
assert_type(signed, SignedDocument)
print(signed)
if __name__ == "__main__":
asyncio.run(main())
Inheriting from Service provides the same action-group behavior without
applying the decorator. See the
Service example.
Use dependencies=[Depends(check)] on an action or service to run checks
before its body. An exception stops the call. Dependencies can use the
call's arguments by name and request other dependencies. The
dependencies guide covers shared checks and overrides.
Dependency resolution also works inside an invocation context. The runnable dependency context and injection context examples show how to share context with nested calls.
Entity services with DataS
DataS(Entity) is an application service for an entity. It replaces repeated
CRUD handlers with typed operations backed by DataR. DataR routes those
operations to persistence and shares their backend context. The
data guide explains their responsibilities. The
SQLite tutorial introduces entity operations
before adding a shared transaction and queries.
DataS uses the entity as its default read and write schema. Explicit read, create,
update, and patch schemas let the service expose separate contracts for
each operation. The operations setting controls which operations are
available.
| Operations | Purpose |
|---|---|
get, exists, count |
Look up an entity or inspect matching records |
list, find_first, find_exactly_one, find_all, stream |
Read pages, individual results, or streams |
create, update, patch, delete, upsert |
Write individual records |
bulk_create, bulk_update, bulk_patch, bulk_delete, bulk_upsert |
Process batches |
update_by_query, delete_by_query |
Write records selected by a query |
map, join |
Compose reads |
Page[ReadSchema] carries items, total, offset, and limit for
paginated reads. Streaming operations keep iteration lazy. Backend lifetime
and transaction boundaries belong to the data context used by the service.
Start with the runnable
CRUD service,
then see custom schemas.
The Actions API documents the service methods and
DataSable protocol.
Workflows
Create a Workflow from its first operation, then append steps with
.then(...). Each step receives the preceding step's result. Calling the
workflow returns an async generator; execution starts when you iterate it:
"""Compose three typed actions in a linear workflow.
Each result becomes the next action input, including the rendered document."""
import asyncio
from fastsprout.actions import Workflow, action
from fastsprout.core import BaseSchema
class Document(BaseSchema):
content: str
class RenderedDocument(BaseSchema):
content: bytes
@action
async def parse(document: Document) -> Document:
return Document(content=document.content.strip())
@action
async def validate(document: Document) -> Document:
if not document.content:
raise ValueError("Document is empty")
return document
@action
async def render(document: Document) -> RenderedDocument:
return RenderedDocument(content=document.content.encode())
workflow = Workflow(parse).then(validate).then(render)
async def main() -> None:
async for result in workflow(Document(content="FastSprout")):
print(result)
if __name__ == "__main__":
asyncio.run(main())
Workflow inputs and outputs are schemas. A step can return one schema,
None, a synchronous or asynchronous iterable of schemas, or an awaitable
of a supported result. None emits no result. Each emitted item enters the
next step separately, so a workflow may yield zero, one, or many outputs.
Iteration releases resources when it completes, fails, or is closed. Close
the async generator when you stop consuming it early, for example with
contextlib.aclosing.
| Composition | Behavior |
|---|---|
.then(next_action) |
Pass each result to the next operation |
.fork(left, right) |
Run two async branches concurrently and emit ForkResult(left=..., right=...) |
.join(next_action) |
Continue with an operation that accepts the combined result |
.branch(SchemaType, handler) |
Route by result type; the first matching branch wins |
An unmatched typed branch raises LookupError. Exceptions from a step
propagate to the caller. See the runnable workflow examples for
linear composition,
then browse the example catalog for forks,
branching, streaming, and shared dependencies.
See Actions API for the public types and signatures.