StateChart 3.2.0

June 16, 2026

Warning

Python 3.9 support dropped. StateMachine 3.2.0 requires Python 3.10 or later. If you cannot upgrade Python yet, pin to python-statemachine<3.2 (the 3.1.x series remains the last line supporting 3.9).

Backward incompatible changes in 3.2.0

Python 3.9 support dropped

Python 3.9 reached end-of-life on 2025-10-31 and is no longer supported by the Python core team. StateMachine 3.2 now requires Python 3.10+.

Rationale:

  • Python 3.9 represented around 1.4% of PyPI downloads of python-statemachine in the 180 days prior to this release; Python 3.10+ accounts for the vast majority of attributable traffic.

  • Dropping 3.9 lets the codebase adopt match/case (PEP 634), PEP 604 union syntax (X | Y), PEP 585 built-in generics (list[int] instead of List[int]), and zip(strict=True) (PEP 618) internally.

  • The same minimum has already been adopted by the major libraries in the ecosystem (pandas, FastAPI, SQLAlchemy, NumPy, Django 5).

Migration

  • Upgrade your interpreter to Python 3.10 or later, or

  • Pin python-statemachine<3.2 to stay on the 3.1.x line.

No public API was changed by this drop. Code that runs on 3.10+ today will continue to run unchanged on 3.2.

SCXML datamodel is now safe by default (GHSA-v4jc-pm6r-3vj8, CVE-2026-47103)

Note

Affected versions: >= 3.0.0, < 3.2.0 (fixed in 3.2.0).

Am I affected? Only if you load statechart documents from an untrusted or third-party source through the (experimental, previously undocumented) SCXML loader — statemachine.io.scxml.SCXMLProcessor or parsing a .scxml file. That loader is the only path that ever evaluated expressions from a document with eval/exec.

If you define your machines in Python with StateMachine / StateChart — the normal way to use this library — you are not affected. “SCXML” here names the W3C execution model the engine implements, not a file you load: nothing from a document is evaluated, because there is no document.

SCXML is executable content per the W3C specification: cond/expr attributes and <script> elements are evaluated in the datamodel language, and this library implements a Python datamodel. Previously SCXMLProcessor evaluated those with Python’s eval/exec with no restriction, so loading an SCXML document from an untrusted source allowed arbitrary code execution (CWE-95).

Starting in 3.2.0, SCXMLProcessor is safe by default, mirroring yaml.safe_load vs yaml.load:

  • Datamodel expressions (<data>, <assign>, <send>, <foreach>, <log>, cond) are evaluated by a restricted AST-whitelist evaluator that supports arithmetic, comparisons, collections, indexing, attribute reads and the In(...) predicate, but cannot reach builtins, dunder attributes, or arbitrary function/method calls.

  • <script> (arbitrary code) is rejected.

To restore the previous behavior for SCXML you trust (hand-authored documents, output of your own CASE tooling, conformance suites), pass trusted=True:

from statemachine.io.scxml.processor import SCXMLProcessor

SCXMLProcessor()               # safe default: restricted evaluator, <script> rejected
SCXMLProcessor(trusted=True)   # full eval/exec — only for trusted documents

A restricted-mode document that uses an unsupported construct fails to load with InvalidDefinition. Runtime evaluation errors (e.g. an undefined name) continue to surface as error.execution events as before.

statemachine.io.scxml internals reorganized

The experimental statemachine.io.scxml internals were promoted into a format-neutral IO core (see What’s new below). Code that imported implementation modules directly must update its imports:

Before (3.1.x)

After (3.2.0)

statemachine.io.scxml.schema

statemachine.io.model

statemachine.io.scxml.parser

statemachine.io.scxml.reader

generic helpers in statemachine.io.scxml.actions

statemachine.io.actions

protected_attrs / _eval in statemachine.io.scxml.actions

statemachine.io.evaluators

EventDataWrapper etc. in statemachine.io.scxml.actions

statemachine.io.system_variables

SCXMLInvoker in statemachine.io.scxml.invoke

Invoker in statemachine.io.invoke

statemachine.io.scxml.processor.SCXMLProcessor (now a thin wrapper over the new format-neutral runtime, minus the removed parse_scxml_file; use io.load for files) and statemachine.io.create_machine_class_from_definition keep their behavior. The runtime itself moved into the new, SCXML-agnostic statemachine.io.interpreter.Interpreter and statemachine.io.builder (see Architecture below).

What’s new in 3.2.0

First-class statechart IO: load from SCXML, JSON and YAML

Statecharts can now be loaded from declarative documents through a single, secure facade, statemachine.io.load():

from statemachine.io import load

Machine = load("traffic_light.yaml")   # format detected from the extension
sm = Machine()
  • Three formats behind one API: SCXML (.scxml/.xml), JSON (.json) and YAML (.yaml/.yml). The format is detected from the file extension or set explicitly with format=.

  • Secure by default. Guards and datamodel expressions are evaluated by a restricted AST-whitelist evaluator; <script> / arbitrary Python is rejected unless you pass trusted=True. YAML is always parsed with safe-load semantics.

  • Functional parity across formats. The native floor is the SCXML ceiling: everything SCXML expresses is expressible in JSON/YAML and behaves the same. cond/unless are real expressions (count >= 3, boolean algebra, In(state)); there is a structured action vocabulary (assign/raise/log/if/foreach/send/cancel); the system variables (_event/_sessionid/_name/_ioprocessors) are available in every format; and invoke works natively (invoke: [{src|content, id, params, namelist, finalize}]). All of this works under trusted=False.

  • Publishable, validatable schema. The native JSON/YAML format has a published JSON Schema (Draft 2020-12); pass validate=True (with the [validation] extra) to validate on load.

  • Low-level access via statemachine.io.build_processor() for documents that declare or invoke several machines.

See IO and formats for the full guide.

Architecture: a format-neutral runtime

The IO stack is a ports-and-adapters design. SCXML defines the execution model and behavior; the XML syntax is just one format. So the runtime is the format-neutral Interpreter (statemachine.io.interpreter), parameterized by a reader (the format port) and an evaluator (secure by default), composing a DefinitionBuilder (statemachine.io.builder) that compiles the neutral IR (statemachine.io.model) into a StateChart class. The class registry (for invoke), sessions and the system variables live in this neutral runtime. SCXMLProcessor is now a thin Interpreter preconfigured with the SCXML reader; its parse_scxml API is preserved (use io.load for files).

New optional dependencies

python-statemachine[yaml]        # PyYAML, for the YAML format
python-statemachine[validation]  # jsonschema, for validate=True
python-statemachine[io]          # both of the above

Bug fixes in 3.2.0

Sibling compound states with same-named children no longer collide

A StateChart with sibling compound states whose children reuse the same local id (e.g. each region declares an a and a b) but carry distinct value= identifiers would collapse in the internal instance-state map, which was keyed by state.id. The duplicate ids overwrote each other, so dispatching an event resolved to the wrong State instance and raised TransitionNotAllowed.

Instance states are now keyed by state.value (globally unique, the canonical identifier already used by states_map), fixing dispatch for nested and parallel configurations that repeat child names.

#624, #625.

Async invoke no longer cancels itself on its own done.invoke

When an async invoke completed and its done.invoke event triggered a transition out of the owning state, the cancel-on-exit path could cancel the invocation’s own task while it was still running. The CancelledError surfaced at the next await, so the target state’s on_enter callback never finished.

The engine now skips self-cancellation when the invocation’s task is the currently running task, letting the originating handler complete normally.

#627.

validators in a dict/JSON transition definition are no longer dropped

create_machine_class_from_definition (the dict/JSON adapter behind statemachine.io.load()) threaded cond, unless, on, before and after from each transition definition into the Transition, but not validators. A validators entry was silently ignored, so it never ran at send(), leaving dict/JSON-defined machines without the explicit-rejection channel (raise to abort with a reason) that validators provides. The TransitionDict type also mistyped validators as bool.

validators is now materialized onto the Transition like the other callback specs, and the type was corrected to the same callback-spec union as cond/unless.

#628, #629.