Skip to content
Pyrula
Pre-release: the packages are not on PyPI yet, so pip install does not resolve. Build from source to try it today.

Coming from Temporal

Pyrula has counterparts for Temporal’s workflow model: replay from history, activities as journaled steps, signals, queries, updates, child runs, continue-as-new, patching and schedules. There is no server or cluster; workers share a store and the journal lives there.

There is no pyrula init --temporal. Start from the workflow scaffold:

Terminal window
pyrula init --workflow
python app.py
TemporalPyrulaChange
@workflow.defn class, @workflow.run@workflow on an async def; parameters travel as JSONSmall rewrite
Activity, start_to_close_timeout@step, or await ctx.step(name, fn); @step(timeout=) bounds one trySmall rewrite
Activity retry policyRetryPolicy; Fatal and Poison never retrySmall rewrite
schedule_to_close, heartbeatsNone; the worker renews the run’s lease itselfNot supported
Whole-workflow deadlineNone; @workflow(timeout=) bounds one attemptNot supported
workflow.sleepawait ctx.sleep(duration), ctx.sleep_until(dt)Direct swap
Signal handler, wait_conditionawait ctx.wait_for_signal(name, timeout=...); no predicate waitSmall rewrite
Signalling another workflowctx.send_signal(target_name, target_run_id, signal_name, payload)Direct swap
@workflow.queryctx.on_query(name, handler); handle.query(name) writes nothingSmall rewrite
@workflow.update, validatorctx.on_update(name, handler, validator); the handler must not suspendSmall rewrite
Child workflow, ParentClosePolicyctx.run_child(name, params, parent_close="cancel"), "terminate" or "abandon"Direct swap
Continue-as-newawait ctx.continue_as_new(params); ctx.continue_suggested at 80% of max_event_count_per_runDirect swap
workflow.patched, deprecate_patchawait ctx.patched(id), await ctx.deprecate_patch(id)Direct swap
Worker Versioning@workflow(version=..., version_behavior="pinned" or "auto_upgrade") (Versioning)Conceptual redesign
workflow.now(), uuid4(), random()ctx.now() (Unix seconds as a float), ctx.uuid(), ctx.random()Small rewrite
Workflow id reuse policysubmit(idempotency_key=...); a repeat returns the first run for 24 hours by defaultSmall rewrite
Cancel, terminatehandle.cancel() at the next step boundary; handle.terminate() nowDirect swap
Reset to an eventhandle.retry() starts a new run that serves recorded steps; no event to reset toConceptual redesign
SchedulesSchedule.create(name, cron=... or every=..., timezone=, overlap=, catchup_window=); no backfillSmall rewrite
Task queuesNone; a worker claims runs of the workflows it registersNot supported
Search attributes, List Filtersubmit(metadata=...); list_runs(name, owner=) has no field filterNot supported
Time-skipping test environmentWorkflowTestEnv from pyrula.workflows.testingSmall rewrite
The replayerassert_replay_compatible(workflow, history) and replay in CIDirect swap
NexusNone; use ctx.run_child or ctx.send_signalNot supported
Server, cluster, UIA shared store, pyrula dev, pyrula runs, Pyrula CloudOperational change

One order run with a retried activity, a query, a signal, a patched branch and a one-day timer the test environment skips:

import asyncio
from datetime import timedelta
from pyrula.workflows import RetryPolicy, WorkflowContext, step, workflow
from pyrula.workflows.testing import WorkflowTestEnv
attempts: list[int] = []
# An activity with a retry policy.
@step(retry=RetryPolicy(max_attempts=3, initial_backoff=0.01, retryable=(ConnectionError,)))
async def reserve(sku: str) -> str:
attempts.append(1)
if len(attempts) == 1:
raise ConnectionError("warehouse unreachable")
return f"reserved {sku}"
@workflow(name="docs_from_temporal_order")
async def order(ctx: WorkflowContext, sku: str) -> dict:
stage = {"now": "reserving"}
ctx.on_query("stage", lambda: stage["now"]) # @workflow.query
reserved = await reserve(sku)
stage["now"] = "waiting for payment"
paid = await ctx.wait_for_signal("paid", timeout=timedelta(days=2)) # @workflow.signal
if await ctx.patched("label-v2"): # workflow.patched
label = await ctx.step("label_v2", lambda: f"v2 label for {sku}")
else:
label = await ctx.step("label", lambda: f"label for {sku}")
await ctx.sleep(timedelta(days=1)) # workflow.sleep
return {"reserved": reserved, "paid_by": paid.payload["by"], "label": label}
async def main() -> None:
env = WorkflowTestEnv()
run = await env.start(order, {"sku": "A-1"})
await env.run_until_next_block(run) # runs to the signal wait
assert await run.query("stage") == "waiting for payment"
await run.signal("paid", {"by": "card"})
await env.run_until_complete(run) # skips the one-day sleep
assert await run.result(timeout=30) == {
"reserved": "reserved A-1",
"paid_by": "card",
"label": "v2 label for A-1",
}
assert len(attempts) == 2
asyncio.run(main())

A child run is a separate run with its own journal, started by a worker or by the test environment:

@workflow(name="docs_from_temporal_ship")
async def ship(ctx: WorkflowContext, order_id: str) -> str:
return await ctx.step("label", lambda: f"label for {order_id}")
@workflow(name="docs_from_temporal_fulfil")
async def fulfil(ctx: WorkflowContext, order_id: str) -> str:
await ctx.wait_for_signal("paid")
return await ctx.run_child(
"docs_from_temporal_ship", {"order_id": order_id}, timeout=60, parent_close="abandon"
)
async def fulfil_one() -> None:
env = WorkflowTestEnv()
run = await env.start(fulfil, {"order_id": "o-1"})
await env.run_until_complete(run) # parks on the signal wait
await run.signal("paid", {})
assert await run.result(timeout=30) == "label for o-1"
asyncio.run(fulfil_one())

View this page as Markdown