Skip to content

Releases: fgmacedo/python-statemachine

v2.4.0: *November 5, 2024*

05 Nov 17:14
Compare
Choose a tag to compare

StateMachine 2.4.0

November 5, 2024

What's new in 2.4.0

This release introduces powerful new features for the StateMachine library: {ref}Condition expressions and explicit definition of {ref}Events. These updates make it easier to define complex transition conditions and enhance performance, especially in workflows with nested or recursive event structures.

Python compatibility in 2.4.0

StateMachine 2.4.0 supports Python 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, and 3.13.

Conditions expressions in 2.4.0

This release introduces support for conditionals with Boolean algebra. You can now use expressions like or, and, and not directly within transition conditions, simplifying the definition of complex state transitions. This allows for more flexible and readable condition setups in your state machine configurations.

Example (with a spoiler of the next highlight):

>>> from statemachine import StateMachine, State, Event

>>> class AnyConditionSM(StateMachine):
...     start = State(initial=True)
...     end = State(final=True)
...
...     submit = Event(
...         start.to(end, cond="used_money or used_credit"),
...         name="finish order",
...     )
...
...     used_money: bool = False
...     used_credit: bool = False

>>> sm = AnyConditionSM()
>>> sm.submit()
Traceback (most recent call last):
TransitionNotAllowed: Can't finish order when in Start.

>>> sm.used_credit = True
>>> sm.submit()
>>> sm.current_state.id
'end'
See {ref}`Condition expressions` for more details or take a look at the {ref}`sphx_glr_auto_examples_lor_machine.py` example.

Explicit event creation in 2.4.0

Now you can explicit declare {ref}Events using the {ref}event class. This allows custom naming, translations, and also helps your IDE to know that events are callable.

>>> from statemachine import StateMachine, State, Event

>>> class StartMachine(StateMachine):
...     created = State(initial=True)
...     started = State(final=True)
...
...     start = Event(created.to(started), name="Launch the machine")
...
>>> [e.id for e in StartMachine.events]
['start']
>>> [e.name for e in StartMachine.events]
['Launch the machine']
>>> StartMachine.start.name
'Launch the machine'
See {ref}`Events` for more details.

Recursive state machines (infinite loop)

We removed a note from the docs saying to avoid recursion loops. Since the {ref}StateMachine 2.0.0 release we've turned the RTC model enabled by default, allowing nested events to occour as all events are put on an internal queue before being executed.

See {ref}`sphx_glr_auto_examples_recursive_event_machine.py` for an example of an infinite loop state machine declaration using `after` action callback to call the same event over and over again.

Bugfixes in 2.4.0

  • Fixes #484 issue where nested events inside loops could leak memory by incorrectly
    referencing previous event_data when queuing the next event. This fix improves performance and stability in event-heavy workflows.

v2.3.1: *June 10, 2024*

10 Jun 22:09
Compare
Choose a tag to compare

StateMachine 2.3.1

June 7, 2024

What's new in 2.3.1

This release has a high expected feature, we're adding asynchronous support, and enhancing overall functionality. In fact, the approach we took was to go all the way down changing the internals of the library to be fully async, keeping only the current external API as a thin sync/async adapter.

Python compatibility 2.3.1

StateMachine 2.3.1 supports Python 3.7, 3.8, 3.9, 3.10, 3.11 and 3.12.

We've fixed a bug on the package declaration that was preventing users from Python 3.7 to install the latest version.

Asynchronous Support in 2.3.1

This release introduces native coroutine support using asyncio, enabling seamless integration with asynchronous code.

Now you can send and await for events, and also write async Actions, Conditions and Validators.

>>> class AsyncStateMachine(StateMachine):
...     initial = State('Initial', initial=True)
...     final = State('Final', final=True)
...
...     advance = initial.to(final)

>>> async def run_sm():
...     sm = AsyncStateMachine()
...     await sm.advance()
...     print(sm.current_state)

>>> asyncio.run(run_sm())
Final

v2.2.0

07 May 00:32
Compare
Choose a tag to compare

StateMachine 2.2.0

May 6, 2024

What's new in 2.2.0

In this release, we conducted a general cleanup and refactoring across various modules to enhance code readability and maintainability. We improved exception handling and reduced code redundancy.

As a result, we achieved a ~2.2x faster setup in our performance tests and significantly simplified the callback machinery.

Check of unreachable and non-final states

We included one more state machine definition validation for non-final states.

We already check if any states are unreachable from the initial state, if not, an InvalidDefinition exception is thrown.

>>> from statemachine import StateMachine, State

>>> class TrafficLightMachine(StateMachine):
...     "A workflow machine"
...     red = State('Red', initial=True, value=1)
...     green = State('Green', value=2)
...     orange = State('Orange', value=3)
...     hazard = State('Hazard', value=4)
...
...     cycle = red.to(green) | green.to(orange) | orange.to(red)
...     blink = hazard.to.itself()
Traceback (most recent call last):
...
InvalidDefinition: There are unreachable states. The statemachine graph should have a single component. Disconnected states: ['hazard']

From this release, StateMachine will also check that all non-final states have an outgoing transition,
and warn you if any states would result in the statemachine becoming trapped in a non-final state with no further transitions possible.

This will currently issue a warning, but can be turned into an exception by setting `strict_states=True` on the class.
>>> from statemachine import StateMachine, State

>>> class TrafficLightMachine(StateMachine, strict_states=True):
...     "A workflow machine"
...     red = State('Red', initial=True, value=1)
...     green = State('Green', value=2)
...     orange = State('Orange', value=3)
...     hazard = State('Hazard', value=4)
...
...     cycle = red.to(green) | green.to(orange) | orange.to(red)
...     fault = red.to(hazard) | green.to(hazard) | orange.to(hazard)
Traceback (most recent call last):
...
InvalidDefinition: All non-final states should have at least one outgoing transition. These states have no outgoing transition: ['hazard']
`strict_states=True` will become the default behavior in the next major release.

See State Transitions.

Bugfixes in 2.2.0

  • Fixes #424 allowing deepcopy of state machines.
  • Dispatch Mechanism: Resolved issues in the dispatch mechanism in statemachine/dispatcher.py that affected the reliability
    of event handling across different states. This fix ensures consistent behavior when events are dispatched in complex state
    machine configurations.

v.2.1.2

06 Oct 16:01
Compare
Choose a tag to compare

StateMachine 2.1.2

October 6, 2023

This release improves the setup performance of the library by a 10x factor, with a major
refactoring on how we handle the callbacks registry and validations.

See #401 for the technical details.

Python compatibility 2.1.2

StateMachine 2.1.2 supports Python 3.7, 3.8, 3.9, 3.10, 3.11 and 3.12.

On the next major release (3.0.0), we will drop support for Python 3.7.

Bugfixes in 2.1.2

  • Fixes #406 action callback being
    called twice when mixing decorator syntax combined with the naming convention.

v2.1.1

03 Aug 12:32
Compare
Choose a tag to compare

StateMachine 2.1.1

August 3, 2023

Bugfixes in 2.1.1

v.2.1.0

12 Jun 02:20
Compare
Choose a tag to compare

StateMachine 2.1.0

June 11, 2023

What's new in 2.1.0

Added support for declaring states using Enum

Given an Enum type that declares our expected states:

>>> from enum import Enum

>>> class Status(Enum):
...     pending = 1
...     completed = 2

A StateMachine can be declared as follows:

>>> from statemachine import StateMachine
>>> from statemachine.states import States

>>> class ApprovalMachine(StateMachine):
...
...     _ = States.from_enum(Status, initial=Status.pending, final=Status.completed)
...
...     finish = _.pending.to(_.completed)
...
...     def on_enter_completed(self):
...         print("Completed!")

See States from Enum types.

Bugfixes in 2.1.0

  • Fixes #369 adding support to wrap
    methods used as Actions decorated with functools.partial.
  • Fixes #384 so multiple observers can watch the same callback.

v.2.0.0

05 Mar 23:13
Compare
Choose a tag to compare

StateMachine 2.0.0

March 5, 2023

Welcome to StateMachine 2.0.0!

This version is the first to take advantage of the Python3 improvements and is a huge internal refactoring removing the deprecated features on 1.*. We hope that you enjoy it.

These release notes cover the what's new in 2.0, as well as some backward incompatible changes you'll
want to be aware of when upgrading from StateMachine 1.*.

Python compatibility in 2.0

StateMachine 2.0 supports Python 3.7, 3.8, 3.9, 3.10, and 3.11.

What's new in 2.0

Run to completion (RTC) by default

There are now two distinct methods for processing events in the library. The new default is to run in
RTC model to be compliant with the specs, where the event is put on a queue before processing.
You can also configure your state machine to run back in Non-RTC model, where the event will
be run immediately and nested events will be chained.

This means that the state machine now completes all the actions associated with an event before moving on to the next event.
Even if you trigger an event inside an action.

See processing model for more details.

State names are now optional

State names are now by default derived from the class variable that they are assigned to.
You can keep declaring explicit names, but we encourage you to only assign a name
when it is different than the one derived from its id.

>>> from statemachine import StateMachine, State

>>> class ApprovalMachine(StateMachine):
...     pending = State(initial=True)
...     waiting_approval = State()
...     approved = State(final=True)
...
...     start = pending.to(waiting_approval)
...     approve = waiting_approval.to(approved)
...

>>> ApprovalMachine.pending.name
'Pending'

>>> ApprovalMachine.waiting_approval.name
'Waiting approval'

>>> ApprovalMachine.approved.name
'Approved'

Added support for internal transitions

An internal transition is like a self transition, but in contrast, no entry or exit actions
are ever executed as a result of an internal transition.

>>> from statemachine import StateMachine, State

>>> class TestStateMachine(StateMachine):
...     initial = State(initial=True)
...
...     loop = initial.to.itself(internal=True)

See internal transition for more details.

Added option to ignore unknown events

You can now instantiate a StateMachine with allow_event_without_transition=True,
so the state machine will allow triggering events that may not lead to a state transition,
including tolerance to unknown event triggers.

The default value is False, that keeps the backward compatible behavior of when an
event does not result in a transition, an exception TransitionNotAllowed will be raised.

>>> sm = ApprovalMachine(allow_event_without_transition=True)

>>> sm.send("unknow_event_name")

>>> sm.pending.is_active
True

>>> sm.send("approve")

>>> sm.pending.is_active
True

>>> sm.send("start")

>>> sm.waiting_approval.is_active
True

Added support for translations (i18n)

Now the library messages can be translated into any language.

See Add a translation on how to contribute with translations.

Minor features in 2.0

  • Modernization of the development tools to use linters and improved mypy support.
  • #342: Guards now supports the
    evaluation of truthy and falsy values.
  • #342: Assignment of Transition
    guards using decorators is now possible.
  • #331: Added a way to generate diagrams using QuickChart.io instead of GraphViz. See diagrams for more details.
  • #353: Support for abstract state machine classes, so you can subclass StateMachine to add behavior on your own base class. Abstract StateMachine cannot be instantiated.
  • #355: Now is possible to trigger an event as an action by registering the event name as the callback param.

Bugfixes in 2.0

  • #341: Fix dynamic dispatch
    on methods with default parameters.
  • #365: Fix transition with multiple
    events was calling actions of all events.

Backward incompatible changes in 2.0

  • Dropped support for Django <= 1.6 for auto-discovering and registering StateMachine classes
    to be used on django integration.

Statemachine class changes in 2.0

The new processing model (RTC) by default

While we've figured out a way to keep near complete backwards compatible changes to the new
Run to completion (RTC) by default feature (all built-in examples run without change),
if you encounter problems when upgrading to this version, you can still switch back to the old
Non-RTC model. Be aware that we may remove the Non-RTC model in the future.

StateMachine.run removed in favor of StateMachine.send

from tests.examples.traffic_light_machine import TrafficLightMachine

sm = TrafficLightMachine()
sm.run("cycle")

Should become:

>>> from tests.examples.traffic_light_machine import TrafficLightMachine

>>> sm = TrafficLightMachine()
>>> sm.send("cycle")
'Running cycle from green to yellow'

StateMachine.allowed_transitions removed in favor of StateMachine.allowed_events

from tests.examples.traffic_light_machine import TrafficLightMachine

sm = TrafficLightMachine()
assert [t.name for t in sm.allowed_transitions] == ["cycle"]

Should become:

>>> from tests.examples.traffic_light_machine import TrafficLightMachine

>>> sm = TrafficLightMachine()
>>> assert [t.name for t in sm.allowed_events] == ["cycle", "slowdown"]

Statemachine.is_<state> removed in favor of StateMachine.<state>.is_active

from tests.examples.traffic_light_machine import TrafficLightMachine

sm = TrafficLightMachine()
assert sm.is_green

Should become:

>>> from tests.examples.traffic_light_machine import TrafficLightMachine

>>> sm = TrafficLightMachine()
>>> assert sm.green.is_active

State class changes in 2.0

State.identification removed in favor of State.id

from tests.examples.traffic_light_machine import TrafficLightMachine

sm = TrafficLightMachine()
assert sm.current_state.identification == "green"

Should become:

>>> from tests.examples.traffic_light_machine import TrafficLightMachine

>>> sm = TrafficLightMachine()
>>> assert sm.current_state.id == "green"

v1.0.3

28 Jan 19:57
Compare
Choose a tag to compare

StateMachine 1.0.3

January 27, 2023

StateMachine 1.0.3 fixes a bug between {ref}State and {ref}transition instances sharing
references of callbacks when there were multiple concurrent instances of the same StateMachine
class.

Bugfixes

  • #334: Fixed a shared reference
    of callbacks when there were multiple concurrent instances of the same StateMachine class.

v1.0.2

13 Jan 01:01
Compare
Choose a tag to compare

StateMachine 1.0.2

January 12, 2023

StateMachine 1.0.2 fixes a regression bug blocking the library usage on
Python 3.11.

Bugfixes

  • Fixes #316 a bad import of 'inspect.getargspec' that was removed on Python 3.11, still backward compatible with older versions.

v1.0.1

12 Jan 03:18
Compare
Choose a tag to compare

StateMachine 1.0.1

January 11, 2023

Welcome to StateMachine 1.0!

This version is a huge refactoring adding a lot of new and exiting features. We hope that
you enjoy.

These release notes cover the , as well as
some backwards incompatible changes you'll
want to be aware of when upgrading from StateMachine 0.9.0 or earlier. We've
begun the deprecation process for some features.

Python compatibility in 1.0

StateMachine 1.0 supports Python 2.7, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, and 3.11.

This is the last release to support Python 2.7, 3.5 and 3.6.

What's new in 1.0

Validators and Guards

Transitions now support cond and unless parameters, to restrict
the execution.

    class ApprovalMachine(StateMachine):
        "A workflow machine"
        requested = State("Requested", initial=True)
        accepted = State("Accepted")
        rejected = State("Rejected")
        completed = State("Completed")

        validate = requested.to(accepted, cond="is_ok") | requested.to(rejected)

Support for diagrams

You can generate diagrams from your statemachine.

Example:

OrderControl

Unified dispatch mecanism for callbacks (actions and guards)

Every single callback, being actions or guards, is now handled equally by the library.

Also, we've improved the internals in a way that you can implement your callbacks with any
number of arbritrary positional or keyword arguments (*args, **kwargs), and the dispatch will
match the available arguments with your method signature.

This means that if on your on_enter_<state>() or on_execute_<event>() method, you also
need to know the source (state), or the event (event), or access a keyword
argument passed with the trigger, you're covered. Just add this parameter to the method and It
will be passed by the dispatch mechanics.

Example of what's available:

def action_or_guard_method_name(self, *args, event_data, event, source, state, model, **kwargs):
    pass

Add observers to a running StateMachine

Observers are a way do generically add behaviour to a StateMachine without
changing it's internal implementation.

The StateMachine itself is registered as an observer, so by using StateMachine.add_observer()
an external object can have the same level of functionalities provided to the built-in class.

Minor features in 1.0

  • Fixed mypy complaining about incorrect type for StateMachine class.
  • The initial state is now entered when the machine starts. The actions, if defined,
    on_enter_state and on_enter_<state> are now called.

Backwards incompatible changes in 1.0

Multiple targets from the same origin state

Prior to this release, as we didn't have validators-and-guards, there wasn't an elegant way
to declare multiples target states starting from the same pair (event, state). But the library
allowed a near-hackish way, by declaring a target state as the result of the on_<event> callback.

So, the previous code (not valid anymore):

class ApprovalMachine(StateMachine):
    "A workflow machine"
    requested = State('Requested', initial=True)
    accepted = State('Accepted')
    rejected = State('Rejected')

    validate = requested.to(accepted, rejected)

    def on_validate(self, current_time):
        if self.model.is_ok():
            self.model.accepted_at = current_time
            return self.accepted
        else:
            return self.rejected

Should be rewriten to use guards, like this:

class ApprovalMachine(StateMachine):
    "A workflow machine"
    requested = State("Requested", initial=True)
    accepted = State("Accepted")
    rejected = State("Rejected")

    validate = requested.to(accepted, conditions="is_ok") | requested.to(rejected)

    def on_validate(self, current_time):
        self.model.accepted_at = current_time

StateMachine now enters the initial state

This issue was reported at #265.

Now StateMachine will execute the actions associated with the on_enter_state and
on_enter_<state> when initialized, if they exists.

Integrity is checked at class definition

Statemachine integrity checks are now performed at class declaration (import time) instead of on
instance creation. This allows early feedback of invalid definitions.

This was the previous behaviour, you only got an error when trying to instantiate a StateMachine:

class CampaignMachine(StateMachine):
    "A workflow machine"
    draft = State('Draft', initial=True)
    producing = State('Being produced')
    closed = State('Closed', initial=True)  # Should raise an Exception when instantiated

    add_job = draft.to(draft) | producing.to(producing)
    produce = draft.to(producing)
    deliver = producing.to(closed)

with pytest.raises(exceptions.InvalidDefinition):
    CampaignMachine()

Not this is performed as the class definition is performed:

with pytest.raises(exceptions.InvalidDefinition):

    class CampaignMachine(StateMachine):
        "A workflow machine"
        draft = State("Draft", initial=True)
        producing = State("Being produced")
        closed = State(
            "Closed", initial=True
        )  # Should raise an Exception right after the class is defined

        add_job = draft.to(draft) | producing.to(producing)
        produce = draft.to(producing)
        deliver = producing.to(closed)

Other backwards incompatible changes in 1.0

  • Due to the check validations and setup performed at the machine initialization, it's now harder
    to perform monkey-patching to add callbacks at runtime (not a bad thing after all).
  • TransitionNotAllowed changed internal attr from transition to event.
  • CombinedTransition does not exist anymore. State now holds a flat Transition list
    called TransitionList that implements de OR operator. This turns a valid StateMachine
    traversal much easier: [transition for state in machine.states for transition in state.transitions].
  • StateMachine.get_transition is removed. See event.
  • The previous excetions MultipleStatesFound and MultipleTransitionCallbacksFound are removed.
    Since now you can have more than one callback defined to the same transition.
  • on_enter_state and on_exit_state now accepts any combination of parameters following the
    dynamic-dispatch rules. Previously it only accepted the state param.
  • Transition.__init__ param on_execute renamed to simply on, and now follows the
    dynamic-dispatch.
  • Transition.destinations removed in favor of Transition.target (following SCXML convention).
    Now each transition only points to a unique target. Each source->target pair is holded by a
    single Transition.

Deprecated features in 1.0

Statemachine class

  • StateMachine.run is deprecated in favor of StateMachine.send.
  • StateMachine.allowed_transitions is deprecated in favor of StateMachine.allowed_events.
  • Statemachine.is_<state> is deprecated in favor of StateMachine.<state>.is_active.

State class

  • State.identification is deprecated in favor of State.id.