Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,69 @@ placeholder until the actor model lands (0.5.0).
| 0.4.0 | v5 config alignment | Rename `cond`→`guard`, `data`→`output`, `always:`, single-object handler signatures, MachineSnapshot |
| 0.5.0 | Actor model | `create_actor`, actor system, `from_promise`/`from_callback`, asyncio |
| 0.6.0 | Setup & parity | `setup()`, composable guards (`and_`/`or_`/`not_`), snapshot serialization |
| 0.7.0+ | Next parity | State tags, `choose`/`pure` actions, TypedDict config schemas, Mermaid diagrams |
| 0.7.0 | Next parity | State `tags` + `hasTag()`, `choose`/`pure` actions, `stateIn` guard, Mermaid/Graphviz diagrams, TypedDict config schemas |
| 0.8.0+ | Internal refactor | `StateNodeConfigParser` factory, opt-in immutable `context_factory`, `ParamSpec` handler typing |

The differentiating niche: **XState / Stately.ai JSON compatibility** — neither `transitions`
nor `python-statemachine` accepts XState JSON natively.

---

## Architectural debt (deferred, tracked)

These items came out of an architectural review. Each is intentionally deferred with a
target version; do not silently "fix" them outside their milestone, because they touch the
public API or the SCXML core and need the verification gates below.

| # | Item | Where | Status / plan |
|---|------|-------|---------------|
| 1 | **Dynamic handler arity** — `algorithm._invoke` / `handlers.invoke_handler` inspect a callable's signature on every call to support 4 calling conventions | `algorithm.py`, `handlers.py` | Short-term approach (signature inspection cached via `functools.lru_cache`) is fine. Moving to a single `Callable[[HandlerArgs], Any]` + `ParamSpec` contract is a **breaking** API change — defer to **0.8.0+** after `setup()` is stable. |
| 2 | **Parser/model separation** — `StateNode` is already a pure dataclass, but some normalization responsibilities still sit close to the model boundary | `state_node.py`, `config_parser.py` | Master already extracted `config_parser.StateNodeConfigParser`. Finish consolidating raw-config traversal, defaults, and transition normalization in the parser so `StateNode` stays a resolved model. Safe only **after** item 3 (typed inputs). Target **0.8.0+**. |
| 3 | **`Any` config boundary → TypedDict** — `Machine(config: dict[str, Any])` loses all static checking | `schema.py`, `machine.py` | Master added `schema.py` with `StateNodeConfig`/`TransitionConfig`/`InvokeConfig`/`MachineConfig` TypedDicts. Next: type `Machine(config: MachineConfig)` and enable stricter mypy on `machine.py` progressively. Target **0.7.0**. |
| 4 | **`deepcopy` context cost** — context is `deepcopy`-ed on each transition | `context.py` | Master added `ContextAdapter` (`DeepCopyContextAdapter`, `DataclassContextAdapter`). Expose the adapter as a documented `context_factory`-style hook so power users opt into immutable/cheaper structures. Target **0.7.0+**. |
| 5 | **IIFE lambda binding** — `(lambda e: lambda: self.send(e))(event)` | `interpreter.py` | ✅ **Done** — replaced with `functools.partial(self.send, event)` (0.6.0). |

**Verification gates for any of the above:**
- Changes to `algorithm.py` (item 1) must pass the SCXML test framework (`tests/test_scxml.py`,
see "Algorithm changes require SCXML test verification" below).
- Parser/model changes (item 2) must pass parser, transition, and SCXML import coverage; run full
SCXML conformance only if they alter transition selection or entry/exit semantics.
- Public-API changes (items 1, 3, 4) must keep the v0.1.0 contract or land in a minor bump
with a `DeprecationWarning` bridge, matching how `cond`→`guard` was handled in 0.4.0.

---

## 0.7.0 feature backlog (research-informed)

Targets drawn from XState v5 (https://stately.ai/docs/xstate) and the Python statechart
landscape (`transitions`, `python-statemachine`, `Sismic`). Ranked by parity value × differentiation.

**XState v5 parity gaps:**
- **State `tags`** — `tags: ["loading"]` in config; `state.hasTag("loading")` on `MachineSnapshot`. Cheap, high-use.
- **`choose` action** — conditional action selection (run the first branch whose guard passes).
- **`pure` action** — a function returning a list of actions to run, with no side effects of its own.
- **`stateIn` guard** — user-facing guard over the current configuration. We already have `in_state`
on transitions internally; expose it as a first-class guard (and as `stateIn(...)` alongside `and_`/`or_`/`not_`).
- **`enqueueActions`** — batch/queue actions imperatively inside an action body.
- **Transition `reenter: true`** — re-enter the source state on a self-transition (vs. internal).
- **`stopChild` action** — explicitly stop a spawned/invoked actor.
- **Dynamic `sendTo` targets** — `to=` resolved from `(context, event)`.
- **Machine / state `meta`** — per-node metadata surfaced via `state.meta` / `getMeta()`.
- **Partial event descriptors / wildcard** — `on: {"UPDATE.*": ...}` style matching.

**Differentiators worth owning (gaps in the Python field):**
- **Mermaid / Graphviz diagram export** — `transitions` has `GraphMachine`; we have none. High value
given native XState JSON in → diagram out.
- **`hasTag` / `can` / `matches` snapshot ergonomics** — round out `MachineSnapshot` query methods.
- **Observer pattern** — `python-statemachine`'s `add_observer`; we have `subscribe`, consider a
multi-callback observer protocol with entry/exit hooks.

**Process note:** a deep-research workflow over the four comparison libraries was scoped in this
session (see `deep-research` skill invocation) but not yet run to completion; re-run it before
locking the final 0.7.0 scope to confirm method signatures and catch anything new upstream.
Comment on lines +171 to +173

---

## Development commands

```bash
Expand Down
5 changes: 3 additions & 2 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/xstate/machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ def _get_actions(
result.append(
self._bind_action(
action,
self.actions[action.type], # type: ignore[index]
self.actions[action.type],
context,
event,
)
Expand Down
2 changes: 2 additions & 0 deletions src/xstate/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@
if sys.version_info >= (3, 12):
from typing import override
else:

def override(fn: Any) -> Any: # noqa: D103
return fn


__all__ = ["Clock", "SimulatedClock", "ThreadClock"]


Expand Down
33 changes: 24 additions & 9 deletions tests/test_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,14 @@ def test_and_resolves_string_subguards():
"id": "m",
"initial": "a",
"states": {
"a": {"on": {"GO": {
"target": "b", "guard": and_("isLoggedIn", "hasPermission"),
}}},
"a": {
"on": {
"GO": {
"target": "b",
"guard": and_("isLoggedIn", "hasPermission"),
}
}
},
"b": {},
},
},
Expand All @@ -149,9 +154,14 @@ def test_and_string_guards_with_context():
"initial": "a",
"context": {"logged_in": True, "permission": True},
"states": {
"a": {"on": {"GO": {
"target": "b", "guard": and_("isLoggedIn", "hasPermission"),
}}},
"a": {
"on": {
"GO": {
"target": "b",
"guard": and_("isLoggedIn", "hasPermission"),
}
}
},
"b": {},
},
},
Expand All @@ -172,9 +182,14 @@ def test_and_string_guards_blocked_by_missing_permission():
"initial": "a",
"context": {"logged_in": True, "permission": False},
"states": {
"a": {"on": {"GO": {
"target": "b", "guard": and_("isLoggedIn", "hasPermission"),
}}},
"a": {
"on": {
"GO": {
"target": "b",
"guard": and_("isLoggedIn", "hasPermission"),
}
}
},
"b": {},
},
},
Expand Down
2 changes: 2 additions & 0 deletions tests/test_modernization.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
if sys.version_info >= (3, 12):
from typing import override
else:

def override(fn): # type: ignore[misc]
return fn


import pytest

from xstate import Machine, assign, interpret
Expand Down
1 change: 0 additions & 1 deletion tests/test_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
- Multiple create_machine() calls from one setup() (registries are merged)
"""


from xstate import Machine, SimulatedClock, and_, create_actor, setup
from xstate.setup_api import MachineSetup

Expand Down
8 changes: 6 additions & 2 deletions tests/test_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
- Error-status snapshots round-trip
"""


from xstate import Machine, create_actor, deserialize_snapshot, serialize_snapshot


Expand Down Expand Up @@ -55,7 +54,12 @@ def test_serialize_active_snapshot_has_required_keys():
actor = create_actor(_toggle_machine()).start()
data = serialize_snapshot(actor.get_snapshot())
assert set(data.keys()) == {
"value", "context", "status", "history_value", "output", "error"
"value",
"context",
"status",
"history_value",
"output",
"error",
}


Expand Down
Loading