Skip to content

feat: StateChart (support for compound / parallel / historical states including SCXML notation)#501

Merged
fgmacedo merged 71 commits intodevelopfrom
macedo/scxml
Feb 14, 2026
Merged

feat: StateChart (support for compound / parallel / historical states including SCXML notation)#501
fgmacedo merged 71 commits intodevelopfrom
macedo/scxml

Conversation

@fgmacedo
Copy link
Owner

@fgmacedo fgmacedo commented Nov 22, 2024

Statecharts are here

This PR brings full statechart support to python-statemachine — the most significant evolution of the library since its creation. What started as an exploration of nested states grew into a comprehensive implementation of the SCXML specification (W3C), introducing compound states, parallel states, history pseudo-states, eventless transitions, error handling, and much more.

This is a major version release (3.0.0) with backwards-incompatible changes. The StateMachine class is preserved for backwards compatibility, while the new StateChart class provides full SCXML-compliant behavior out of the box.

Compound states

States can now contain inner substates using State.Compound with Python class syntax:

class ShireToRoad(StateChart):
    class shire(State.Compound):
        bag_end = State(initial=True)
        green_dragon = State()
        visit_pub = bag_end.to(green_dragon)

    road = State(final=True)
    depart = shire.to(road)

Entering a compound state activates both the parent and its initial child. Exiting removes the parent and all descendants.

Parallel states

Multiple regions can be active simultaneously using State.Parallel:

class WarOfTheRing(StateChart):
    class war(State.Parallel):
        class frodos_quest(State.Compound):
            shire = State(initial=True)
            mordor = State(final=True)
            journey = shire.to(mordor)
        class aragorns_path(State.Compound):
            ranger = State(initial=True)
            king = State(final=True)
            coronation = ranger.to(king)

Events in one region don't affect others — true orthogonal concurrency.

History pseudo-states

The HistoryState records the configuration of a compound state when exited. Re-entering via history restores the previously active child (shallow or deep):

class GollumPersonality(StateChart):
    class personality(State.Compound):
        smeagol = State(initial=True)
        gollum = State()
        h = HistoryState()
        dark_side = smeagol.to(gollum)
    outside = State()
    leave = personality.to(outside)
    return_via_history = outside.to(personality.h)

Eventless (automatic) transitions

Transitions without an event trigger fire automatically when their source state is active, cascading through the entire chain in a single macrostep.

DoneData on final states

Final states can provide data to done.state handlers via the donedata parameter, following the SCXML spec for communicating completion results.

Error handling with error.execution

When error_on_execution is enabled (default in StateChart), runtime exceptions are caught and result in an internal error.execution event — following the SCXML error handling specification. A error_ naming convention makes it easy:

class MyChart(StateChart):
    s1 = State(initial=True)
    error_state = State(final=True)
    go = s1.to(s1, on="bad_action")
    error_execution = s1.to(error_state)  # matches "error.execution" automatically

Additional features

  • Delayed eventssm.send("event", delay=500) with cancellation via sm.cancel_event(send_id)
  • Event matching per SCXML spec — hierarchical event name matching with wildcard * support
  • In(state) condition checkscond="In('state_id')" to check active configuration
  • prepare_event callback — inject custom kwargs into all callbacks
  • Multi-target transitions — a single transition can target multiple states
  • Dictionary-based definitionscreate_machine_class_from_definition() for dynamic state machine creation
  • Diagram generation — Graphviz rendering for compound, parallel, and history states with proper subgraph nesting

Engine architecture

Both the sync and async engines have been rewritten to implement the SCXML processing model:

  • 3-phase macrostep loop (eventless → internal queue → external queue)
  • Proper enter/exit ordering following the SCXML algorithm
  • configuration (ordered set of active states) replaces current_state
  • Internal and external event queues with priority handling

SCXML compliance

The test suite includes 198 W3C SCXML test cases (mandatory + optional), run on both sync and async engines. Currently 152 pass and 46 are marked as expected failures (mostly requiring <invoke> which is deferred to v3.1+).

Quality

  • 100% test coverage — 988 tests, 0 missing lines, 0 missing branches
  • Full support for both sync and async engines across all features
  • SonarCloud issues addressed
  • Comprehensive documentation with doctests

Backwards compatibility

The StateMachine class preserves the old behavior through class-level attribute defaults:

Attribute StateChart StateMachine
allow_event_without_transition True False
enable_self_transition_entries True False
atomic_configuration_update False True
error_on_execution True False

Existing StateMachine subclasses continue to work as before.

Known limitations (deferred to v3.1+)

  • <invoke> — invoking external services or sub-machines
  • HTTP and other external communication targets
  • <finalize> — processing data returned from invoked services

Closes #439, #455. Supersedes #329.

@codecov
Copy link

codecov bot commented Nov 22, 2024

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (d062de9) to head (76b2a85).
⚠️ Report is 1 commits behind head on develop.

Additional details and impacted files
@@             Coverage Diff              @@
##           develop      #501      +/-   ##
============================================
+ Coverage    99.77%   100.00%   +0.22%     
============================================
  Files           25        31       +6     
  Lines         1756      3592    +1836     
  Branches       230       553     +323     
============================================
+ Hits          1752      3592    +1840     
+ Misses           2         0       -2     
+ Partials         2         0       -2     
Flag Coverage Δ
unittests 100.00% <100.00%> (+0.22%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@fgmacedo fgmacedo force-pushed the macedo/scxml branch 5 times, most recently from f1edb7f to 1e7f40d Compare December 3, 2024 19:21
@fgmacedo fgmacedo force-pushed the macedo/scxml branch 3 times, most recently from 0890680 to 35f4bed Compare December 12, 2024 10:48
@fgmacedo fgmacedo changed the title feat: Basic support for SCXML test suit feat: Nested states (compound / parallel) and support for SCXML (test suit) Dec 14, 2024
@comalice
Copy link
Contributor

comalice commented Jan 7, 2025

Is this in a state (🤣) that I could try it out? I have several use-cases where the sub-state functionality would be useful and I'd be glad to give feedback if I run into issues.

Note, the user experience I had expected looked something like this:

class SubMachine(StateMachine):
    test = State(initial=True)
    test_complete = State(final=True)

    next = test.to(test_complete)


class TestSM(StateMachine):
    initial_state = State(initial=True)
    sub_machine_as_state = SubMachine()
    some_other_state = State(final=True)

    goto_sub_machine = initial_state.to(sub_machine_as_state)
    goto_finish = sub_machine_as_state.to(some_other_state)

And then something like

sm = TestSM()

sm.send('goto_sub_machine')
sm.send('next')   # TestSM now has the submachine states as states it can traverse.
sm.send('goto_finish')

With target-version py314, ruff format applies PEP 758 (parentheses-free
except clauses), which is invalid syntax on Python < 3.14. Since the
project supports Python 3.9+, the target must match the minimum version
to prevent incompatible syntax in formatted code.
# Conflicts:
#	pyproject.toml
#	uv.lock
Add `error_on_execution` flag (True on StateChart, False on StateMachine
for backward compat) that catches runtime exceptions in callbacks and
queues an `error.execution` internal event instead of propagating.

Error handling is layered by responsibility:
- callbacks.call(on_error=...): per-block isolation for onentry/onexit
  — errors in one block don't affect other blocks
- callbacks.all(on_error=...): condition errors treated as False
- Engine._conditions_match: delegates to callbacks.all with on_error
- Engine.microstep: catches errors in transition actions with rollback
- SCXML if_action: condition errors treated as false (SCXML translation)
- InvalidDefinition always propagates regardless of error_on_execution

Also fixes:
- Event with custom id in factory (preserve SCXML event ids like
  "error.execution" while keeping Python attribute accessible)
- SCXML _event system variable initialized to None before first event
- Infinite loop guard for error.execution in transition handlers
…ing tests

Auto-register dot-notation aliases for event attributes starting with
`error_` (e.g. `error_execution` matches both `error_execution` and
`error.execution`), removing the need for verbose `id="error.execution"`.

Add `docs/statecharts.md` documenting StateChart vs StateMachine class-level
flags (allow_event_without_transition, enable_self_transition_entries,
atomic_configuration_update, error_on_execution) and error.execution handling.

Comprehensive test coverage for error handling with conditions, listeners,
flow control, and SCXML spec compliance using LOTR-themed scenarios.
Parse <donedata> elements (<param> and <content>) from <final> states,
evaluate expressions at runtime, and pass the data correctly to
done.state.* events. Error cases (invalid expr/location) raise
error.execution per SCXML spec.

Fixes W3C conformance tests: 294, 298, 343, 488, 527, 528, 529.
Add comprehensive tests organized by scope in existing test files:
- Async engine: error propagation, InvalidDefinition handling, start noop
- Error execution: internal event error handling, microstep error paths
- State machine: compound state configuration, abstract SM, raise_ events
- Diagrams: compound/parallel subgraphs, dashed style, lhead attributes
- SCXML units: parser errors, action callables, schema edge cases
- Transitions: non-callable call, loop transitions, event name tests

Mark confirmed dead code with pragma no cover:
- factory.py: abstract class checks unreachable after early return
- sync.py: _run_microstep safety net (microstep handles errors internally)
- sync.py: secondary internal queue drain (macrostep loop already drains)
- base.py: is_in_final_state parallel/atomic branches (nested parallel)
The `target` parameter now accepts `State | List[State] | None`,
enabling SCXML parallel region entry where a single transition
targets multiple states. Internally stores as `_targets` list with
backward-compatible `target` property (returns first target) and
new `targets` property (returns full list).
…ixes

- Rewrite AsyncEngine to mirror SyncEngine's 3-phase macrostep
  architecture with full StateChart support
- Extract pure computation helpers from BaseEngine so both engines
  share the same logic
- Add on_error callback support to async dispatch methods
- Support multi-target initial transitions for SCXML parallel regions
  in engine, IO layer, parser, and factory
- Fix SCXML send error handling: error.communication for undispatchable
  session targets, error.execution for invalid targets/types, and
  namelist variable validation
- Remove resolved xfail markers (test364, test496, test521, test553)
Add `donedata` parameter to `State.__init__()` so Python-defined final
states can specify donedata callables directly. The SCXML processor now
passes donedata as a separate key instead of mixing it into enter
callbacks. Also removes the no-op `_processing_loop()` call from
`ExecuteBlock`.
Add async SCXML test variant using a minimal AsyncListener to trigger
AsyncEngine selection, keeping SCXMLProcessor free of async concerns.
Fix __initial__ handling in AsyncEngine (break instead of continue in
phase 3) to ensure internal events are processed before external ones.
- Render initial pseudo-state (black dot → initial child) inside all
  compound subgraphs, not just root level
- Add history state rendering as UML circles labeled "H" / "H*"
- Annotate parallel state subgraph labels with ☷ indicator
- Support multi-target transitions (one edge per target)
- Extract _add_transitions() helper to reduce _graph_states() complexity
- Remove stale TODO comment
- Fix NestedStateFactory to propagate kwargs (e.g. parallel=True) from
  State.Parallel/State.Compound base classes to subclass-created States
- Export HistoryState from statemachine.__init__
- Register event label "None" bug as release blocker in PLAN
Replace scattered `if self.sm.error_on_execution` checks and the broken
`_on_error_execution` property with two unified methods:

- `_on_error_handler(trigger_data)`: returns a bound callable (or None)
  for per-block callback error isolation (onentry/onexit/conditions).
- `_handle_error(error, trigger_data)`: for try/except blocks in
  microstep/_run_microstep — sends error.execution or re-raises.

Fixes two bugs in the previous code:
- base.py used `partial(self._on_error_execution, trigger_data)` which
  bound trigger_data to the wrong positional parameter (error), causing
  AttributeError on any per-block callback error.
- async_.py passed `self._on_error_execution` without binding
  trigger_data at all, which would fail with a missing argument.

Also fixes `_send_error_execution` parameter order to (error,
trigger_data) and removes the unused `functools.partial` import.
NestedStateFactory.__new__ was treating HistoryState as regular State
(via isinstance) and adding it to states=[]. HistoryState instances
need to go into history=[] for proper initialization in State.__init__.
Comprehensive test coverage for StateChart features using Python class
syntax: compound states, parallel states, history states, eventless
transitions, donedata, delayed events, error.execution, and In() conditions.
Add SMRunner fixture parametrized over sync/async to conftest.py.
Engine-dependent tests (53) now run on both engines via sm_runner,
producing 108 test cases total. Definition-only tests (2) remain sync.
Add done_state_ prefix handling in factory.py alongside the existing
error_ convention. Attributes starting with done_state_ auto-register
both the underscore and dot forms (e.g., done_state_quest matches
both "done_state_quest" and "done.state.quest"). Only the prefix is
replaced, preserving multi-word state names (done_state_lonely_mountain
→ done.state.lonely_mountain).

Simplify existing tests, examples, and docs to use the convention
instead of explicit id= parameters.

Add comprehensive StateChart documentation: compound states, parallel
states, history pseudo-states, eventless transitions, DoneData,
delayed events, In() conditions, error.execution, and the new
done_state_ convention.
Fix bug where transition_data.get("event") returning None was
interpolated as literal "None" in event IDs (e.g., "next None").

Complete remaining documentation: configuration property in states.md,
cross-boundary transitions and transition priority in transitions.md,
async-specific limitations in async.md, prepare_event convention and
create_machine_class_from_definition docstring in api.md.
Resolved conflicts in engines (adapted enabled_events for StateChart
architecture using configuration instead of current_state), statemachine.py,
transition_mixin.py (took develop's TypeError/decorator-syntax bugfix),
pyproject.toml (merged python_version markers with xdist/timeout deps),
and test files (kept both scxml and enabled_events tests).

Also restored engine.start() call in __setstate__ for async copy support.
@fgmacedo fgmacedo changed the title feat: Nested states (compound / parallel) and support for SCXML (test suit) feat: StateChart (support for compound / parallel / historical states including SCXML notation) Feb 14, 2026
- S1186: add comments to empty method bodies in tests
- S125: remove commented-out code in conftest.py and test_microwave.py
- S1192: extract _ERROR_EXECUTION constant in engines/base.py
- S108: invert condition to eliminate empty pass block in base engine
- S5713: remove redundant IndentationError (subclass of SyntaxError)
- S1940: use != instead of not == in processor.py
- S2772: remove unneeded pass in schema.py Action dataclass
- S1854: remove dead assignment to all_initial_states in parser.py
- S5806: rename id -> func_id to avoid shadowing builtin in spec_parser.py
- S1172: remove unused is_root parameter from diagram._graph_states
- S5655: fix Events.match type hint to accept str | None
- S5886: fix return type of create_machine_class_from_definition
Add tests and pragmas to eliminate all coverage gaps:

- Add tests for enabled_events dedup, history diagram node, IO
  definition parsing (_parse_history, event name concatenation),
  and SCXML history without transitions
- Remove unused trigger_data param from _on_error_handler
- Remove unused Transition._get_event_by_id method
- Mark genuinely unreachable defensive code with pragma comments
  (callbacks on_error path, SCXML algorithm guards, factory parallel
  initial transitions, parser/processor safety checks)
@sonarqubecloud
Copy link

Repository owner deleted a comment from sonarqubecloud bot Feb 14, 2026
@fgmacedo fgmacedo merged commit 08dd542 into develop Feb 14, 2026
14 checks passed
@fgmacedo fgmacedo deleted the macedo/scxml branch February 14, 2026 02:25
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.

feature request: return to previous state

2 participants