Python Authoring
The conceptual model - workflows as durable control flow, actions as distributed work - is the same in every language Waymark targets. This page covers the Python-specific surface: how arguments and return values get serialized, how dependency injection works, how the worker discovers your handlers.
If you haven't yet, the Quickstart is the fastest way to get a working setup. The Concepts section covers the behavior of workflows and actions in language-neutral terms.
Inputs and outputs
Action arguments and return values round-trip through Postgres. They need to survive serialization. In Python that means pydantic models or plain types pydantic understands (primitives, lists, dicts, dataclasses with pydantic-compatible types, etc.).
For anything richer than a couple of scalars, declare a pydantic model:
from pydantic import BaseModel, Field
from waymark import action
class FetchUsersRequest(BaseModel):
user_ids: list[str] = Field(min_length=1)
include_deactivated: bool = False
class FetchUsersResult(BaseModel):
users: list[User]
fetched_at: datetime
@action
async def fetch_users(request: FetchUsersRequest) -> FetchUsersResult:
users = await db.user.fetch_many(
ids=request.user_ids,
include_deactivated=request.include_deactivated,
)
return FetchUsersResult(users=users, fetched_at=datetime.utcnow())
This is the same shape FastAPI uses for request/response models - if you're coming from there, the migration is essentially free.
For tiny actions, plain types are fine:
@action
async def compute_factorial(n: int) -> int:
...
Type hints and validation
Action signatures are type-hinted Python. Waymark uses those hints both for static checking (mypy / pyright understand them as you'd expect) and at runtime - when an action is dispatched, its arguments are validated against the declared types before the handler runs. A malformed payload raises an error during the action attempt, not in your handler body.
Workflow run() signatures work the same way. Inputs you pass to
workflow.run(...) are validated against the run() signature when
the workflow is queued.
Dependency injection
Actions can declare dependencies the same way Mountaineer and FastAPI
controllers do - via Annotated[T, Depends(provider)] parameters:
from typing import Annotated
from waymark import Depends, action
@action
async def send_email(
to: str,
subject: str,
emailer: Annotated[EmailClient, Depends(get_email_client)],
) -> EmailResult:
return await emailer.send(to=to, subject=subject)
Depends is re-exported from mountaineer-di. The older Depend
spelling remains as a backward-compatible alias.
A dependency is resolved per action invocation by the worker that claims the row. That means the lifecycle of any per-request resource (database session, scoped HTTP client) is handled the same way you'd handle it inside a web handler. If your provider is itself async, that works - the resolver awaits it.
Workflow bodies do not take injected dependencies. They don't run on a worker; they ran once at compile time to produce IR. If your workflow needs a database handle, fetch it inside an action and pass the result through.
Async / await
Both actions and workflow run() methods are async. Waymark only
dispatches async handlers - synchronous functions aren't supported as
actions. If you have legacy sync code, wrap it inside an async action
that calls asyncio.to_thread(...) to push the blocking work off the
event loop:
import asyncio
@action
async def render_pdf(report: Report) -> bytes:
return await asyncio.to_thread(_render_pdf_sync, report)
Inside a workflow body, await on an action call is what tells the
compiler to lower it to a DAG node. Bare action calls (no await) and
synchronous helpers aren't recognized - they're flagged at registration
time.
asyncio.gather(...) and asyncio.sleep(...) are recognized as
control-flow primitives by the compiler. See
Control Flow for the full list of supported
patterns.
Module preloading on workers
Workers need to import the modules where your @action and @workflow
decorators run, so they have handlers registered before they start
pulling rows off the queue. Tell them where to look with
WAYMARK_USER_MODULE (comma-separated for multiple modules):
export WAYMARK_USER_MODULE=myapp.workflows
uv run waymark-start-workers
A worker that boots without seeing your decorators won't fail loudly - it'll just sit there and never claim anything. If you queue a workflow and nothing happens, that's the first thing to check.
For a multi-module project, list them all:
export WAYMARK_USER_MODULE=myapp.workflows.billing,myapp.workflows.email
Where workflows differ from actions
A few Python-specific things worth knowing about workflow run()
bodies that don't apply to actions:
- No f-strings against action results.
f"{result}"after anawaitdoesn't compile. Build the formatted value inside a follow-up action. - No constructor calls for return values.
return MyResult(...)at the end of a workflow doesn't compile. Move the construction into a dedicated "build result" action andreturn await build_result(...)instead. - No module-level globals. References to module-level names from inside the workflow body are flagged at registration. Pass them in as workflow inputs or fetch them inside an action.
- No injected dependencies. Workflow
run()doesn't getDepends(...)resolution. The body runs once at compile time, not per request.
The compiler diagnostic on registration tells you the offending node and offers a fix. The Control Flow guide walks through the patterns that are supported, with examples for each.