Skip to content

v3.2.0

Latest

Choose a tag to compare

@fgmacedo fgmacedo released this 17 Jun 02:27

Highlights

  • Load statecharts from documents. A single, secure statemachine.io.load
    reads SCXML, JSON and YAML into a running StateChart. From an inline definition:

    from statemachine.io import load
    
    Light = load(
        """
        states:
          green: {initial: true, on: {next: [{target: red}]}}
          red: {on: {next: [{target: green}]}}
        """,
        format="yaml",
    )
    sm = Light()
    sm.send("next")

    Or from a file, with the format detected from the extension:

    Machine = load("traffic_light.scxml")
  • Safe by default. Expressions in loaded documents are evaluated by a restricted
    allowlist, never eval — this also closes a code-execution vulnerability in the old
    SCXML loader (CVE-2026-47103); see Security below.

  • Python 3.10+ now required. Support for the end-of-life Python 3.9 was dropped.

Security: arbitrary code execution when loading SCXML (CVE-2026-47103)

In short: before 3.2.0, loading an SCXML document with SCXMLProcessor evaluated the
expressions inside it with Python's eval/exec, so a .scxml file from an untrusted
source could run arbitrary code on your machine. 3.2.0 makes loading safe by default:
expressions are evaluated by a restricted allowlist and <script> is rejected.

Note

Am I affected?

  • Yes — only if you loaded .scxml documents you did not author, through
    SCXMLProcessor (e.g. SCXMLProcessor().parse_scxml(...) or parse_scxml_file(...))
    on input you don't control. That class was the only SCXML loader in the affected
    releases (io.load() did not exist yet).
  • No — if you define your machines in Python (StateMachine / StateChart), or only
    load .scxml files you wrote yourself. Defining a machine in code never evaluates a
    document; there is no document to evaluate.

Affected versions: only the 3.x line before 3.2 — >= 3.0.0, < 3.2.0. SCXML file
loading was added in 3.0.0 (experimental and undocumented); 2.x and earlier have no SCXML
loader and are not affected. Fixed in 3.2.0.

What changed. Guards and datamodel expressions (cond, <assign>, <send>,
<foreach>, <log>, …) are now compiled by a restricted AST allowlist — arithmetic,
comparisons, collections, indexing, attribute reads and the In(...) predicate, but no
builtins, dunder access, or function/method calls. <script> is rejected. This mirrors
yaml.safe_load vs yaml.load.

Trusting a document. For SCXML you author yourself (hand-written documents or the W3C
conformance suite), opt back into full Python with trusted=True:

from statemachine.io.scxml.processor import SCXMLProcessor

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

A restricted-mode document that uses an unsupported construct fails to load with
InvalidDefinition; runtime evaluation errors still surface as error.execution events.

Refs: GHSA-v4jc-pm6r-3vj8, CVE-2026-47103, CWE-95.

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-allowlist 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 the IO and formats guide 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

Backward incompatible changes in 3.2.0

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).

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.

statemachine.io.scxml internals reorganized

The experimental statemachine.io.scxml internals were promoted into a format-neutral
IO core (see What's new above). 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 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.