Skip to main content

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.

The playground on examples/chat.py, while a reply is being generated and played

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)

Source distribution for plantigrade 0.1.0
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)

Table of built distributions (wheels) for plantigrade 0.1.0
File Interpreter ABI Platform Actions
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 log

Release 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 log

Security 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.

Select from 2 files

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 log

A 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.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page