Skip to content

feat: state tags + has_tag()/hasTag() (0.7.0) - #19

Merged
JovaniPink merged 1 commit into
masterfrom
claude/0.7.0-state-tags
Jun 28, 2026
Merged

feat: state tags + has_tag()/hasTag() (0.7.0)#19
JovaniPink merged 1 commit into
masterfrom
claude/0.7.0-state-tags

Conversation

@JovaniPink

Copy link
Copy Markdown
Owner

Summary

First 0.7.0 feature — XState v5 state tags. A state can declare tags and a running
snapshot 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).

machine = Machine({
    "id": "fetch",
    "initial": "loading",
    "states": {
        "loading": {"tags": ["busy", "network"], "on": {"DONE": "ready"}},
        "ready":   {"tags": "interactive"},
    },
})

snap = machine.initial_state
snap.has_tag("busy")    # True
snap.hasTag("busy")     # True  (camelCase alias for JS parity)
snap.tags               # frozenset({"busy", "network"})

snap = machine.transition(snap, "DONE")
snap.has_tag("busy")    # False
snap.has_tag("interactive")  # True

What changed

File Change
src/xstate/state_node.py New tags: tuple[str, ...] field on StateNode
src/xstate/config_parser.py _build_tags() — accepts a single string or a list of strings; raises InvalidConfigError on a non-string member or a wrong container type
src/xstate/state.py tags property (frozenset unioned across the active configuration) + has_tag() with a hasTag camelCase alias
src/xstate/schema.py tags: str | list[str] added to the StateNodeConfig TypedDict
tests/test_tags.py 12 new tests
CLAUDE.md Record tags in the "working" feature list

Design notes

  • Aggregation semantics match XState v5: state.tags is the union of tags over every active
    node, so a compound ancestor's tags and a leaf's tags both appear, and parallel regions each
    contribute. Tests cover both cases.
  • Snapshots stay tag-free: tags derive from the static machine definition and are recomputed
    from the configuration, so serialize_snapshot / deserialize_snapshot need no change and old
    snapshots keep working.
  • No SCXML-core touch: algorithm.py is untouched, so the SCXML verification gate doesn't apply.
  • Input validation: a single string is sugar for a one-element list; non-string tags or a dict
    raise InvalidConfigError at machine-build time with a path-qualified message.

Test plan

  • python3 -m pytest tests/ --ignore=tests/test_scxml.py348 passed (12 new), 0 failures
  • ruff check src/ tests/ → All checks passed
  • mypy src/xstate/ → Success: no issues found in 21 source files

🤖 Generated with Claude Code


Generated by Claude Code

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.
Copilot AI review requested due to automatic review settings June 28, 2026 23:41

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/xstate/state_node.py
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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 $O(1)$ membership checks (e.g., tag in node.tags) instead of $O(N)$ scans.

Suggested change
tags: tuple[str, ...] = field(default_factory=tuple)
tags: frozenset[str] = field(default_factory=frozenset)

Comment on lines +114 to +128
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}."
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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        )

Comment thread src/xstate/state.py
Comment on lines +83 to +85
return frozenset(
tag for node in self.configuration for tag in node.tags
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If StateNode.tags is updated to be a frozenset, we can compute the union of all active tags more efficiently and cleanly using frozenset().union instead of a nested generator expression.

        return frozenset().union(*(node.tags for node in self.configuration))

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 tags from state node config (string sugar or list), with InvalidConfigError validation on invalid types.
  • Add StateNode.tags and 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.

Comment thread src/xstate/schema.py
target: TransitionTarget
output: Any
data: Any
tags: str | list[str]

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/xstate/state.py
Comment on lines +83 to +85
return frozenset(
tag for node in self.configuration for tag in node.tags
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@JovaniPink
JovaniPink merged commit 0a45784 into master Jun 28, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants