Skip to main content

Python Finite State Machines made easy.

Project description

Python StateMachine

pypi downloads total downloads Coverage report Documentation Status GitHub commits since last release (main)

Python finite-state machines made easy.

Welcome to python-statemachine, an intuitive and powerful state machine library designed for a great developer experience. We provide a pythonic and expressive API for implementing state machines in sync or asynchonous Python codebases.

Features

  • Basic components: Easily define States, Events, and Transitions to model your logic.
  • ⚙️ Actions and handlers: Attach actions and handlers to states, events, and transitions to control behavior dynamically.
  • 🛡️ Conditional transitions: Implement Guards and Validators to conditionally control transitions, ensuring they only occur when specific conditions are met.
  • 🚀 Full async support: Enjoy full asynchronous support. Await events, and dispatch callbacks asynchronously for seamless integration with async codebases.
  • 🔄 Full sync support: Use the same state machine from synchronous codebases without any modifications.
  • 🎨 Declarative and simple API: Utilize a clean, elegant, and readable API to define your state machine, making it easy to maintain and understand.
  • 👀 Observer pattern support: Register external and generic objects to watch events and register callbacks.
  • 🔍 Decoupled design: Separate concerns with a decoupled "state machine" and "model" design, promoting cleaner architecture and easier maintenance.
  • Correctness guarantees: Ensured correctness with validations at class definition time:
    • Ensures exactly one initial state.
    • Disallows transitions from final states.
    • Requires ongoing transitions for all non-final states.
    • Guarantees all non-final states have at least one path to a final state if final states are declared.
    • Validates the state machine graph representation has a single component.
  • 📦 Flexible event dispatching: Dispatch events with any extra data, making it available to all callbacks, including actions and guards.
  • 🔧 Dependency injection: Needed parameters are injected into callbacks.
  • 📊 Graphical representation: Generate and output graphical representations of state machines. Create diagrams from the command line, at runtime, or even in Jupyter notebooks.
  • 🌍 Internationalization support: Provides error messages in different languages, making the library accessible to a global audience.
  • 🛡️ Robust testing: Ensured reliability with a codebase that is 100% covered by automated tests, including all docs examples. Releases follow semantic versioning for predictable releases.
  • 🏛️ Domain model integration: Seamlessly integrate with domain models using Mixins.
  • 🔧 Django integration: Automatically discover state machines in Django applications.

Installing

To install Python State Machine, run this command in your terminal:

pip install python-statemachine

To generate diagrams from your machines, you'll also need pydot and Graphviz. You can install this library already with pydot dependency using the extras install option. See our docs for more details.

pip install python-statemachine[diagrams]

First example

Define your state machine:

>>> from statemachine import StateMachine, State

>>> class TrafficLightMachine(StateMachine):
...     "A traffic light machine"
...     green = State(initial=True)
...     yellow = State()
...     red = State()
...
...     cycle = (
...         green.to(yellow)
...         | yellow.to(red)
...         | red.to(green)
...     )
...
...     def before_cycle(self, event: str, source: State, target: State, message: str = ""):
...         message = ". " + message if message else ""
...         return f"Running {event} from {source.id} to {target.id}{message}"
...
...     def on_enter_red(self):
...         print("Don't move.")
...
...     def on_exit_red(self):
...         print("Go ahead!")

You can now create an instance:

>>> sm = TrafficLightMachine()

This state machine can be represented graphically as follows:

>>> img_path = "docs/images/readme_trafficlightmachine.png"
>>> sm._graph().write_png(img_path)

Where on the TrafficLightMachine, we've defined green, yellow, and red as states, and one event called cycle, which is bound to the transitions from green to yellow, yellow to red, and red to green. We also have defined three callbacks by name convention, before_cycle, on_enter_red, and on_exit_red.

Then start sending events to your new state machine:

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

That's it. This is all an external object needs to know about your state machine: How to send events. Ideally, all states, transitions, and actions should be kept internally and not checked externally to avoid unnecessary coupling.

But if your use case needs, you can inspect state machine properties, like the current state:

>>> sm.current_state.id
'yellow'

Or get a complete state representation for debugging purposes:

>>> sm.current_state
State('Yellow', id='yellow', value='yellow', initial=False, final=False)

The State instance can also be checked by equality:

>>> sm.current_state == TrafficLightMachine.yellow
True

>>> sm.current_state == sm.yellow
True

Or you can check if a state is active at any time:

>>> sm.green.is_active
False

>>> sm.yellow.is_active
True

>>> sm.red.is_active
False

Easily iterate over all states:

>>> [s.id for s in sm.states]
['green', 'yellow', 'red']

Or over events:

>>> [t.id for t in sm.events]
['cycle']

Call an event by its id:

>>> sm.cycle()
Don't move.
'Running cycle from yellow to red'

Or send an event with the event id:

>>> sm.send('cycle')
Go ahead!
'Running cycle from red to green'

>>> sm.green.is_active
True

You can pass arbitrary positional or keyword arguments to the event, and they will be propagated to all actions and callbacks using something similar to dependency injection. In other words, the library will only inject the parameters declared on the callback method.

Note how before_cycle was declared:

def before_cycle(self, event: str, source: State, target: State, message: str = ""):
    message = ". " + message if message else ""
    return f"Running {event} from {source.id} to {target.id}{message}"

The params event, source, target (and others) are available built-in to be used on any action. The param message is user-defined, in our example we made it default empty so we can call cycle with or without a message parameter.

If we pass a message parameter, it will be used on the before_cycle action:

>>> sm.send("cycle", message="Please, now slowdown.")
'Running cycle from green to yellow. Please, now slowdown.'

By default, events with transitions that cannot run from the current state or unknown events raise a TransitionNotAllowed exception:

>>> sm.send("go")
Traceback (most recent call last):
statemachine.exceptions.TransitionNotAllowed: Can't go when in Yellow.

Keeping the same state as expected:

>>> sm.yellow.is_active
True

A human-readable name is automatically derived from the State.id, which is used on the messages and in diagrams:

>>> sm.current_state.name
'Yellow'

Async support

We support native coroutine using asyncio, enabling seamless integration with asynchronous code. There's no change on the public API of the library to work on async codebases.

>>> class AsyncStateMachine(StateMachine):
...     initial = State('Initial', initial=True)
...     final = State('Final', final=True)
...
...     advance = initial.to(final)
...
...     async def on_advance(self):
...         return 42

>>> async def run_sm():
...     sm = AsyncStateMachine()
...     result = await sm.advance()
...     print(f"Result is {result}")
...     print(sm.current_state)

>>> asyncio.run(run_sm())
Result is 42
Final

A more useful example

A simple didactic state machine for controlling an Order:

>>> class OrderControl(StateMachine):
...     waiting_for_payment = State(initial=True)
...     processing = State()
...     shipping = State()
...     completed = State(final=True)
...
...     add_to_order = waiting_for_payment.to(waiting_for_payment)
...     receive_payment = (
...         waiting_for_payment.to(processing, cond="payments_enough")
...         | waiting_for_payment.to(waiting_for_payment, unless="payments_enough")
...     )
...     process_order = processing.to(shipping, cond="payment_received")
...     ship_order = shipping.to(completed)
...
...     def __init__(self):
...         self.order_total = 0
...         self.payments = []
...         self.payment_received = False
...         super(OrderControl, self).__init__()
...
...     def payments_enough(self, amount):
...         return sum(self.payments) + amount >= self.order_total
...
...     def before_add_to_order(self, amount):
...         self.order_total += amount
...         return self.order_total
...
...     def before_receive_payment(self, amount):
...         self.payments.append(amount)
...         return self.payments
...
...     def after_receive_payment(self):
...         self.payment_received = True
...
...     def on_enter_waiting_for_payment(self):
...         self.payment_received = False

You can use this machine as follows.

>>> control = OrderControl()

>>> control.add_to_order(3)
3

>>> control.add_to_order(7)
10

>>> control.receive_payment(4)
[4]

>>> control.current_state.id
'waiting_for_payment'

>>> control.current_state.name
'Waiting for payment'

>>> control.process_order()
Traceback (most recent call last):
...
statemachine.exceptions.TransitionNotAllowed: Can't process_order when in Waiting for payment.

>>> control.receive_payment(6)
[4, 6]

>>> control.current_state.id
'processing'

>>> control.process_order()

>>> control.ship_order()

>>> control.payment_received
True

>>> control.order_total
10

>>> control.payments
[4, 6]

>>> control.completed.is_active
True

There's a lot more to cover, please take a look at our docs: https://python-statemachine.readthedocs.io.

Contributing

  • If you found this project helpful, please consider giving it a star on GitHub.

  • Contribute code: If you would like to contribute code, please submit a pull request. For more information on how to contribute, please see our contributing.md file.

  • Report bugs: If you find any bugs, please report them by opening an issue on our GitHub issue tracker.

  • Suggest features: If you have an idea for a new feature, of feels something being harder than it should be, please let us know by opening an issue on our GitHub issue tracker.

  • Documentation: Help improve documentation by submitting pull requests.

  • Promote the project: Help spread the word by sharing on social media, writing a blog post, or giving a talk about it. Tag me on Twitter @fgmacedo so I can share it too!

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

python_statemachine-2.5.0.tar.gz (403.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

python_statemachine-2.5.0-py3-none-any.whl (50.4 kB view details)

Uploaded Python 3

File details

Details for the file python_statemachine-2.5.0.tar.gz.

File metadata

  • Download URL: python_statemachine-2.5.0.tar.gz
  • Upload date:
  • Size: 403.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/5.1.1 CPython/3.12.7

File hashes

Hashes for python_statemachine-2.5.0.tar.gz
Algorithm Hash digest
SHA256 ae88cd22e47930b92b983a2176e61d811e571b69897be2568ec812c2885fb93a
MD5 04598d34159bedc13a4998e6a045f0d0
BLAKE2b-256 45914f05f3931d1e9b1df71b17dc08c43feddf2bed7dbf13f95323df2cc8e340

See more details on using hashes here.

Provenance

The following attestation bundles were made for python_statemachine-2.5.0.tar.gz:

Publisher: release.yml on fgmacedo/python-statemachine

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file python_statemachine-2.5.0-py3-none-any.whl.

File metadata

File hashes

Hashes for python_statemachine-2.5.0-py3-none-any.whl
Algorithm Hash digest
SHA256 0ed53846802c17037fcb2a92323f4bc0c833290fa9d17a3587c50886c1541e62
MD5 217303116c31eb3b850043df410c4c1b
BLAKE2b-256 bf2d1c95ebe84df60d630f8e855d1df2c66368805444ac167e9b50f29eabe917

See more details on using hashes here.

Provenance

The following attestation bundles were made for python_statemachine-2.5.0-py3-none-any.whl:

Publisher: release.yml on fgmacedo/python-statemachine

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page