Skip to content
Merged
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
124 changes: 124 additions & 0 deletions docs/releases/3.3.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

*Unreleased*

% Intro paragraph: written by the maintainer at release time.

## What's new in 3.3.0

### Extension keys in native documents
Expand All @@ -10,3 +12,125 @@ A JSON or YAML document can now carry top-level keys prefixed with `x-`, such as
`x-review: {owner: ops}`. The loader and the JSON Schema accept them and ignore their
content, so other tools can keep their data in the same file as the statechart. Other
unknown keys are still rejected. See [](../io/formats.md#extension-keys).

[#697](https://github.com/fgmacedo/python-statemachine/pull/697).

### UML test cases based on PSSM

The test suite now runs 103 cases generated from the test suite of the OMG Precise Semantics
of UML State Machines (PSSM) 1.0 specification, on both the sync and the async engine. Each
case is a native YAML statechart, with the stimulus and the expected traces kept under an
`x-pssm` extension key. The cases the library cannot pass yet are listed with a reason code,
so each later fix shows up as a case that starts passing, and the cases that pass only
because the converter lowers a construct the library cannot spell yet are listed apart. The OMG file is never committed: a
converter generates the cases from it, and a CI job checks that the committed cases match
its output. See [](../contributing.md#uml-cases-based-on-pssm).

[#697](https://github.com/fgmacedo/python-statemachine/pull/697).

## Behavior changes to check when upgrading

Each item below fixes behavior that did not match the documented contract. Code that relied
on the old behavior can still behave differently or fail after upgrading:

- The actions of a transition that targets a `HistoryState` now run once. They used to run
twice whenever the history state had a recorded value or a default transition. See {ref}`release-3.3.0-history`.
- Entering a `HistoryState` that has recorded nothing and declares no default transition now
enters its compound state through its initial state. The machine used to end up with an
empty configuration. See {ref}`release-3.3.0-history`.
- On a `StateMachine`, when an invoke handler finishes while the machine is idle and
processing its `done.invoke` raises, the error is now reported through `threading.excepthook` on the sync
engine and through the event loop's exception handler on the async engine. It used to be
sent back as `error.execution`, or dropped when the transition left the invoking state. See {ref}`release-3.3.0-done-invoke`.
- A `StateMachine` with an `error_execution` transition and no transition for
`done.invoke.<id>` now reports `TransitionNotAllowed` for that event, through the same
channels. It used to turn it into `error.execution`. See {ref}`release-3.3.0-done-invoke`.
- On a `StateChart`, an error raised by an invoke handler's `on_finalize` now becomes an
internal `error.execution`, processed after the transition of the event that ran the
finalize. That transition used to be skipped, and the error could reach the caller of an
unrelated `send()`. On a `StateMachine` the error propagates: to the caller driving the
loop, as before, or, when the invocation itself processes `done.invoke`, through the
channels above, where it used to become `error.execution`. See
{ref}`release-3.3.0-done-invoke`.

## Deprecations in 3.3.0

Deprecated APIs are removed in 4.0. Nothing is deprecated in 3.3.0 yet.

## Bug fixes in 3.3.0

(release-3.3.0-history)=

### Entering a history state by default

A transition to a `HistoryState` that had a recorded value or a default transition ran its
actions twice: once as the transition's own actions, and again on entry of the compound
state, as if they were the history's default.
And a history state with nothing recorded and no default transition (`h.to(...)`) entered
nothing at all: the machine was left with an empty configuration.

The default of a history state is now only its own transition, used only when nothing is
recorded. Without one, the compound state is entered through its initial state:

```py
>>> from statemachine import HistoryState, State, StateChart

>>> class Player(StateChart):
... class playing(State.Compound):
... track_1 = State(initial=True)
... track_2 = State()
... h = HistoryState()
... skip = track_1.to(track_2)
... stopped = State(initial=True)
... resume = stopped.to(playing.h, on="count_resume")
... stop = playing.to(stopped)
...
... resumes = 0
...
... def count_resume(self):
... self.resumes += 1

>>> sm = Player()
>>> sm.send("resume")
>>> sorted(sm.configuration_values)
['playing', 'track_1']
>>> sm.resumes
1

>>> sm.send("skip")
>>> sm.send("stop")
>>> sm.send("resume")
>>> sorted(sm.configuration_values)
['playing', 'track_2']
>>> sm.resumes
2

```

Before this fix, the first `resume` left `Player` with an empty configuration. See
{ref}`history-states`.

[#693](https://github.com/fgmacedo/python-statemachine/pull/693).

(release-3.3.0-done-invoke)=

### Errors raised while an invocation processes its `done.invoke`

When an invoke handler finishes while the machine is idle, the handler's own thread (sync
engine) or task (async engine) sends `done.invoke.<id>` and runs that macrostep. The same
`except` clause covered the handler's `run()` and that send, so an error raised while
processing `done.invoke` was treated as an error of the handler: it was dropped when the
transition had already left the invoking state, and sent back as `error.execution`
otherwise, even on a `StateMachine`, whose `catch_errors_as_events` is `False`. On the sync
engine, the error surfaced only when another thread happened to be driving the loop.

Now the invocation turns only an error from the handler's own `run()` into
`error.execution`. An error raised while
processing `done.invoke` is handled like an error in any other step: it becomes
`error.execution` when `catch_errors_as_events=True`, and propagates otherwise. With no
caller waiting for it, the sync engine lets it propagate out of the handler's thread, to
`threading.excepthook`, and the async engine passes it to the event loop's exception
handler. An error raised by a handler's `on_finalize` goes through the same error handling.
See [](../invoke.md#error-handling).

[#698](https://github.com/fgmacedo/python-statemachine/pull/698).
Loading