Plantigrade
Reactive state and effects derived from an append-only event log, in plain Python.
Every change is an event appended to a log. Atoms describe values computed from the log, like cells in a spreadsheet. Reactions look at those values and propose effects. A store owns the log, computes the atoms, and runs the effects: when an effect finishes, its result is appended to the log as another event, and the loop goes on.
dispatch reduce / project / derive
world ───────────────▶ log ──────────────────────────────▶ atoms
▲ │
│ run effect, append its events │ propose
└──────────────────────────────────────────── reactions ◀───┘
Because state is a pure function of the log, you get for free:
- Speculation: fork the store, try things, commit what worked or throw it away.
- Time travel: the state at any offset of the log is one
fork(at)away. - Declarative effects: reactions say what should be running; the store starts, keeps, restarts and cancels effects to match. Interrupting work is just changing the state that proposed it.
- Observability: the log says what happened, the atoms say what it means. The playground shows both.
Plantigrade is inspired by Tardigrade, a TypeScript framework for durable actors. It keeps
the core idea and leaves the rest out: there is no durability, no actor hosting, and no
Effect-style machinery. Events are plain dataclasses, reducers are plain functions, effects
are plain async functions, and everything is checked with ty.
A first store
import asyncio
from dataclasses import dataclass
from plantigrade import Fold, Get, Rejected, Store, derived, guard, reaction
@dataclass(frozen=True)
class Incremented:
by: int = 1
@dataclass(frozen=True)
class Decremented:
by: int = 1
# State: reduced from events. Reducers are pure: (state, event) -> state.
count = Fold(0).on(Incremented, lambda n, e: n + e.by).on(Decremented, lambda n, e: n - e.by)
# Derived values: recomputed only when what they read changes.
@derived
def parity(get: Get) -> str:
return "even" if get(count) % 2 == 0 else "odd"
# Guards: refuse an event before it reaches the log.
@guard(Decremented)
def never_negative(event: Decremented, get: Get) -> None:
if get(count) < event.by:
raise Rejected("count would go below zero")
# Effects: a reaction proposes requests, a handler runs them, its result is appended to the log.
@dataclass(frozen=True)
class Announce:
value: int
@dataclass(frozen=True)
class Announced:
value: int
announced = Fold[frozenset[int]](frozenset()).on(Announced, lambda values, e: values | {e.value})
@reaction
def announce(get: Get) -> dict[str, Announce]:
value = get(count)
if value > 0 and get(parity) == "even" and value not in get(announced):
return {"announce": Announce(value)}
return {}
async def say(request: Announce) -> Announced:
print(f"{request.value} is even!")
return Announced(request.value)
async def main() -> None:
store = Store(never_negative, announce).handle(Announce, say)
async with store: # a started store runs effects
store.dispatch(Incremented(), Incremented())
await store.settle() # prints "2 is even!"
print(store.get(count), store.get(parity)) # 2 even
asyncio.run(main())
The primitives
| What it is | Value | |
|---|---|---|
Fold(initial).on(Event, reduce) |
State reduced from events | anything |
Journal[R]().on(Event, project) |
Append-only rows projected from events, O(1) per row | Rows[R] |
@derived |
A value computed from other atoms | anything |
@reaction |
Effects proposed from atoms, as {key: request} |
Mapping[K, R] |
@guard(Event, ...) |
Refuses events before they reach the log | — |
Store(*mounted) |
Owns the log, computes atoms, runs effects | — |
Atoms are definitions: they hold no value, so they live at module level and any number of stores can share them. The store keeps the values, computes them lazily and incrementally, and only recomputes a derived atom when an atom it read last time has changed.
The full guide is in docs/concepts.md. docs/recipes.md shows patterns built from the primitives: keyed state, nested scopes, speculation, retries, streaming, fencing late results, testing.
Effects, declaratively
A reaction does not start effects; it says which ones should be running, under stable keys. After every dispatch the store reconciles the proposals with what is running:
| The reaction... | The store... |
|---|---|
| proposes a new key | starts the handler |
| keeps proposing an equal request | leaves it alone: it runs once, even after it completes |
| proposes a different request under the key | cancels the running one and starts the new one |
| stops proposing the key | cancels it |
Handlers are async functions returning an event (or None), or async generators yielding events as they come,
for streaming. A handler that raises produces an EffectFailed event, which state can react to, to retry for
instance. Cancellation is plain asyncio cancellation.
So interrupting work is a state change. In examples/chat.py, a voice assistant streams a reply sentence by sentence while a second effect plays each sentence. When the user speaks again, the turn changes, which changes both requests: generation restarts for the new turn, playback stops, and sentences that were never played drop out of the transcript.
Speculation and time travel
fork = store.fork() # a pure copy-on-write store over the same log
fork.dispatch(Planned("...")) # speculative: the parent does not see it
store.commit(fork, upto=offset) # append the fork's events up to `offset`, validated and atomic
past = store.fork(at=12) # the state after the first 12 events
Playground
uv run plantigrade playground examples/chat.py # calls make_store() in that module
uv run plantigrade playground my_module.py:build_store
The playground opens a local page with a form for every event type the store knows about, the log, the value of each atom, and the effects running. Click an event, or drag the slider, to see the state as it was at that point.
More in docs/playground.md.
Development
uv sync
uv run pytest # tests
uv run ty check # types
uv run ruff check && uv run ruff format --check
Python 3.13 or later. No runtime dependencies.
Metadata
Release files for plantigrade 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| plantigrade-0.1.0.tar.gz | 20.7 kB | With provenance | Details for plantigrade-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| plantigrade-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for plantigrade-0.1.0-py3-none-any.whl |
Total release size: 43.9 kB
Release files / plantigrade-0.1.0.tar.gz
| Download URL | plantigrade-0.1.0.tar.gz |
|---|---|
| Size | 20.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
7af22b6e55d276ba215e827ec5bf4302ddf10cc01b8d74b556f10115933faca4
|
|
BLAKE2b-256 checksum How to use checksums |
8ac33d2b910e57ae16cfb389a946209035c453bd3ce1760405285c3328c28903
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 4, 2026.
Transparency logRelease files / plantigrade-0.1.0-py3-none-any.whl
| Download URL | plantigrade-0.1.0-py3-none-any.whl |
|---|---|
| Size | 23.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f7f2b3848f2a4469c538fb50b3b537d126065b6b0abeac982153ba3c59203af6
|
|
BLAKE2b-256 checksum How to use checksums |
ce4f4dc655768ed7784a48bed2cd36de5945d2cc4bbfa9c533bbfb0cf6c35c20
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 4, 2026.
Transparency logSecurity for plantigrade 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for plantigrade-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 4, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.