Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support for native coroutines (asyncio) #435

Merged
merged 17 commits into from
Jun 6, 2024
Merged

Conversation

fgmacedo
Copy link
Owner

@fgmacedo fgmacedo commented May 11, 2024

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:

  • Full working example using async events and callbacks
  • Fix failing tests (the remaining are mostly internal API tests).

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

@fgmacedo fgmacedo marked this pull request as draft May 12, 2024 21:21
@fgmacedo fgmacedo changed the title [WIP] feat: Initial attempt to support native coroutines (asyncio) feat: Support for native coroutines (asyncio) May 14, 2024
Copy link

codecov bot commented May 14, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 100.00%. Comparing base (79546a5) to head (95eb438).

Additional details and impacted files
@@            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 Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@fgmacedo fgmacedo marked this pull request as ready for review May 14, 2024 02:11
Copy link
Contributor

@jsbueno jsbueno left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am chatting with the author over twitter - just left some comments here.

statemachine/callbacks.py Show resolved Hide resolved
statemachine/callbacks.py Show resolved Hide resolved
statemachine/signature.py Outdated Show resolved Hide resolved
docs/actions.md Outdated Show resolved Hide resolved
docs/actions.md Outdated Show resolved Hide resolved
docs/conf.py Outdated Show resolved Hide resolved
docs/conf.py Outdated Show resolved Hide resolved
docs/releases/2.3.0.md Outdated Show resolved Hide resolved
statemachine/signature.py Show resolved Hide resolved
tests/examples/air_conditioner_machine.py Outdated Show resolved Hide resolved
Copy link

sonarcloud bot commented Jun 6, 2024

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

@fgmacedo fgmacedo merged commit 2dd1233 into develop Jun 6, 2024
13 checks passed
@fgmacedo fgmacedo deleted the macedo/async-sm branch June 6, 2024 22:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants