pytransitions / transitions

A lightweight, object-oriented finite state machine implementation in Python with many extensions
MIT License
5.48k stars 525 forks source link

Proper way to handle mypy errors. #674

Closed quantitative-technologies closed 1 week ago

quantitative-technologies commented 4 weeks ago

I posted my question on StackOverflow What is the proper way to handle mypy [attr-defined] errors, due to transitions dynamically adding is_* attributes?.

So far the only answer is that pytransitions "would need to provide a plugin in order for mypy to know about the dynamic attributes that it defines". So perhaps adding a mypy plugin would be an enhancement issue?

Also, I see that #658 may be relevant.

I will give an updated MRE here, and also check if my proposed approach is the proper way to handle the issue.

Note that I do not want to bypass mypy with e.g. # type: ignore[attr-defined].

MRE

from transitions import Machine

class TradingSystem:
    def __init__(self):
        self.machine = Machine(model=self, states=['RUNNING', 'STOPPED'], initial='RUNNING')
        self.machine.add_transition(trigger='stop', source='RUNNING', dest='STOPPED')

    def check_running(self) -> None:
        if self.is_RUNNING():  
            print("System is running")

    def stop_system(self) -> None:
        self.stop()
        print("System stopped")

# Example usage
system = TradingSystem()
system.check_running()
system.stop_system()

Running mypy gives the errors:

transitions_mypy_mre.py:10: error: "TradingSystem" has no attribute "is_RUNNING"  [attr-defined]
transitions_mypy_mre.py:14: error: "TradingSystem" has no attribute "stop"  [attr-defined]

Proposed Method

The best I could think of was to manually define the attributes. I used the attrs library to declare the attributes, and let pytransitions initialize them:

from typing import Callable
from attrs import define, field
from transitions import Machine

@define(slots=False)
class TradingSystem:
    is_RUNNING: Callable[[], bool] = field(init=False)
    stop: Callable[[], None] = field(init=False)

    def __attrs_post_init__(self):
        self.machine = Machine(model=self, states=['RUNNING', 'STOPPED'], initial='RUNNING')
        self.machine.add_transition(trigger='stop', source='RUNNING', dest='STOPPED')

    def check_running(self) -> None:
        if self.is_RUNNING():  
            print("System is running")

    def stop_system(self) -> None:
        self.stop()
        print("System stopped")

# Example usage
system = TradingSystem()
system.check_running()
system.stop_system()

Is this a good/proper approach to integrate pytransitions with mypy?

aleneum commented 1 week ago

Hello @quantitative-technologies,

I posted an answer to your SO question. I think your solution is pretty neat! I added some remarks concerning in my SO answer. I am currently working on tools to ease static typed development with transitions. I will close this issue but let's continue discussing this issue in #658.

aleneum commented 1 week ago

FYI,

an adaption of your code without attrs that makes use of model_override=True:

from typing import Callable
from transitions import Machine

class TradingSystem:
    is_RUNNING: Callable[[], bool] = ...
    stop: Callable[[], None] = ...

    def __init__(self):
        self.machine = Machine(model=self, states=['RUNNING', 'STOPPED'], initial='RUNNING', model_override=True)
        self.machine.add_transition(trigger='stop', source='RUNNING', dest='STOPPED')

    def check_running(self) -> None:
        if self.is_RUNNING():
            print("System is running")

    def stop_system(self) -> None:
        self.stop()
        print("System stopped")

# Example usage
system = TradingSystem()
assert system.is_RUNNING()
print(system.__dict__.keys())
# >> dict_keys(['is_RUNNING', 'state', 'machine', 'stop'])

This only decorates the methods you have stated and brings the runtime and static version of TradingSystem closer together.