Warning
Pre-release. This is an early prototype: APIs are unstable and will change without notice, and there are no published releases yet.
A simple, modern, type-safe actor framework for asynchronous, fault-tolerant systems.
tractor gives you isolated, single-threaded actors that communicate only by
message passing on top of asyncio. Messages are ordinary typed objects, so
your editor and type checker understand exactly which actor a message targets
and what it replies with — no Any, no string dispatch.
- Type-safe messaging —
Message[A, R]ties a message to the actor typeAit's sent to and the reply typeRit produces.askreturnsR;tellreturns nothing. Mismatches are caught statically. ask/tellsemantics — request/reply or fire-and-forget, plus non-blockingtry_ask/try_telland reply forwarding for delegation.- Lifecycle hooks —
on_start,on_stop, andon_panicwith a per-actorControlFlowdecision (stop or keep running). - Fault tolerance — panics are isolated to the actor and reported to a
pluggable
CrashPolicy. - Custom work sources — override
stepto await timers, futures, or streams alongside the inbox using the biasedselect. - Fully typed — ships
py.typed; built around PEP 695 generics.
- Python 3.14+ (the API uses recent typing features).
Not yet published to PyPI (note that the tractor name on PyPI currently
belongs to an unrelated project — do not pip install tractor expecting this
library). For now, install from source:
uv add git+https://github.com/ShaneEverittM/tractor
# or
pip install git+https://github.com/ShaneEverittM/tractorimport asyncio
from dataclasses import dataclass
from typing import override
from tractor import Actor, Context, Message, Runtime
class Counter(Actor):
def __init__(self) -> None:
self.count = 0
@dataclass
class Increment(Message[Counter, None]):
by: int = 1
@override
async def dispatch(self, actor: Counter, ctx: Context[Counter]) -> None:
actor.count += self.by
@dataclass
class Get(Message[Counter, int]):
@override
async def dispatch(self, actor: Counter, ctx: Context[Counter]) -> int:
return actor.count
async def main() -> None:
runtime = Runtime()
counter = runtime.spawn(Counter())
await runtime.tell(counter, Increment(by=3)) # fire-and-forget
await runtime.tell(counter, Increment())
total = await runtime.ask(counter, Get()) # request/reply -> int
print(total) # 4
await counter.stop()
if __name__ == "__main__":
asyncio.run(main())Actor— your state lives here. Subclass it and add plain methods; lifecycle hooks (on_start/on_stop/on_panic) andstepare all optional with sensible defaults.Message[A, R]— a typed, dispatchable message. Implementdispatchto invoke the actor and produce the replyR.Runtime— created once at startup;spawns actors and routesask/tell. Inside a handler, usectx.ask/ctx.tellso sender identity is carried through.ActorRef[A]— an opaque handle to a spawned actor; the only way to address it.CrashPolicy— observer invoked after every panic (defaults to logging).
tractor borrows its shape from two places: kameo,
a typed Rust actor library on tokio, which informs the Actor lifecycle
(on_start / on_stop / on_panic) and the typed ask/tell surface; and an
internal Rust actor runtime, which informs the explicit Runtime that every
message passes through.
One deliberate inversion from kameo: Rust declares message handling on the
actor (impl Message<M> for A, with an associated reply type); tractor
declares it on the message (Message[A, R].dispatch). Python has no trait
impls, so the message is the one place the actor/reply relationship can be
stated once and checked everywhere. The trade-off is generic bounds: where Rust
can constrain "any actor handling M1 and M2", tractor spells the
single-message case with Sender[M, R] (kameo's Recipient<M>) or a signature
generic over A shared by ActorRef[A] and Message[A, R], and multi-message
capabilities with a common Actor base class.
This design is also why tractor requires Python 3.14+: typed messages lean
on PEP 695 class-scoped generics, @override, Self, and strict variance
checking — the pieces that make a statically-typed actor protocol expressible
in Python at all.
See examples/ for a runnable pub/sub demo covering fanout,
lifecycle hooks, and a custom step heartbeat:
python examples/pub_sub.pyThis repo uses uv and provides a Nix flake.
# with uv
uv sync
uv run pytest
# with Nix (provides Python 3.14 + uv, tractor installed editable)
nix develop
pytestBuild the PyPI artifacts (sdist + wheel) reproducibly with Nix:
nix build # -> ./result/{tractor-*.tar.gz, tractor-*.whl}MIT © Shane Murphy