feat: state tags + has_tag()/hasTag() (0.7.0) - #19
Conversation
XState v5 lets a state declare `tags: ["loading"]` and query a running
snapshot with `state.hasTag("loading")`. This adds the same to xstate-python.
- StateNode: new `tags: tuple[str, ...]` field
- config_parser: `_build_tags()` accepts a single string or list of strings;
raises InvalidConfigError on non-string members or wrong container type
- State/MachineSnapshot: `tags` property (frozenset unioned across the active
configuration — compound ancestors + parallel regions) and `has_tag()` with
a `hasTag` camelCase alias for JS parity
- schema.py: add `tags: str | list[str]` to StateNodeConfig TypedDict
Tags derive from the static machine definition and are recomputed from the
configuration, so snapshot serialization is unaffected.
Tests: tests/test_tags.py (12 cases) — parsing, validation, has_tag/hasTag,
tags across transitions, aggregation over compound + parallel. 348 passing,
ruff clean, mypy clean.
There was a problem hiding this comment.
Code Review
This pull request implements State tags (0.7.0) to align with XState v5, allowing state nodes to declare tags and enabling queries on active configurations via state.tags, state.has_tag(), and state.hasTag(). The feedback suggests optimizing the implementation by using frozenset instead of tuple for storing and parsing tags, which would allow for cleaner tag aggregation and more efficient membership checks.
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.
| after: list[tuple[Any, str]] = field(default_factory=list) | ||
| invoke: list[dict[str, Any]] = field(default_factory=list) | ||
| initial_transition: Transition | None = None | ||
| tags: tuple[str, ...] = field(default_factory=tuple) |
There was a problem hiding this comment.
Using a frozenset[str] instead of a tuple[str, ...] for StateNode.tags is more idiomatic and efficient. It automatically deduplicates tags at the node level and allows tag in node.tags) instead of
| tags: tuple[str, ...] = field(default_factory=tuple) | |
| tags: frozenset[str] = field(default_factory=frozenset) |
| def _build_tags(self, tags_config: Any, path: str) -> tuple[str, ...]: | ||
| if tags_config is None: | ||
| return () | ||
| if isinstance(tags_config, str): | ||
| return (tags_config,) | ||
| if isinstance(tags_config, (list, tuple)): | ||
| if not all(isinstance(t, str) for t in tags_config): | ||
| raise InvalidConfigError( | ||
| f"{path}: every tag must be a string, got {tags_config!r}." | ||
| ) | ||
| return tuple(tags_config) | ||
| raise InvalidConfigError( | ||
| f"{path}: 'tags' must be a string or a list of strings, " | ||
| f"got {type(tags_config)!r}." | ||
| ) |
There was a problem hiding this comment.
Update _build_tags to return a frozenset[str] to align with the suggested frozenset type for StateNode.tags. This also deduplicates any duplicate tags provided in the configuration.
def _build_tags(self, tags_config: Any, path: str) -> frozenset[str]:\n if tags_config is None:\n return frozenset()\n if isinstance(tags_config, str):\n return frozenset((tags_config,))\n if isinstance(tags_config, (list, tuple, set, frozenset)):\n if not all(isinstance(t, str) for t in tags_config):\n raise InvalidConfigError(\n f\"{path}: every tag must be a string, got {tags_config!r}.\"\n )\n return frozenset(tags_config)\n raise InvalidConfigError(\n f\"{path}: 'tags' must be a string or a list of strings, \"\n f\"got {type(tags_config)!r}.\"\n )| return frozenset( | ||
| tag for node in self.configuration for tag in node.tags | ||
| ) |
There was a problem hiding this comment.
Pull request overview
Adds XState v5-compatible state tags to xstate-python, allowing state nodes to declare tags and runtime snapshots to query aggregated tags via state.tags, state.has_tag(...), and the JS-parity alias state.hasTag(...).
Changes:
- Parse
tagsfrom state node config (string sugar or list), withInvalidConfigErrorvalidation on invalid types. - Add
StateNode.tagsand expose snapshot-level aggregation/query helpers (tags,has_tag,hasTag). - Add a dedicated test suite covering parsing, querying, transition updates, and aggregation across compound/parallel configurations.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/test_tags.py | New tests covering parsing and tag aggregation/query semantics. |
| src/xstate/state.py | Adds State.tags aggregation + has_tag() / hasTag() query APIs. |
| src/xstate/state_node.py | Adds tags storage on resolved StateNode. |
| src/xstate/schema.py | Extends TypedDict config surface with tags. |
| src/xstate/config_parser.py | Implements _build_tags() validation + assigns parsed tags onto nodes. |
| CLAUDE.md | Documents tags as a working 0.7.0 feature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| target: TransitionTarget | ||
| output: Any | ||
| data: Any | ||
| tags: str | list[str] |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7e84a80e85
ℹ️ 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".
| return frozenset( | ||
| tag for node in self.configuration for tag in node.tags | ||
| ) |
There was a problem hiding this comment.
Preserve root-level tags across transitions
When the machine root declares tags, the initial snapshot includes the root in configuration, but Machine.transition() rebuilds the next configuration from state.value and does not re-add the root node for string or nested values. Because tags is computed only from self.configuration here, any event sent through the pure API or actor runtime drops root-level tags from state.tags/has_tag, even for ignored events. Please include the root in reconstructed configurations or account for root tags explicitly.
Useful? React with 👍 / 👎.
Summary
First 0.7.0 feature — XState v5 state tags. A state can declare
tagsand a runningsnapshot can be queried with
state.has_tag(...)/state.hasTag(...), exactly as in XState v5.This is the cheapest high-use parity win from the 0.7.0 backlog (recorded in #16).
What changed
src/xstate/state_node.pytags: tuple[str, ...]field onStateNodesrc/xstate/config_parser.py_build_tags()— accepts a single string or a list of strings; raisesInvalidConfigErroron a non-string member or a wrong container typesrc/xstate/state.pytagsproperty (frozenset unioned across the active configuration) +has_tag()with ahasTagcamelCase aliassrc/xstate/schema.pytags: str | list[str]added to theStateNodeConfigTypedDicttests/test_tags.pyCLAUDE.mdDesign notes
state.tagsis the union of tags over every activenode, so a compound ancestor's tags and a leaf's tags both appear, and parallel regions each
contribute. Tests cover both cases.
from the configuration, so
serialize_snapshot/deserialize_snapshotneed no change and oldsnapshots keep working.
algorithm.pyis untouched, so the SCXML verification gate doesn't apply.raise
InvalidConfigErrorat machine-build time with a path-qualified message.Test plan
python3 -m pytest tests/ --ignore=tests/test_scxml.py→ 348 passed (12 new), 0 failuresruff check src/ tests/→ All checks passedmypy src/xstate/→ Success: no issues found in 21 source files🤖 Generated with Claude Code
Generated by Claude Code