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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ are retained under `tests/fixtures/scxml/`.
poetry run python -m pytest tests/test_scxml.py
```

The configured conformance subset contains 56 passing cases, including all 15 enabled
The configured conformance subset contains 57 passing cases, including all 15 enabled
`more-parallel` cases, plus a fixture inventory/provenance guard. This is a focused SCXML
subset rather than a claim of full W3C conformance; broader datamodel and executable-content
coverage remains future work. The `cond-js` subset passes with the safe Boolean evaluator,
Expand Down
8 changes: 4 additions & 4 deletions docs/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ bridge between XState's JSON ecosystem and Python backends.
| Actor model | `create_actor`, `ActorSystem`, spawn, parent/child tree, `send_parent`, `send_to` |
| Actor logic | `from_promise`, `from_callback`, `from_observable`, `to_promise` |
| Invoke | Child actor lifetime reconciliation with `done.invoke.*` and `error.platform.*` events |
| SCXML XML import | Present; safe Boolean and narrow integer-data subset; configured 56-case suite passes |
| SCXML XML import | Present; safe Boolean and narrow integer-data subset; configured 57-case suite passes |

## What Works Today

Expand All @@ -58,7 +58,7 @@ bridge between XState's JSON ecosystem and Python backends.
`MachineSnapshot`, `create_actor`, and `setup`.
- Primary suite passes in current Python 3.13/3.14 CI.
- SCXML `cond-js` subset result: `4 passed`.
- Configured SCXML result: `56 passed`, `0 failed`, including all enabled
- Configured SCXML result: `57 passed`, `0 failed`, including all enabled
`more-parallel` cases.
- Concept guides cover machine configuration, runtime choices, actors,
persistence, and SCXML import.
Expand All @@ -81,7 +81,7 @@ JavaScript and wanting the same machine shape in Python services.

| Milestone | Result |
|---|---|
| Parallel transition domains | All 15 configured `more-parallel` cases pass; the configured SCXML suite is `56 passed`, `0 failed` |
| Parallel transition domains | All 15 configured `more-parallel` cases pass; the configured SCXML suite is `57 passed`, `0 failed` |
| Concept documentation | Machines, implementations, sync/async runtimes, actors, persistence, and SCXML import are documented |
| Runnable examples | `docs/examples/` is canonical and every runner has subprocess smoke coverage |
| Persistence adoption | Snapshot compatibility and timer/child-actor restoration limits are documented with a JSON resume example |
Expand All @@ -102,7 +102,7 @@ JavaScript and wanting the same machine shape in Python services.
|---|---|
| Package distribution | An approved package name installs the reviewed 0.7.1 artifact after ownership and publication are verified |
| Primary test count | Maintain 300+ focused tests |
| SCXML pass rate | Keep the configured 56-case suite green while expanding supported coverage |
| SCXML pass rate | Keep the configured 57-case suite green while expanding supported coverage |
| Docs | Keep every public runtime boundary represented by a maintained concept guide |
| Examples | Keep JSON, sync, async, actor, persistence, and SCXML runners green in CI; add framework integrations next |

Expand Down
2 changes: 1 addition & 1 deletion docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ structure with a JavaScript/XState frontend or a Stately-authored design.
| Gap | Notes |
|---|---|
| Package distribution | 0.7.1 artifacts can be built, but PyPI name/ownership and publication authorization are unresolved. |
| SCXML conformance | Configured SCXML suite is `56 passed`, `0 failed`; broader W3C coverage is not yet claimed. |
| SCXML conformance | Configured SCXML suite is `57 passed`, `0 failed`; broader W3C coverage is not yet claimed. |
| Full ECMAScript cond support | Intentionally not implemented; unsupported SCXML expressions raise `InvalidConfigError`. |
| Graph/test utilities | Mermaid export exists; no graph traversal/test-path helpers yet. |
| Inspector protocol | No `@statelyai/inspect` compatibility yet. |
Expand Down
2 changes: 1 addition & 1 deletion docs/concepts/scxml.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ in the same way; declaring data does not expand the supported grammar.

## Conformance Boundary

The configured repository subset contains 56 passing conformance cases, including all 15 enabled
The configured repository subset contains 57 passing conformance cases, including all 15 enabled
`more-parallel` cases, plus a fixture inventory/provenance guard. This is a focused subset, not a
claim of complete W3C SCXML conformance. The broader datamodel and executable content surface
remains future work.
Expand Down
52 changes: 44 additions & 8 deletions src/xstate/algorithm.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,8 +515,47 @@ def compute_exit_set(
return states_to_exit


def name_match(event: str, specific_event: str) -> bool:
return event == specific_event
def name_match(descriptor: str, event_type: str) -> bool:
"""Return whether an XState event descriptor matches an event type.

Bare descriptors are exact. ``*`` is the catch-all descriptor, while a
partial wildcard must end in ``.*`` and matches both the base token and its
dotted descendants. Infix and non-tokenized wildcard forms do not match.
"""
if descriptor == event_type or descriptor == "*":
return True
if not descriptor.endswith(".*") or descriptor.count("*") != 1:
return False
prefix = descriptor[:-2]
return bool(prefix) and (
event_type == prefix or event_type.startswith(f"{prefix}.")
)


def _event_descriptor_candidates(
state_node: StateNode, event_type: str
) -> TransitionSequence:
"""Return candidates in XState v5 descriptor-priority order."""
descriptors: list[str] = []
if event_type in state_node.on:
descriptors.append(event_type)

partial_descriptors = [
descriptor
for descriptor in state_node.on
if descriptor not in {"", event_type, "*"}
and name_match(descriptor, event_type)
]
descriptors.extend(sorted(partial_descriptors, key=len, reverse=True))

if "*" in state_node.on and event_type != "*":
descriptors.append("*")

return [
transition
for descriptor in descriptors
for transition in sorted(state_node.on[descriptor], key=lambda item: item.order)
]


def _matches_in_state(
Expand Down Expand Up @@ -646,14 +685,11 @@ def select_transitions(
for s in [state_node] + get_proper_ancestors(state_node, None):
if break_loop:
break
for t in sorted(s.transitions, key=lambda t: t.order):
if (
t.event
and name_match(t.event, event.name)
and condition_match(t, context, event, configuration)
):
for t in _event_descriptor_candidates(s, event.name):
if condition_match(t, context, event, configuration):
enabled_transitions.add(t)
break_loop = True
break
return remove_conflicting_transitions(
enabled_transitions,
configuration=configuration,
Expand Down
29 changes: 17 additions & 12 deletions src/xstate/state_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,21 +61,26 @@ def _get_relative(self, target: str) -> StateNode:
)
return node

if self.parent is None:
state_node = self.states.get(target)
if state_node is not None:
return state_node
raise InvalidConfigError(
f"Cannot resolve relative target '{target}' from root state "
f"node '#{self.id}'"
)
if target.startswith("."):
state_node = self
parts = target[1:].split(".")
else:
state_node = self.parent or self
parts = target.split(".")

state_node = self.parent.states.get(target)
if state_node is None:
if not parts or any(not part for part in parts):
raise InvalidConfigError(
f"Relative state node '{target}' does not exist on state "
f"node '#{self.id}'"
f"Relative target '{target}' is invalid on state node '#{self.id}'"
)

for part in parts:
child = state_node.states.get(part)
if child is None:
raise InvalidConfigError(
f"Relative target '{target}' does not exist from state "
f"node '#{self.id}'"
)
state_node = child
return state_node

def __repr__(self) -> str:
Expand Down
177 changes: 177 additions & 0 deletions tests/test_event_descriptors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""XState v5 event descriptor selection regressions."""

import pytest

from xstate import Machine


def _transition_value(on, event: str):
machine = Machine(
{
"id": "descriptors",
"initial": "idle",
"states": {
"idle": {"on": on},
"exact": {},
"long": {},
"short": {},
"fallback": {},
},
}
)
return machine.transition(machine.initial_state, event).value


def test_catch_all_descriptor_matches_any_event():
assert _transition_value({"*": "fallback"}, "unknown.event") == "fallback"


def test_exact_descriptor_has_priority_over_earlier_wildcard():
assert (
_transition_value(
{"*": "fallback", "user.*": "short", "user.created": "exact"},
"user.created",
)
== "exact"
)


def test_exact_guard_failure_falls_back_to_partial_wildcard():
assert (
_transition_value(
{
"user.created": {"target": "exact", "guard": lambda: False},
"user.*": "short",
},
"user.created",
)
== "short"
)


def test_longest_partial_wildcard_has_priority():
assert (
_transition_value(
{"resource.*": "short", "resource.item.*": "long"},
"resource.item.created",
)
== "long"
)


def test_longer_partial_guard_failure_falls_back_to_shorter_descriptor():
assert (
_transition_value(
{
"resource.*": "short",
"resource.item.*": {"target": "long", "guard": lambda: False},
},
"resource.item.created",
)
== "short"
)


def test_document_order_is_preserved_within_one_descriptor():
assert (
_transition_value(
{
"resource.*": [
{"target": "long", "guard": lambda: False},
"short",
"fallback",
]
},
"resource.created",
)
== "short"
)


@pytest.mark.parametrize("event", ["resource", "resource.item.created"])
def test_partial_wildcard_matches_base_and_dotted_descendants(event: str):
assert _transition_value({"resource.*": "short"}, event) == "short"


@pytest.mark.parametrize(
"descriptor",
["resource.*.created", "resource*", "resource.item*", "resource.*created"],
)
def test_invalid_wildcard_forms_do_not_match(descriptor: str):
assert (
_transition_value(
{descriptor: "exact", "*": "fallback"}, "resource.item.created"
)
== "fallback"
)


def test_bare_prefix_descriptor_remains_exact_only():
assert (
_transition_value({"error": "exact", "*": "fallback"}, "error.platform.worker")
== "fallback"
)


def test_parent_is_checked_only_after_all_local_candidates_fail():
machine = Machine(
{
"id": "parent-fallback",
"initial": "active",
"states": {
"active": {
"initial": "child",
"states": {
"child": {
"on": {
"task.done": {
"target": "local",
"guard": lambda: False,
},
"task.*": {
"target": "local",
"guard": lambda: False,
},
}
},
"local": {},
},
"on": {"task.*": "outside"},
},
"outside": {},
},
}
)

state = machine.transition(machine.initial_state, "task.done")

assert state.value == "outside"


def test_parallel_regions_select_descriptors_independently():
machine = Machine(
{
"id": "parallel-descriptors",
"type": "parallel",
"states": {
"left": {
"initial": "waiting",
"states": {
"waiting": {"on": {"job.*": "done"}},
"done": {},
},
},
"right": {
"initial": "waiting",
"states": {
"waiting": {"on": {"*": "done"}},
"done": {},
},
},
},
}
)

state = machine.transition(machine.initial_state, "job.finished")

assert state.value == {"left": "done", "right": "done"}
34 changes: 34 additions & 0 deletions tests/test_history_extended.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
- Multi-target transitions that restore multiple regions simultaneously
"""

import pytest

from xstate import Machine

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -230,6 +232,38 @@ def _reach_ACEKMO(machine):
return s


@pytest.mark.parametrize("history_type", ["shallow", "deep"])
def test_unvisited_parallel_history_enters_default_configuration(history_type):
"""Match XState's shallow and deep unvisited parallel-history shape."""
machine = Machine(
{
"id": "unvisited-parallel-history",
"initial": "off",
"states": {
"off": {"on": {"GO": "on.hist"}},
"on": {
"type": "parallel",
"states": {
"regA": {
"initial": "a1",
"states": {"a1": {}, "a2": {}},
},
"regB": {
"initial": "b1",
"states": {"b1": {}, "b2": {}},
},
"hist": {"type": "history", "history": history_type},
},
},
},
}
)

state = machine.transition(machine.initial_state, "GO")

assert state.value == {"on": {"regA": "a1", "regB": "b1"}}


def test_parallel_switch_enters_initials():
machine = make_parallel_history()
state = machine.initial_state
Expand Down
Loading