Skip to content

v3.2.2

Latest

Choose a tag to compare

@fgmacedo fgmacedo released this 14 Oct 00:47
· 4 commits to main since this release

October 13, 2026

python-statemachine 3.2.2 is a bugfix release that also adds Python 3.15 support and closes
denial-of-service gaps in the restricted expression evaluator. Most of its
fixes close gaps between what the documentation promised and what the engine did: events
declared inside a State.Compound body, delay and internal declared on an Event, internal
events and the order of the event queues, callbacks spread over nested state bodies, and the
errors Graphviz reports when it cannot render a diagram.

Security fixes in 3.2.2

Unbounded arithmetic in the restricted evaluator (GHSA-9r2q-5523-wc47, GHSA-rfcg-j6wv-c8cr, GHSA-wq35-qxpw-w5jc)

The magnitude caps added in 3.2.1 (GHSA-r8gj-366q-cgvj) covered ** and sequence replication
with *, but other operators could still build a huge object from a small untrusted document
loaded with the default trusted=False: a product of two integers, a sequence concatenation
with +, nested collections that repeat a reference ([[0] * 1000] * 2000 is cheap to build
but walks two million items when compared, hashed or printed), and % string formatting, whose
field width sizes the result. The restricted evaluator now caps the integer product, and caps
every sequence or collection it builds by the size of its nested items counted per
reference, and it rejects % on str and bytes operands. Numeric % and ordinary arithmetic are
unaffected, and trusted=True still uses full Python without these caps. See
the security guide.

Reported by @manus-pi (GHSA-9r2q-5523-wc47,
GHSA-rfcg-j6wv-c8cr) and @manus-use (GHSA-wq35-qxpw-w5jc).

Python compatibility in 3.2.2

Added Python 3.15 on the test matrix. python-statemachine 3.2.2 supports Python 3.10, 3.11, 3.12,
3.13, 3.14 and 3.15.

#676.

Behavior changes to check when upgrading

Each item below fixes behavior that did not match the documented contract, which is why they
ship in a patch release. Code that relied on the old behavior can still behave differently or
fail after upgrading:

  • A transition wrapped in Event(...) or declared with the @source.to(target) decorator
    inside a State.Compound or State.Parallel body now fires only on its event. It used to be
    eventless. See Event declarations inside State.Compound.
  • A delay declared on an Event(...) now takes effect. On the sync engine the processing loop
    blocks the calling thread until the delay elapses. See delay and internal declared on an Event.
  • An event declared Event(..., internal=True) and triggered from a callback now runs within
    the current macrostep, before any pending external event. If it enables no transition it is
    ignored like any internal event; on the sync engine a StateMachine used to raise
    TransitionNotAllowed. See delay and internal declared on an Event.
  • A delayed internal event now raises InvalidDefinition: an Event(...) declared with both
    delay and internal=True, raise_() or send(..., internal=True) called with a delay or
    on an event declared with a delay (pass delay=0 to override the declared one),
    send(..., delay=...) on an event declared internal=True (pass internal=False to deliver
    it later on the external queue), and an SCXML, JSON or YAML <send> to #_internal with a
    delay, rejected when the document is loaded. See delay and internal declared on an Event.
  • raise_() and send(..., internal=True) called from outside a callback now return the
    result like send() does, and on a StateMachine they raise TransitionNotAllowed when the
    event enables no transition. See Internal events triggered from outside a callback.
  • Internal events and eventless transitions enabled by an external event now run before the
    next queued external event. They used to run after the whole batch of queued external
    events, and eventless guards are now evaluated once per external event. See
    Each external event gets its own macrostep.
  • Events still queued when the machine reaches a top-level final state are dropped. On the
    sync engine, a StateMachine used to raise TransitionNotAllowed for them. A coroutine
    awaiting a delayed one, which was never answered, now gets None, or TransitionNotAllowed
    on a StateMachine. See
    Each external event gets its own macrostep.
  • When an exception escapes an internal event or eventless transition enabled by an external
    event (any exception on a StateMachine, one from a validator or prepare_event on a
    StateChart), the events still queued are dropped, and a coroutine awaiting one gets the
    exception. The queued external events used to run before the internal event or eventless
    transition. Internal events raised before a callback fails, and events sent before a failed
    lazy initial activation on the async engine, are dropped too, instead of running at the next
    event. See Each external event gets its own macrostep.
  • On the async engine, with catch_errors_as_events=False, await send() raises an exception
    that escapes the internal events or eventless transitions following its event. It used to
    return the event's result, and the exception was lost. See Async engine: an exception after the event, and a cancelled processing loop.
  • An attribute named with the error_, done_state_ or done_invoke_ prefix now registers
    two events, so events, allowed_events and enabled_events() list both ids. See
    Prefixed events were registered under a glued id.
  • TriggerData.execution_time is now on the time.monotonic() clock instead of epoch seconds.
    See Ordering, timing and cancelling queued events.
  • A callback name reached by name (a naming convention, a string reference or a plain method)
    that is declared in two nested state bodies, or in a nested body and the class body, or that
    shares its name with an event or another attribute of the class, now raises
    InvalidDefinition. It used to replace the other declaration. See
    Callback names colliding between nested state bodies.
  • A decorated callback declared under the same name in several nested bodies now runs in each
    of them, unless the name is also referenced by name. Only the last body used to run. See
    Callback names colliding between nested state bodies.
  • A state reached through its parent (sm.door.shut) now reports its real is_active and
    parent. See is_active on a state reached through its parent.
  • % string formatting ('%d items' % n) in an expression of a document loaded with
    trusted=False is now rejected and routed to error.execution like any failing expression.
    Use trusted=True for documents that need it.
  • The diagram paths that run dot (the svg format, _repr_svg_(), the command line and the
    Sphinx directive) raise statemachine.contrib.diagram.GraphvizError where they raised pydot's
    AssertionError when dot failed. A missing dot raises GraphvizNotFoundError, which is
    still a FileNotFoundError. See Graphviz errors reported only the exit code.

Bug fixes in 3.2.2

Event declarations and queues

Event declarations inside State.Compound

A State.Compound (or State.Parallel) class body only understood the assignment form of an
event declaration (visit_pub = bag_end.to(green_dragon)). The two other documented forms were
silently dropped: the name was bound to a detached object and the transition it wrapped stayed
eventless, firing as soon as its source state became active.

The Event class now declares an event inside a nested state body:

>>> from statemachine import Event, State, StateChart

>>> class Journey(StateChart):
...     class shire(State.Compound):
...         bag_end = State(initial=True)
...         green_dragon = State()
...
...         visit_pub = Event(bag_end.to(green_dragon))
...
...     road = State(final=True)
...     depart = Event(shire.to(road))

>>> sm = Journey()
>>> set(sm.configuration_values) == {"shire", "bag_end"}
True

>>> sm.send("visit_pub")
>>> set(sm.configuration_values) == {"shire", "green_dragon"}
True

Before this fix, Journey started already in green_dragon and visit_pub was not among its
events. The attribute name now becomes the event id, an explicit id takes precedence, and
the error_ / done_state_ / done_invoke_ prefixes expand to their dotted form.

The same applies to the @<source>.to(<target>) decorator, which declares an event and its
inline action at once. Inside a compound body its transition was eventless too, and sending
the event raised AttributeError:

>>> class Gate(StateChart):
...     class gate(State.Compound):
...         locked = State(initial=True)
...         unlocked = State()
...
...         push = unlocked.to(locked)
...
...         @locked.to(unlocked)
...         def coin(self):
...             return "accepted"
...
...     broken = State(final=True)
...     smash = gate.to(broken)

>>> sm = Gate()
>>> sm.send("coin")
'accepted'

>>> set(sm.configuration_values) == {"gate", "unlocked"}
True

Two differences from the top-level form remain, both because a nested body is evaluated before
the owning class exists: an explicit id that differs from the attribute name does not also
bind the attribute name, and an Event with no transitions is dropped instead of becoming a
class attribute. Both are tracked by
#656.

Reported by @Dolecor.

#643,
#645.

delay and internal declared on an Event

light = Event(dark.to(lit), delay=50) rebuilt the event without its delay, so
BeaconsOfGondor.light.delay was 0 and the event fired immediately. internal was dropped
the same way. Both are now kept on the declaration:

>>> class BeaconsOfGondor(StateChart):
...     dark = State(initial=True)
...     lit = State(final=True)
...
...     light = Event(dark.to(lit), delay=50)

>>> BeaconsOfGondor.light.delay
50

A declared delay now applies every time the event is triggered, so sm.light() and
sm.send("light") queue the event for 50 ms. On the sync engine the processing loop blocks the
calling thread until the delay elapses (see Delayed events).

A declared internal=True is now honored when the event is triggered: sm.go() and
sm.send("go") from a callback put the event on the internal queue, so it runs within the
current macrostep, before any pending external event. It used to go to the external queue
and run after them:

>>> class Muster(StateChart):
...     camp = State(initial=True)
...     riding = State(final=True)
...     besieged = State(final=True)
...
...     summon = camp.to.itself(on="call")
...     ride = Event(camp.to(riding), internal=True)
...     siege = camp.to(besieged)
...
...     def call(self):
...         self.send("siege")
...         self.ride()

>>> sm = Muster()
>>> sm.send("summon")
>>> sm.riding.is_active
True

A delay never reached the internal queue: raise_("go", delay=50) fired at once. A macrostep
takes no time, and SCXML forbids delay on a <send> to #_internal, so a delayed internal
event is now an invalid definition, whether declared, sent, raised, or loaded from an SCXML,
JSON or YAML <send> to #_internal (also rejected by the JSON Schema):

>>> class DelayedBeacons(StateChart):  # doctest: -IGNORE_EXCEPTION_DETAIL
...     dark = State(initial=True)
...     lit = State(final=True)
...
...     light = Event(dark.to(lit), delay=50, internal=True)
Traceback (most recent call last):
...
statemachine.exceptions.InvalidDefinition: Event 'light' cannot be both delayed and internal: ...

The error names the event by its attribute name, or by its explicit id, inside a
State.Compound body too. The same applies to raise_() and send(..., internal=True) on an
event declared with a delay, which used to fire at once because the declared delay was lost,
and to send(..., delay=...) on an event declared internal=True, which used to be delivered
later because the declared internal was lost.

Drop internal to deliver the event later on the external queue, or drop delay to run it
within the current macrostep. An explicit argument to send() overrides the declaration:
send("light", delay=50, internal=False) delivers a declared-internal event later, and
send("light", delay=0) or raise_("light", delay=0) runs an event declared with a delay now.

Based on the original fix by @chiliec in
#663.

#643,
#645,
#655,
#668,
#679,
#683.

Internal events triggered from outside a callback

The internal queue belongs to the macrostep in progress, but it also accepted triggers that no
running step had raised. raise_() and send(..., internal=True) called from outside a callback
returned None instead of the callback result, and a StateMachine raised no
TransitionNotAllowed for an event that enabled no transition. On the async engine, the same
calls on a fresh machine were lost: they returned None and the transition never ran.

The internal queue now receives only what the machine raises while it runs a step. Triggered
from outside, raise_(), send(..., internal=True) and a declared-internal event go to the
external queue and give their caller what send() gives, the result, the exception or
TransitionNotAllowed:

>>> class Beacons(StateChart):
...     dark = State(initial=True)
...     lit = State(final=True)
...
...     light = Event(dark.to(lit, on="kindle"), internal=True)
...
...     def kindle(self):
...         return "kindled"

>>> Beacons().light()
'kindled'

>>> Beacons().raise_("light")
'kindled'

Based on the original fix by @chiliec in
#663.

#655,
#668.

Each external event gets its own macrostep

With several external events already queued, the engine took them one after the other and
only then ran the internal events and eventless transitions they had enabled. An internal
event raised while handling the first external event ran after the second, breaking the
run-to-completion order the processing model describes:

>>> class Relay(StateChart):
...     dark = State(initial=True)
...     lit = State(final=True)
...     besieged = State(final=True)
...
...     signal = dark.to.itself(internal=True, on="relay")
...     light = dark.to(lit)
...     siege = dark.to(besieged)
...
...     def relay(self):
...         self.raise_("light")
...         self.send("siege")

>>> sm = Relay()
>>> sm.send("signal")
>>> sm.lit.is_active
True

Each external event is now followed by its macrostep before the next one is taken, on both
engines. Eventless transitions are checked after every external event instead of once per
batch, so a burst of queued external events costs one guard evaluation each.

Once the machine reaches a top-level final state it stops, and the external events still in
its queue are dropped. On the sync engine, a StateMachine used to process them against the
final state and raise TransitionNotAllowed:

>>> from statemachine import StateMachine

>>> class Ferry(StateMachine):
...     docked = State(initial=True)
...     sunk = State(final=True)
...
...     sail = docked.to.itself()
...     storm = docked.to(sunk, on="flood")
...
...     def flood(self):
...         self.send("sail")

>>> sm = Ferry()
>>> sm.send("storm")
>>> sm.is_terminated
True

A coroutine awaiting the send() of a dropped event, delayed or not, gets the answer the
terminated machine gives to a new event: None, or TransitionNotAllowed for that event when
allow_event_without_transition is False, as on a StateMachine. This also applies when the
machine terminates because every region of a top-level parallel state reached a final state.
On 3.2.1, a coroutine awaiting a delayed event still pending when the machine terminated was
never answered.

An exception that escapes the internal events or eventless transitions following an external
event is now handled like one escaping the external event itself: every event still queued,
internal or external, delayed or not, is dropped, and a coroutine awaiting the send() of a
dropped event gets that exception. Any exception escapes with catch_errors_as_events=False,
as on a StateMachine; on a StateChart, one from a validator or prepare_event does. On
3.2.1 the queued external events ran before those internal events and eventless transitions,
and their callers got their own results; the exception was then raised by the sync send()
that started processing, and on the async engine it reached no caller. The coroutine whose
event enabled the failing transitions also gets the exception, see
Async engine: an exception after the event, and a cancelled processing loop. Internal events raised by a callback before it fails are
dropped too: they used to run at the next event. The same applies when the lazy initial
activation fails on the async engine, where a second coroutine awaiting an event sent before it
used to wait forever, and the events sent before it, which used to run at the next send(),
are dropped.

#668,
#678,
#686,
#685.

Async engine: an exception after the event, and a cancelled processing loop

An exception raised after a coroutine's event was lost. With
catch_errors_as_events=False, as on a StateMachine, an exception raised by the internal
events or eventless transitions that follow an external event reached no caller on the async
engine: await send() returned the event's result, and the exception was neither raised nor
logged. The sync engine raises it from send(). A coroutine is now answered once the
macrostep of its event ends, so the exception reaches it:

>>> import asyncio

>>> class Alarm(StateMachine):
...     idle = State(initial=True)
...     armed = State()
...     ringing = State()
...
...     arm = idle.to(armed)
...     trip = armed.to(ringing)
...     reset = ringing.to(idle)
...
...     async def on_enter_armed(self):
...         await self.raise_("trip")
...
...     async def on_trip(self):
...         raise ValueError("sensor failure")

>>> async def arm():
...     sm = Alarm()
...     await sm.activate_initial_state()
...     await sm.send("arm")

>>> asyncio.run(arm())
Traceback (most recent call last):
...
ValueError: sensor failure

When no coroutine awaits the event whose macrostep failed, because a callback sent it or its
caller was cancelled while awaiting it, the coroutine running the processing loop gets the
exception, as the
sync engine raises it from the send() running the loop. The callers of the events still
queued get the exception as before. A StateChart is unchanged: the error becomes
error.execution.

Cancelling the coroutine running the processing loop left the others waiting. When the
coroutine holding the processing loop was cancelled, as asyncio.wait_for() or a request
timeout does, the coroutines awaiting the send() of events queued behind it were not
answered until a later send() ran the loop again: CancelledError is not an Exception, so
the loop's error handling did not run. The cancellation now stops only that coroutine. The
internal events left by its macrostep are dropped, and a coroutine awaiting a queued event
takes the processing loop over, so each gets its own result, delayed events included. Like any
coroutine running the loop, the one taking over returns once the queue is empty. A coroutine
awaiting the event being processed when the cancellation hit, whose callbacks were interrupted
halfway, gets a StateMachineError saying so. With no coroutine waiting, the events left in
the queue, the cancelled coroutine's own included, run at the next send().

#689,
#690.

Prefixed events were registered under a glued id

The error_ / done_state_ / done_invoke_ prefixes declare two event ids, the attribute
name and its SCXML dotted form. Both reached the transition, so send("error.execution")
worked, but the chart registered a single event whose id was the two ids joined by a space.
sm.error_execution() raised TypeError on a bare transition and silently did nothing on an
Event(...), and allowed_events / enabled_events() raised AttributeError looking up an
attribute named error.execution.

Each id is now an event of its own, so both are listed, both are sendable, the attribute name
is callable as a method, and the declared arguments are kept on both. The
@source.to(target) decorator form, which registered only the attribute name, expands too:

>>> class ErrorLogger(StateChart):
...     running = State(initial=True)
...     failed = State(final=True)
...
...     error_execution = Event(running.to(failed), name="Boom")

>>> [(event.id, event.name) for event in ErrorLogger.events]
[('error_execution', 'Boom'), ('error.execution', 'Boom')]

>>> sm = ErrorLogger()
>>> [event.id for event in sm.enabled_events()]
['error_execution', 'error.execution']

>>> sm.error_execution()
>>> sm.failed.is_active
True

Code that lists events, allowed_events or enabled_events() now sees both ids. To offer
events to the outside without the ones the engine raises, see Platform events are not meant to be sent by user code.

Reported by @Dolecor.

#644,
#654,
#675.

Ordering, timing and cancelling queued events

Three fixes to how both engines order, time and cancel the triggers in their queues.

Events due at the same moment kept their order only by chance. Both event queues are
priority queues ordered by each trigger's due time, and triggers due at the same time came out
in no particular order. A clock coarse enough to stamp two events alike could run them out of
the order they were sent or raised: twenty events raised in one callback, all stamped with the
same time, ran as 0, 2, 6, 14, 19, 18, .... Each trigger now also holds the order it was
created in, and triggers due at the same moment run in that order, on both engines and both
queues.

Cancelling a delayed event could disorder the others and strand its caller.
cancel_event() removed a trigger by filtering the list behind the queue's heap, and a
filtered heap is not always a heap. When the loop reached them after they were due, after a
long callback or on a coarse clock, the triggers left could then run out of due order: of ten
delayed events due from 10 to 90 ms, cancelling the first left the one due at 50 ms running
before the one due at 40 ms. The trigger's future went with it, so on the async engine a
coroutine awaiting the cancelled event's send() was never answered. A cancelled trigger now
stays where it is, marked as cancelled, and is dropped when it reaches the head of the queue,
without the processing loop waiting for its due time. A coroutine awaiting it gets None as
soon as it is cancelled. A trigger waiting on its delay also stays in the queue while the loop
checks the clock, so a cancel_event() from another thread always finds it.

Delays were measured on the wall clock. Every trigger was stamped with time.time() plus
its delay, and the processing loop held a trigger until time.time() reached its stamp. A
step of the wall clock, from a time sync or a change of the system time, moved every due time
with it: stepped back, it held even an event sent with no delay until the clock caught up;
stepped forward, it ran delayed events early. Delays are now measured on time.monotonic(),
which no setting of the wall clock moves, and TriggerData.execution_time holds a value of that
clock instead of epoch seconds.

Contributed by @JMerrill-Fairness.

#665.

Nested state bodies and inheritance

Callback names colliding between nested state bodies

Callbacks declared inside a State.Compound or State.Parallel body are attached to the
statechart class under their own names, with no reference to the owning state. Two sibling
regions declaring the same callback name collided, and the last body silently won: with an
@go.on def act in each region of a parallel state, send("go") moved both regions but ran
only the second act. The same happened to a plain callback such as on_enter_<state>
declared in two bodies, and to a nested body overriding a method of the class body.

A callback attached through a decorator, or by passing the function itself (cond=ready), now
runs in every body that declares it:

>>> from statemachine.exceptions import InvalidDefinition

>>> class War(StateChart):
...     class war(State.Parallel):
...         class quest(State.Compound):
...             start = State(initial=True)
...             end = State(final=True)
...             go = start.to(end)
...
...             @go.on
...             def act(self):
...                 print("quest")
...
...         class battle(State.Compound):
...             fighting = State(initial=True)
...             won = State(final=True)
...             go = fighting.to(won)
...
...             @go.on
...             def act(self):
...                 print("battle")

>>> sm = War()
>>> _ = sm.send("go")
quest
battle

A name reached by name, through a naming convention, a string reference or a plain method,
cannot tell the bodies apart. Such a name declared in two nested bodies, or in a nested body and
the class body, is now rejected at class creation:

>>> try:
...     class War(StateChart):
...         class war(State.Parallel):
...             class quest(State.Compound):
...                 start = State(initial=True)
...                 end = State(final=True)
...                 go = start.to(end)
...
...                 def on_enter_end(self):
...                     print("quest")
...
...             class battle(State.Compound):
...                 fighting = State(initial=True)
...                 won = State(final=True)
...                 go = fighting.to(won)
...
...                 def on_enter_end(self):
...                     print("battle")
... except InvalidDefinition as e:
...     print(e)
Callback 'on_enter_end' is declared in both the 'quest' and the 'battle' state bodies. Nested state bodies share the class namespace. Rename one of them.

A plain method in a nested body named like an event or another attribute of the class used to
replace that attribute, so sending the event then failed with AttributeError. It is rejected
too:

>>> try:
...     class Quest(StateChart):
...         class journey(State.Compound):
...             start = State(initial=True)
...             end = State(final=True)
...             go = start.to(end)
...
...             def finish(self):
...                 return "done"
...
...         home = State(final=True)
...         finish = journey.to(home)
... except InvalidDefinition as e:
...     print(e)
Callback 'finish' in the 'journey' state body clashes with the event 'finish'. Nested state bodies share the class namespace. Rename one of them.

Assigning the same callable under one name in several bodies is still allowed, and so is
overriding a method inherited from a base class. See Callback names across nested state bodies. Giving
each nested body its own namespace for names, so that they can be reused across regions, is
tracked by #657.

#653,
#674.

Subclassing a chart with callbacks in a State.Compound body

Subclassing a StateChart whose compound (or parallel) body declared any callback, as a plain
method or through the @<event>.on / @<event>.cond decorators, raised
AttributeError: 'State' object has no attribute '_callbacks' while the subclass was being
built. The nested State objects are shared with the subclass, and unpacking the callbacks of
the body deleted the attribute the subclass then read. The subclass now builds, inherits the
callbacks of the base and may override the naming-convention ones as it would at the top level:

>>> class Shire(StateChart):
...     class shire(State.Compound):
...         bag_end = State(initial=True)
...         green_dragon = State()
...
...         visit_pub = bag_end.to(green_dragon)
...
...         def on_enter_green_dragon(self):
...             print("A pint for Frodo")
...
...     road = State(final=True)
...     depart = shire.to(road)

>>> class Hobbit(Shire):
...     pass

>>> sm = Hobbit()
>>> sm.send("visit_pub")
A pint for Frodo

#646,
#673.

is_active on a state reached through its parent

Reaching a nested state through its parent (sm.door.shut) returned the class-level State,
whose is_active is always False, while sm.shut.is_active on the same machine was True.
The same happened to parent. This held for any state nested in a compound or parallel
state. A state reached through any attribute path now resolves to the machine's per-instance
proxy:

>>> class Microwave(StateChart):
...     class microwave(State.Parallel):
...         my_state = State(initial=True)
...
...         class door(State.Compound):
...             shut = State(initial=True)

>>> sm = Microwave()
>>> sm.door.shut.is_active
True

>>> sm.door.shut is sm.shut
True

>>> sm.shut.parent.is_active
True

Creating a machine also rebound the shared parent of every nested state to that machine's
proxy, so with two instances alive, parent on the first answered for the second. Each
instance now keeps its own proxies and the class-level states are never mutated.

Reported and contributed by @MartinBernstorff.

#651,
#652.

Diagrams and Sphinx

Graphviz errors reported only the exit code

A diagram that dot refused to render (the svg format, _repr_svg_() in Jupyter, the
command line or the Sphinx directive) failed with "dot" with args [...] returned code: 1,
while the reason, such as Error: <stdin>: syntax error in line 25 near ',', was printed to
stdout by pydot, apart from the exception and from the Sphinx warning. These paths now run
dot directly and raise GraphvizError with the format
that was requested and what Graphviz wrote to stderr. The Sphinx directive reports the same
text in its warning.

GraphvizError is a subclass of StateMachineError. Code that caught pydot's
AssertionError around these paths when dot failed must catch GraphvizError instead. A
missing dot says so, with a link to the Graphviz download page, and raises
GraphvizNotFoundError, a subclass of both GraphvizError and FileNotFoundError, so
except FileNotFoundError still catches it.
Formats requested directly on the pydot.Dot object (write_png(), create_svg(), ...) are
still run by pydot and raise as before. See When Graphviz fails.

Reported by @barnettwilliam.

#649,
#672,
#680.

Sphinx diagram zoom links outside the documentation root

The statemachine-diagram directive with an empty :target: writes the full SVG to the
build's _images directory and links the diagram to it. The link started with a slash
(/_images/...), so documentation served below a URL prefix, such as
https://example.com/project/en/stable/, linked outside its own deployment. The link is now
relative to the page that holds the diagram, for both the html and dirhtml builders.

Contributed by @rastagan-git.

#650.

Documentation in 3.2.2

Platform events are not meant to be sent by user code

error.execution, done.state.<id> and done.invoke.<id> are raised by the engine, and SCXML
defines no external counterpart for them. The library does not tell them apart from user events:
a transition declared for one of them is listed by allowed_events and enabled_events(), and
send() accepts it. An external send() of one of them bypasses the error-handling contract. A
bare send("error.execution") reaches the handler with no error argument, and a
send("error.execution", error=exc) completes the transition although no action failed.
Platform events are not meant to be sent by user code now states this, and the automatic events
section links to it. No behavior changed.

Reported by @Dolecor.

#644,
#671.