plantigrade 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.4
2
+ Name: plantigrade
3
+ Version: 0.1.0
4
+ Summary: Reactive state and effects derived from an append-only event log.
5
+ Author: Félix Martel
6
+ Author-email: Félix Martel <22093661+felix-martel@users.noreply.github.com>
7
+ License-Expression: MIT
8
+ Requires-Python: >=3.13
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Plantigrade
12
+
13
+ Reactive state and effects derived from an append-only event log, in plain Python.
14
+
15
+ Every change is an event appended to a log. **Atoms** describe values computed from the log, like cells in a
16
+ spreadsheet. **Reactions** look at those values and propose effects. A **store** owns the log, computes the atoms, and
17
+ runs the effects: when an effect finishes, its result is appended to the log as another event, and the loop goes on.
18
+
19
+ ```
20
+ dispatch reduce / project / derive
21
+ world ───────────────▶ log ──────────────────────────────▶ atoms
22
+ ▲ │
23
+ │ run effect, append its events │ propose
24
+ └──────────────────────────────────────────── reactions ◀───┘
25
+ ```
26
+
27
+ Because state is a pure function of the log, you get for free:
28
+
29
+ - **Speculation**: fork the store, try things, commit what worked or throw it away.
30
+ - **Time travel**: the state at any offset of the log is one `fork(at)` away.
31
+ - **Declarative effects**: reactions say *what* should be running; the store starts, keeps, restarts and cancels
32
+ effects to match. Interrupting work is just changing the state that proposed it.
33
+ - **Observability**: the log says what happened, the atoms say what it means. The [playground](#playground) shows both.
34
+
35
+ Plantigrade is inspired by [Tardigrade](https://tardigrade.sh), a TypeScript framework for durable actors. It keeps
36
+ the core idea and leaves the rest out: there is no durability, no actor hosting, and no
37
+ [Effect](https://effect.website)-style machinery. Events are plain dataclasses, reducers are plain functions, effects
38
+ are plain `async` functions, and everything is checked with [ty](https://github.com/astral-sh/ty).
39
+
40
+ ## A first store
41
+
42
+ ```python
43
+ import asyncio
44
+ from dataclasses import dataclass
45
+
46
+ from plantigrade import Fold, Get, Rejected, Store, derived, guard, reaction
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Incremented:
51
+ by: int = 1
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Decremented:
56
+ by: int = 1
57
+
58
+
59
+ # State: reduced from events. Reducers are pure: (state, event) -> state.
60
+ count = Fold(0).on(Incremented, lambda n, e: n + e.by).on(Decremented, lambda n, e: n - e.by)
61
+
62
+
63
+ # Derived values: recomputed only when what they read changes.
64
+ @derived
65
+ def parity(get: Get) -> str:
66
+ return "even" if get(count) % 2 == 0 else "odd"
67
+
68
+
69
+ # Guards: refuse an event before it reaches the log.
70
+ @guard(Decremented)
71
+ def never_negative(event: Decremented, get: Get) -> None:
72
+ if get(count) < event.by:
73
+ raise Rejected("count would go below zero")
74
+
75
+
76
+ # Effects: a reaction proposes requests, a handler runs them, its result is appended to the log.
77
+ @dataclass(frozen=True)
78
+ class Announce:
79
+ value: int
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class Announced:
84
+ value: int
85
+
86
+
87
+ announced = Fold[frozenset[int]](frozenset()).on(Announced, lambda values, e: values | {e.value})
88
+
89
+
90
+ @reaction
91
+ def announce(get: Get) -> dict[str, Announce]:
92
+ value = get(count)
93
+ if value > 0 and get(parity) == "even" and value not in get(announced):
94
+ return {"announce": Announce(value)}
95
+ return {}
96
+
97
+
98
+ async def say(request: Announce) -> Announced:
99
+ print(f"{request.value} is even!")
100
+ return Announced(request.value)
101
+
102
+
103
+ async def main() -> None:
104
+ store = Store(never_negative, announce).handle(Announce, say)
105
+ async with store: # a started store runs effects
106
+ store.dispatch(Incremented(), Incremented())
107
+ await store.settle() # prints "2 is even!"
108
+ print(store.get(count), store.get(parity)) # 2 even
109
+
110
+
111
+ asyncio.run(main())
112
+ ```
113
+
114
+ ## The primitives
115
+
116
+ | | What it is | Value |
117
+ |---|---|---|
118
+ | `Fold(initial).on(Event, reduce)` | State reduced from events | anything |
119
+ | `Journal[R]().on(Event, project)` | Append-only rows projected from events, O(1) per row | `Rows[R]` |
120
+ | `@derived` | A value computed from other atoms | anything |
121
+ | `@reaction` | Effects proposed from atoms, as `{key: request}` | `Mapping[K, R]` |
122
+ | `@guard(Event, ...)` | Refuses events before they reach the log | — |
123
+ | `Store(*mounted)` | Owns the log, computes atoms, runs effects | — |
124
+
125
+ Atoms are *definitions*: they hold no value, so they live at module level and any number of stores can share them.
126
+ The store keeps the values, computes them lazily and incrementally, and only recomputes a derived atom when an atom it
127
+ read last time has changed.
128
+
129
+ The full guide is in [docs/concepts.md](docs/concepts.md). [docs/recipes.md](docs/recipes.md) shows patterns built
130
+ from the primitives: keyed state, nested scopes, speculation, retries, streaming, fencing late results, testing.
131
+
132
+ ## Effects, declaratively
133
+
134
+ A reaction does not *start* effects; it says which ones *should be running*, under stable keys. After every dispatch
135
+ the store reconciles the proposals with what is running:
136
+
137
+ | The reaction... | The store... |
138
+ |---|---|
139
+ | proposes a new key | starts the handler |
140
+ | keeps proposing an equal request | leaves it alone: it runs once, even after it completes |
141
+ | proposes a different request under the key | cancels the running one and starts the new one |
142
+ | stops proposing the key | cancels it |
143
+
144
+ Handlers are `async` functions returning an event (or `None`), or async generators yielding events as they come,
145
+ for streaming. A handler that raises produces an `EffectFailed` event, which state can react to, to retry for
146
+ instance. Cancellation is plain `asyncio` cancellation.
147
+
148
+ So interrupting work is a state change. In [examples/chat.py](examples/chat.py), a voice assistant streams a reply
149
+ sentence by sentence while a second effect plays each sentence. When the user speaks again, the turn changes, which
150
+ changes both requests: generation restarts for the new turn, playback stops, and sentences that were never played
151
+ drop out of the transcript.
152
+
153
+ ## Speculation and time travel
154
+
155
+ ```python
156
+ fork = store.fork() # a pure copy-on-write store over the same log
157
+ fork.dispatch(Planned("...")) # speculative: the parent does not see it
158
+ store.commit(fork, upto=offset) # append the fork's events up to `offset`, validated and atomic
159
+ past = store.fork(at=12) # the state after the first 12 events
160
+ ```
161
+
162
+ ## Playground
163
+
164
+ ```sh
165
+ uv run plantigrade playground examples/chat.py # calls make_store() in that module
166
+ uv run plantigrade playground my_module.py:build_store
167
+ ```
168
+
169
+ The playground opens a local page with a form for every event type the store knows about, the log, the value of
170
+ each atom, and the effects running. Click an event, or drag the slider, to see the state as it was at that point.
171
+
172
+ ![The playground on examples/chat.py, while a reply is being generated and played](docs/assets/playground.png)
173
+
174
+ More in [docs/playground.md](docs/playground.md).
175
+
176
+ ## Development
177
+
178
+ ```sh
179
+ uv sync
180
+ uv run pytest # tests
181
+ uv run ty check # types
182
+ uv run ruff check && uv run ruff format --check
183
+ ```
184
+
185
+ Python 3.13 or later. No runtime dependencies.
@@ -0,0 +1,175 @@
1
+ # Plantigrade
2
+
3
+ Reactive state and effects derived from an append-only event log, in plain Python.
4
+
5
+ Every change is an event appended to a log. **Atoms** describe values computed from the log, like cells in a
6
+ spreadsheet. **Reactions** look at those values and propose effects. A **store** owns the log, computes the atoms, and
7
+ runs the effects: when an effect finishes, its result is appended to the log as another event, and the loop goes on.
8
+
9
+ ```
10
+ dispatch reduce / project / derive
11
+ world ───────────────▶ log ──────────────────────────────▶ atoms
12
+ ▲ │
13
+ │ run effect, append its events │ propose
14
+ └──────────────────────────────────────────── reactions ◀───┘
15
+ ```
16
+
17
+ Because state is a pure function of the log, you get for free:
18
+
19
+ - **Speculation**: fork the store, try things, commit what worked or throw it away.
20
+ - **Time travel**: the state at any offset of the log is one `fork(at)` away.
21
+ - **Declarative effects**: reactions say *what* should be running; the store starts, keeps, restarts and cancels
22
+ effects to match. Interrupting work is just changing the state that proposed it.
23
+ - **Observability**: the log says what happened, the atoms say what it means. The [playground](#playground) shows both.
24
+
25
+ Plantigrade is inspired by [Tardigrade](https://tardigrade.sh), a TypeScript framework for durable actors. It keeps
26
+ the core idea and leaves the rest out: there is no durability, no actor hosting, and no
27
+ [Effect](https://effect.website)-style machinery. Events are plain dataclasses, reducers are plain functions, effects
28
+ are plain `async` functions, and everything is checked with [ty](https://github.com/astral-sh/ty).
29
+
30
+ ## A first store
31
+
32
+ ```python
33
+ import asyncio
34
+ from dataclasses import dataclass
35
+
36
+ from plantigrade import Fold, Get, Rejected, Store, derived, guard, reaction
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Incremented:
41
+ by: int = 1
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Decremented:
46
+ by: int = 1
47
+
48
+
49
+ # State: reduced from events. Reducers are pure: (state, event) -> state.
50
+ count = Fold(0).on(Incremented, lambda n, e: n + e.by).on(Decremented, lambda n, e: n - e.by)
51
+
52
+
53
+ # Derived values: recomputed only when what they read changes.
54
+ @derived
55
+ def parity(get: Get) -> str:
56
+ return "even" if get(count) % 2 == 0 else "odd"
57
+
58
+
59
+ # Guards: refuse an event before it reaches the log.
60
+ @guard(Decremented)
61
+ def never_negative(event: Decremented, get: Get) -> None:
62
+ if get(count) < event.by:
63
+ raise Rejected("count would go below zero")
64
+
65
+
66
+ # Effects: a reaction proposes requests, a handler runs them, its result is appended to the log.
67
+ @dataclass(frozen=True)
68
+ class Announce:
69
+ value: int
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class Announced:
74
+ value: int
75
+
76
+
77
+ announced = Fold[frozenset[int]](frozenset()).on(Announced, lambda values, e: values | {e.value})
78
+
79
+
80
+ @reaction
81
+ def announce(get: Get) -> dict[str, Announce]:
82
+ value = get(count)
83
+ if value > 0 and get(parity) == "even" and value not in get(announced):
84
+ return {"announce": Announce(value)}
85
+ return {}
86
+
87
+
88
+ async def say(request: Announce) -> Announced:
89
+ print(f"{request.value} is even!")
90
+ return Announced(request.value)
91
+
92
+
93
+ async def main() -> None:
94
+ store = Store(never_negative, announce).handle(Announce, say)
95
+ async with store: # a started store runs effects
96
+ store.dispatch(Incremented(), Incremented())
97
+ await store.settle() # prints "2 is even!"
98
+ print(store.get(count), store.get(parity)) # 2 even
99
+
100
+
101
+ asyncio.run(main())
102
+ ```
103
+
104
+ ## The primitives
105
+
106
+ | | What it is | Value |
107
+ |---|---|---|
108
+ | `Fold(initial).on(Event, reduce)` | State reduced from events | anything |
109
+ | `Journal[R]().on(Event, project)` | Append-only rows projected from events, O(1) per row | `Rows[R]` |
110
+ | `@derived` | A value computed from other atoms | anything |
111
+ | `@reaction` | Effects proposed from atoms, as `{key: request}` | `Mapping[K, R]` |
112
+ | `@guard(Event, ...)` | Refuses events before they reach the log | — |
113
+ | `Store(*mounted)` | Owns the log, computes atoms, runs effects | — |
114
+
115
+ Atoms are *definitions*: they hold no value, so they live at module level and any number of stores can share them.
116
+ The store keeps the values, computes them lazily and incrementally, and only recomputes a derived atom when an atom it
117
+ read last time has changed.
118
+
119
+ The full guide is in [docs/concepts.md](docs/concepts.md). [docs/recipes.md](docs/recipes.md) shows patterns built
120
+ from the primitives: keyed state, nested scopes, speculation, retries, streaming, fencing late results, testing.
121
+
122
+ ## Effects, declaratively
123
+
124
+ A reaction does not *start* effects; it says which ones *should be running*, under stable keys. After every dispatch
125
+ the store reconciles the proposals with what is running:
126
+
127
+ | The reaction... | The store... |
128
+ |---|---|
129
+ | proposes a new key | starts the handler |
130
+ | keeps proposing an equal request | leaves it alone: it runs once, even after it completes |
131
+ | proposes a different request under the key | cancels the running one and starts the new one |
132
+ | stops proposing the key | cancels it |
133
+
134
+ Handlers are `async` functions returning an event (or `None`), or async generators yielding events as they come,
135
+ for streaming. A handler that raises produces an `EffectFailed` event, which state can react to, to retry for
136
+ instance. Cancellation is plain `asyncio` cancellation.
137
+
138
+ So interrupting work is a state change. In [examples/chat.py](examples/chat.py), a voice assistant streams a reply
139
+ sentence by sentence while a second effect plays each sentence. When the user speaks again, the turn changes, which
140
+ changes both requests: generation restarts for the new turn, playback stops, and sentences that were never played
141
+ drop out of the transcript.
142
+
143
+ ## Speculation and time travel
144
+
145
+ ```python
146
+ fork = store.fork() # a pure copy-on-write store over the same log
147
+ fork.dispatch(Planned("...")) # speculative: the parent does not see it
148
+ store.commit(fork, upto=offset) # append the fork's events up to `offset`, validated and atomic
149
+ past = store.fork(at=12) # the state after the first 12 events
150
+ ```
151
+
152
+ ## Playground
153
+
154
+ ```sh
155
+ uv run plantigrade playground examples/chat.py # calls make_store() in that module
156
+ uv run plantigrade playground my_module.py:build_store
157
+ ```
158
+
159
+ The playground opens a local page with a form for every event type the store knows about, the log, the value of
160
+ each atom, and the effects running. Click an event, or drag the slider, to see the state as it was at that point.
161
+
162
+ ![The playground on examples/chat.py, while a reply is being generated and played](docs/assets/playground.png)
163
+
164
+ More in [docs/playground.md](docs/playground.md).
165
+
166
+ ## Development
167
+
168
+ ```sh
169
+ uv sync
170
+ uv run pytest # tests
171
+ uv run ty check # types
172
+ uv run ruff check && uv run ruff format --check
173
+ ```
174
+
175
+ Python 3.13 or later. No runtime dependencies.
@@ -0,0 +1,59 @@
1
+ [project]
2
+ name = "plantigrade"
3
+ version = "0.1.0"
4
+ description = "Reactive state and effects derived from an append-only event log."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.13"
8
+ dependencies = []
9
+
10
+ [[project.authors]]
11
+ name = "Félix Martel"
12
+ email = "22093661+felix-martel@users.noreply.github.com"
13
+
14
+ [project.scripts]
15
+ plantigrade = "plantigrade.__main__:main"
16
+
17
+ [dependency-groups]
18
+ dev = [
19
+ "hypothesis>=6.140",
20
+ "pytest>=8.4",
21
+ "pytest-asyncio>=1.2",
22
+ "ruff>=0.14",
23
+ "ty>=0.0.1a30",
24
+ ]
25
+
26
+ [build-system]
27
+ requires = ["uv_build>=0.13.5,<0.14.0"]
28
+ build-backend = "uv_build"
29
+
30
+ [tool.pytest.ini_options]
31
+ asyncio_mode = "auto"
32
+ asyncio_default_fixture_loop_scope = "function"
33
+ testpaths = ["tests"]
34
+ addopts = [
35
+ "-p",
36
+ "no:cacheprovider",
37
+ ]
38
+
39
+ [tool.ruff]
40
+ line-length = 120
41
+ target-version = "py313"
42
+
43
+ [tool.ruff.lint]
44
+ select = [
45
+ "E",
46
+ "F",
47
+ "I",
48
+ "B",
49
+ "UP",
50
+ "SIM",
51
+ "RUF",
52
+ ]
53
+
54
+ [tool.ty.src]
55
+ include = [
56
+ "src",
57
+ "tests",
58
+ "examples",
59
+ ]
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "plantigrade"
3
+ version = "0.1.0"
4
+ description = "Reactive state and effects derived from an append-only event log."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "Félix Martel", email = "22093661+felix-martel@users.noreply.github.com" }
9
+ ]
10
+ requires-python = ">=3.13"
11
+ dependencies = []
12
+
13
+ [project.scripts]
14
+ plantigrade = "plantigrade.__main__:main"
15
+
16
+ [dependency-groups]
17
+ dev = [
18
+ "hypothesis>=6.140",
19
+ "pytest>=8.4",
20
+ "pytest-asyncio>=1.2",
21
+ "ruff>=0.14",
22
+ "ty>=0.0.1a30",
23
+ ]
24
+
25
+ [build-system]
26
+ requires = ["uv_build>=0.13.5,<0.14.0"]
27
+ build-backend = "uv_build"
28
+
29
+ [tool.pytest.ini_options]
30
+ asyncio_mode = "auto"
31
+ asyncio_default_fixture_loop_scope = "function"
32
+ testpaths = ["tests"]
33
+ addopts = ["-p", "no:cacheprovider"]
34
+
35
+ [tool.ruff]
36
+ line-length = 120
37
+ target-version = "py313"
38
+
39
+ [tool.ruff.lint]
40
+ select = ["E", "F", "I", "B", "UP", "SIM", "RUF"]
41
+
42
+ [tool.ty.src]
43
+ include = ["src", "tests", "examples"]
@@ -0,0 +1,48 @@
1
+ """Plantigrade: reactive state and effects derived from an append-only event log.
2
+
3
+ Every change is an event appended to a log. Atoms describe values computed from it:
4
+
5
+ - `Fold`: state reduced from events;
6
+ - `Journal`: rows projected from events, for histories that only grow;
7
+ - `derived`: values computed from other atoms;
8
+ - `reaction`: effects proposed from atoms, run by the store.
9
+
10
+ A `guard` rejects events before they reach the log. A `Store` owns the log, computes atom values, and runs effects.
11
+ """
12
+
13
+ from plantigrade._atoms import (
14
+ SKIP,
15
+ Atom,
16
+ Derived,
17
+ Fold,
18
+ Get,
19
+ Guard,
20
+ Journal,
21
+ Reaction,
22
+ Rejected,
23
+ Rows,
24
+ derived,
25
+ guard,
26
+ reaction,
27
+ )
28
+ from plantigrade._store import Effect, EffectFailed, Handler, Store
29
+
30
+ __all__ = [
31
+ "SKIP",
32
+ "Atom",
33
+ "Derived",
34
+ "Effect",
35
+ "EffectFailed",
36
+ "Fold",
37
+ "Get",
38
+ "Guard",
39
+ "Handler",
40
+ "Journal",
41
+ "Reaction",
42
+ "Rejected",
43
+ "Rows",
44
+ "Store",
45
+ "derived",
46
+ "guard",
47
+ "reaction",
48
+ ]
@@ -0,0 +1,27 @@
1
+ """Command line: `plantigrade playground path/to/module.py[:factory]`."""
2
+
3
+ import argparse
4
+ import contextlib
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ def main(argv: list[str] | None = None) -> None:
10
+ parser = argparse.ArgumentParser(prog="plantigrade", description=__doc__)
11
+ commands = parser.add_subparsers(dest="command", required=True)
12
+ playground = commands.add_parser("playground", help="explore a store in the browser")
13
+ playground.add_argument("target", help="module.py[:factory] or package.module[:factory]; factory: make_store")
14
+ playground.add_argument("--host", default="127.0.0.1")
15
+ playground.add_argument("--port", type=int, default=4242)
16
+ args = parser.parse_args(argv)
17
+
18
+ from plantigrade.playground import load_factory, serve
19
+
20
+ sys.path.insert(0, str(Path.cwd()))
21
+ factory, names = load_factory(args.target)
22
+ with contextlib.suppress(KeyboardInterrupt):
23
+ serve(factory, host=args.host, port=args.port, names=names)
24
+
25
+
26
+ if __name__ == "__main__":
27
+ main()