feat: 0.6.0 — setup() API, composable guards, snapshot serialization - #15
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces several key features to align with XState v5, including composable guard combinators (and_, or_, not_), the setup() builder API for pre-configuring machines, and snapshot serialization/deserialization for state persistence. It also refactors delayed sends in the interpreter to use functools.partial. The feedback highlights a few critical improvements: deep-copying the context during serialization to prevent mutation side-effects, restoring the state's output attribute during deserialization, transitively resolving string guards to support aliasing, and copying setup registries to ensure isolation between machines created from the same setup.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| history_value: dict[str, list[str]] = {} | ||
| for hist_node_id, state_nodes in (snapshot.history_value or {}).items(): | ||
| history_value[hist_node_id] = sorted(node.id for node in state_nodes) | ||
|
|
||
| error = snapshot.error | ||
| return { | ||
| "value": snapshot.value, | ||
| "context": snapshot.context, | ||
| "status": snapshot.status, | ||
| "history_value": history_value, | ||
| "output": snapshot.output, | ||
| "error": repr(error) if error is not None else None, | ||
| } |
There was a problem hiding this comment.
The serialized snapshot directly references the mutable snapshot.context dictionary. Since context is mutated in-place during machine transitions (e.g., via _apply_assignment), any changes to the running machine's context will retroactively modify previously serialized snapshots. To ensure snapshots are immutable, point-in-time representations, snapshot.context should be deep-copied during serialization.
| history_value: dict[str, list[str]] = {} | |
| for hist_node_id, state_nodes in (snapshot.history_value or {}).items(): | |
| history_value[hist_node_id] = sorted(node.id for node in state_nodes) | |
| error = snapshot.error | |
| return { | |
| "value": snapshot.value, | |
| "context": snapshot.context, | |
| "status": snapshot.status, | |
| "history_value": history_value, | |
| "output": snapshot.output, | |
| "error": repr(error) if error is not None else None, | |
| } | |
| import copy | |
| history_value: dict[str, list[str]] = {} | |
| for hist_node_id, state_nodes in (snapshot.history_value or {}).items(): | |
| history_value[hist_node_id] = sorted(node.id for node in state_nodes) | |
| error = snapshot.error | |
| return { | |
| "value": snapshot.value, | |
| "context": copy.deepcopy(snapshot.context) if snapshot.context is not None else {}, | |
| "status": snapshot.status, | |
| "history_value": history_value, | |
| "output": snapshot.output, | |
| "error": repr(error) if error is not None else None, | |
| } |
| if data.get("status") == "error": | ||
| state.status = "error" | ||
| state.error = data.get("error") |
There was a problem hiding this comment.
The deserialize_snapshot function does not restore the output attribute of the state. If a machine was in a "done" state with a specific output, resuming from the deserialized snapshot will result in a state with output set to None (or its default), losing the computed output. We should explicitly restore state.output from the serialized data.
| if data.get("status") == "error": | |
| state.status = "error" | |
| state.error = data.get("error") | |
| if data.get("status") == "error": | |
| state.status = "error" | |
| state.error = data.get("error") | |
| elif data.get("status") == "done": | |
| state.status = "done" | |
| state.output = data.get("output") |
| if isinstance(guard, str): | ||
| fn = registry.get(guard) | ||
| if fn is None: | ||
| raise KeyError( | ||
| f"Composable guard references unknown guard '{guard}'. " | ||
| "Make sure it is registered in the machine's guards dict." | ||
| ) | ||
| guard = fn |
There was a problem hiding this comment.
The _eval method only resolves string guards one level deep. If a string guard references another string guard (aliasing/indirection), the second string will not be resolved and will be passed directly to _invoke, which will raise a TypeError because a string is not callable. Using a loop to resolve string guards transitively makes the resolution robust and supports guard aliasing safely.
| if isinstance(guard, str): | |
| fn = registry.get(guard) | |
| if fn is None: | |
| raise KeyError( | |
| f"Composable guard references unknown guard '{guard}'. " | |
| "Make sure it is registered in the machine's guards dict." | |
| ) | |
| guard = fn | |
| visited = set() | |
| while isinstance(guard, str): | |
| if guard in visited: | |
| raise ValueError(f"Circular dependency detected in guards: {' -> '.join(visited)} -> {guard}") | |
| visited.add(guard) | |
| fn = registry.get(guard) | |
| if fn is None: | |
| raise KeyError( | |
| f"Composable guard references unknown guard '{guard}'. " | |
| "Make sure it is registered in the machine's guards dict." | |
| ) | |
| guard = fn |
| return Machine( | ||
| config, | ||
| guards=resolved_guards, | ||
| actions=self._actions, | ||
| actors=self._actors, | ||
| delays=self._delays, | ||
| ) |
There was a problem hiding this comment.
The create_machine method passes the setup's internal _actions, _actors, and _delays dictionaries directly to the Machine constructor. If a machine or external code mutates these registries, it will affect other machines created from the same setup() instance. To ensure complete isolation and reusability of the MachineSetup builder, we should pass copies of these dictionaries.
| return Machine( | |
| config, | |
| guards=resolved_guards, | |
| actions=self._actions, | |
| actors=self._actors, | |
| delays=self._delays, | |
| ) | |
| return Machine( | |
| config, | |
| guards=resolved_guards, | |
| actions=dict(self._actions), | |
| actors=dict(self._actors), | |
| delays=dict(self._delays), | |
| ) |
There was a problem hiding this comment.
Pull request overview
Introduces the 0.6.0 feature set for xstate-python by adding an XState v5-style setup() builder, composable guard combinators, and snapshot (de)serialization to support persistence and resuming actors from saved state.
Changes:
- Added
setup()/MachineSetup.create_machine(...)to centralize guards/actions/actors/delays registration before building aMachine. - Added composable guards (
and_,or_,not_) with lazy string sub-guard resolution, and updated guard evaluation to support them. - Added snapshot serialization/deserialization utilities plus
create_actor(..., snapshot=...)convenience support.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_snapshot.py | Adds coverage for snapshot serialize/deserialize and resuming execution, including history and create_actor(snapshot=...). |
| tests/test_setup.py | Adds coverage for setup() builder wiring across registries and reuse across multiple create_machine calls. |
| tests/test_guards.py | Adds coverage for guard combinators, nesting, and string sub-guard resolution behavior. |
| src/xstate/snapshot.py | Implements snapshot serialization/deserialization utilities. |
| src/xstate/setup.py | Implements the setup() builder and MachineSetup container. |
| src/xstate/interpreter.py | Replaces IIFE lambdas with functools.partial for delayed sends. |
| src/xstate/guards.py | Adds composable guard combinator implementations. |
| src/xstate/algorithm.py | Updates guard evaluation to pass registry info to composable guards. |
| src/xstate/actor.py | Adds snapshot kwarg support and applies it on start() when no explicit initial state is provided. |
| src/xstate/init.py | Exposes new APIs from the package top-level. |
| pyproject.toml | Bumps package version to 0.6.0 and updates ruff per-file ignores for the new tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| self._types = types | ||
| self._guards = guards | ||
| self._actions = actions | ||
| self._actors = actors | ||
| self._delays = delays |
| from xstate.state import State | ||
|
|
||
| configuration = set(machine._get_configuration(data["value"])) | ||
|
|
||
| history_value: dict[str, set[Any]] = {} | ||
| for hist_node_id, node_ids in (data.get("history_value") or {}).items(): | ||
| nodes = set() | ||
| for nid in node_ids: | ||
| node = machine._id_map.get(nid) | ||
| if node is not None: | ||
| nodes.add(node) | ||
| if nodes: | ||
| history_value[hist_node_id] = nodes |
| "value": snapshot.value, | ||
| "context": snapshot.context, | ||
| "status": snapshot.status, |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5f2c050696
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| """ | ||
| from xstate.state import State | ||
|
|
||
| configuration = set(machine._get_configuration(data["value"])) |
There was a problem hiding this comment.
Include root ancestors when restoring snapshots
When restoring a snapshot for a leaf or nested state, _get_configuration(data["value"]) rebuilds only the nodes encoded in state.value and omits the root ancestor that normal configurations contain. Interpreter.start() then schedules after timers and actor invocation reconciliation by iterating state.configuration, so root-level after timers or invokes that were active before serialization are never resumed after deserialize_snapshot; for example, a restored machine with root after: {100: "#m.done"} remains in its child state forever. Rebuild the configuration with ancestors/root included so persisted snapshots resume all active nodes.
Useful? React with 👍 / 👎.
New modules: - guards.py: and_(), or_(), not_() combinators with lazy string sub-guard resolution via machine.guards registry at evaluation time - snapshot.py: serialize_snapshot() / deserialize_snapshot() for JSON- compatible State persistence; round-trip with create_actor(snapshot=) - setup_api.py: already on master — wired up exports and tests Engine changes: - algorithm.py: condition_match() detects _ComposableGuard (including HandlerAdapter-wrapped instances) and calls _call(ctx, evt, registry) with the machine's guards dict instead of invoke_handler - actor.py: Actor.__init__ + start() accept snapshot= param; create_actor() forwards snapshot= to Actor so callers can resume from a checkpoint Compatibility fixes (Python 3.11 / ruff): - Replace PEP-695 `type X = ...` syntax with bare assignments (3.12+ only) - Add sys.version_info shim for typing.override (added in 3.12) - Lower requires-python to >=3.11 and ruff/mypy target to py311 - Fix all ruff UP006/UP007/UP035/UP036/I001/E501/F401/F811 violations Tests: 336 passing (44 new), 0 failures, ruff clean, mypy clean
5f2c050 to
9f1646b
Compare
Summary
setup()builder (src/xstate/setup.py) — XState v5 entry point.setup(guards=..., actions=..., actors=..., delays=..., types=...).create_machine(config)wires all four implementation registries in one place, matching thecreateMachine + setup()pattern from XState v5.Composable guards (
src/xstate/guards.py) —and_(),or_(),not_()combinators. Sub-guards can be callables or string names resolved lazily from the machine's guards registry at evaluation time.algorithm.condition_matchupdated to forward the registry to composable guards.Snapshot serialization (
src/xstate/snapshot.py) —serialize_snapshot(state) → dictanddeserialize_snapshot(machine, data) → Statefor persistence.create_actor(machine, snapshot=...)convenience kwarg added to bothActorandcreate_actor().functools.partialcleanup (src/xstate/interpreter.py) — replaced three IIFE lambda bindings ((lambda en: lambda: self.send(en))(event_name)) with idiomaticfunctools.partial(self.send, event_name). Same semantics, cleaner code (addresses architectural review item 0.5.0: actor-model foundation — create_actor, Actor, ActorSystem #5).313 tests pass, ruff clean, mypy clean (17 source files). 40 new tests across
test_guards,test_setup,test_snapshot.Test plan
python3 -m pytest tests/ --ignore=tests/test_scxml.py→ 313 passedruff check src/ tests/→ no errorsmypy src/xstate/→ no issues🤖 Generated with Claude Code
https://claude.ai/code/session_01E5AJUvQDU2YYNtWWUD54ML
Generated by Claude Code