diff --git a/CLAUDE.md b/CLAUDE.md index 9843d55..91c8dab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. + +--- + ## Development commands ```bash diff --git a/poetry.lock b/poetry.lock index 720ba72..caa9fa3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -424,6 +424,7 @@ files = [ [package.dependencies] pytest = ">=8.4,<10" +typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)", "sphinx-tabs (>=3.5)"] @@ -493,5 +494,5 @@ scxml = [] [metadata] lock-version = "2.1" -python-versions = ">=3.13" -content-hash = "97b01673ecda312018969255bcf6d529f50f5bf32cc37deaa1563ccd40e15318" +python-versions = ">=3.11" +content-hash = "e7092c6659068ff4692084a9380a057b037e5261949edfe7a98958d6467eafb8" diff --git a/src/xstate/machine.py b/src/xstate/machine.py index dcbf2b6..11a7fc6 100644 --- a/src/xstate/machine.py +++ b/src/xstate/machine.py @@ -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, ) diff --git a/src/xstate/scheduler.py b/src/xstate/scheduler.py index e8a6eaf..d26507e 100644 --- a/src/xstate/scheduler.py +++ b/src/xstate/scheduler.py @@ -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"] diff --git a/tests/test_guards.py b/tests/test_guards.py index b282f39..805758a 100644 --- a/tests/test_guards.py +++ b/tests/test_guards.py @@ -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": {}, }, }, @@ -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": {}, }, }, @@ -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": {}, }, }, diff --git a/tests/test_modernization.py b/tests/test_modernization.py index 172825c..683840e 100644 --- a/tests/test_modernization.py +++ b/tests/test_modernization.py @@ -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 diff --git a/tests/test_setup.py b/tests/test_setup.py index c81ad60..93be77e 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -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 diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py index e544eab..36cbbfe 100644 --- a/tests/test_snapshot.py +++ b/tests/test_snapshot.py @@ -8,7 +8,6 @@ - Error-status snapshots round-trip """ - from xstate import Machine, create_actor, deserialize_snapshot, serialize_snapshot @@ -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", }