fgmacedo / python-statemachine

Python Finite State Machines made easy.
MIT License
854 stars 84 forks source link

feat: Support for native coroutines (asyncio) #435

Closed fgmacedo closed 3 months ago

fgmacedo commented 4 months ago

This PR changes the internals of the library. All the inner code becomes async with a thin sync wrapper.

This allows support for native coroutines, as expected in #388, also allows awaiting any event handler. A developer can mix sync and async callbacks freely, like using a web framework like FastAPI.

Milestones:

Examples

Async Air Conditioner machine

A StateMachine that exercises reading from a stream of events.

Note the new API sm.async_send() in contrast with the current sync sm.send().

import asyncio
import random

from statemachine import State
from statemachine import StateMachine

def sensor_temperature_reader(seed: int, lower: int = 15, higher: int = 35):
    "Infinitely generates random temperature readings."
    random.seed(seed)
    while True:
        yield random.randint(lower, higher)

class AirConditioner(StateMachine):
    off = State(initial=True)
    cooling = State()
    standby = State()

    sensor_updated = (
        off.to(cooling, cond="is_hot")
        | cooling.to(standby, cond="is_good")
        | standby.to(cooling, cond="is_hot")
        | standby.to(off, cond="is_cool")
        | off.to.itself(internal=True)
        | cooling.to.itself(internal=True)
        | standby.to.itself(internal=True)
    )

    async def is_hot(self, temperature: int):
        return temperature > 25

    async def is_good(self, temperature: int):
        return temperature < 20

    async def is_cool(self, temperature: int):
        return temperature < 18

    async def after_transition(self, event: str, source: State, target: State, event_data):
        print(f"Running {event} from {source!s} to {target!s}: {event_data.trigger_data.kwargs!r}")

Testing

async def main():
    sensor = sensor_temperature_reader(123456)
    print("Will create AirConditioner machine")
    sm = AirConditioner()

    generator = (("sensor_updated", next(sensor)) for _ in range(20))
    for event, temperature in generator:
        await sm.send(event, temperature=temperature)

if __name__ == "__main__":
    asyncio.run(main())

Async Order Control

Note the new API for async trigger an event sm.async_<event_name>() in contrast with the current sync sm.<event_name>().

import asyncio

from statemachine import State
from statemachine import StateMachine

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

    async def payments_enough(self, amount):
        return sum(self.payments) + amount >= self.order_total

    async def before_add_to_order(self, amount):
        self.order_total += amount
        return self.order_total

    async def before_receive_payment(self, amount):
        self.payments.append(amount)
        return self.payments

    async def after_receive_payment(self):
        self.payment_received = True

    async def on_enter_waiting_for_payment(self):
        self.payment_received = False

Testing

async def main():
    sm = OrderControl()

    assert await sm.add_to_order(3) == 3
    assert await sm.add_to_order(7) == 10

    assert await sm.receive_payment(4) == [4]
    assert sm.waiting_for_payment.is_active

    try:
        await sm.process_order()
    except sm.TransitionNotAllowed:
        pass

    assert sm.waiting_for_payment.is_active

    assert await sm.receive_payment(6) == [4, 6]
    await sm.process_order()

    await sm.ship_order()
    assert sm.order_total == 10
    assert sm.payments == [4, 6]
    assert sm.completed.is_active

if __name__ == "__main__":
    asyncio.run(main())
codecov[bot] commented 4 months ago

Codecov Report

All modified and coverable lines are covered by tests :white_check_mark:

Project coverage is 100.00%. Comparing base (79546a5) to head (95eb438). Report is 41 commits behind head on develop.

Additional details and impacted files ```diff @@ Coverage Diff @@ ## develop #435 +/- ## ========================================= Coverage 100.00% 100.00% ========================================= Files 20 20 Lines 1200 1231 +31 Branches 174 179 +5 ========================================= + Hits 1200 1231 +31 ``` | [Flag](https://app.codecov.io/gh/fgmacedo/python-statemachine/pull/435/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo) | Coverage Δ | | |---|---|---| | [unittests](https://app.codecov.io/gh/fgmacedo/python-statemachine/pull/435/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo) | `100.00% <100.00%> (ø)` | | Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fernando+Macedo#carryforward-flags-in-the-pull-request-comment) to find out more.

:umbrella: View full report in Codecov by Sentry.
:loudspeaker: Have feedback on the report? Share it here.

sonarcloud[bot] commented 3 months ago

Quality Gate Passed Quality Gate passed

Issues
0 New issues
0 Accepted issues

Measures
0 Security Hotspots
No data about Coverage
3.1% Duplication on New Code

See analysis details on SonarCloud