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 an await doesn'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 and return 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 get Depends(...) 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.