Guards and conditions passed as strings really necessary?
- Python State Machine version: 2.1.2
- Python version: 3.11.4
- Operating System: Windows 10
Description
I wonder why we have to give the "unless" parameter and similar as strings. It hinders me from using automatic renaming in my IDE which does not recognize function names as strings.
What I Did
So what I am using currently is following hack:
step |= measure_spot_center.to(move_spot_center, unless=spot_center_ok.__name__)
Using __name__ to pass a string just to be resolved back to the functions feels very unnerving to do.
Is there a reason, why this has to be that way?
Hi @svenwanzenried, how are you doing? Thanks for bringing this up! You're right on your clain that using strings can reduce the power on your IDE with auto renaming;
The good news is that it's already not strictly necessary for it to be a string. Allowing a string as an identifier actually serves as a handy feature to circumvent issues with circular references. This is especially useful since you might want to reference a method or property declared further down in the same file. By using the name, you can do just that without any hassle.
The dispatch mechanism is quite flexible and can accept any callable—this includes functions, methods, properties, and it doesn't matter whether these are part of the state machine, the 'model', or any observer.
I've noticed we haven't made this as clear as it could be in our documentation. I encourage you to give it a go while we work on making the docs more comprehensive. If you have any suggestions or even wish to help clarify this in the documentation, your input would be greatly appreciated.
I've also put together a small example to help shed some light on this, just for you. I hope you find it useful, and I plan to include it in the documentation as an example. Let me know if it helps, and feel free to reach out if you have any more questions or suggestions!
Example Air conditioner machine
"""
Air Conditioner machine
=======================
A :ref:`StateMachine` that demonstrates declaring conditions as methods,
and extra variables on init.
"""
from statemachine import State
from statemachine import StateMachine
def is_cooler_enough(machine):
"""If the external temperature is less than 32, it's cooler enough."""
return machine.external_temperature < 32
class AirConditioner(StateMachine):
off = State(initial=True)
cooling = State()
fan_only = State()
eco_mode = State()
turn_on = off.to(cooling)
set_to_fan = cooling.to(fan_only) | fan_only.to(fan_only)
set_to_cool = fan_only.to(cooling) | cooling.to(cooling)
turn_off = cooling.to(off) | fan_only.to(off) | eco_mode.to(off)
set_to_eco = (
cooling.to(eco_mode, cond=is_cooler_enough) |
fan_only.to(eco_mode, cond=is_cooler_enough)
)
def __init__(self, external_temperature=25):
super().__init__()
self.external_temperature = external_temperature
# Asserting that eco-mode is allowed
ac = AirConditioner(external_temperature=28)
ac.turn_on()
ac.set_to_eco()
assert ac.eco_mode.is_active
# Asserting that eco-mode is not allowed
ac = AirConditioner(external_temperature=35)
ac.turn_on()
try:
ac.set_to_eco()
except StateMachine.TransitionNotAllowed as err:
print(err)
Hi @fgmacedo
Thanks for the detailed answer!
In my case a have a class method I'd like to reference as the unless parameter
class Sequence(StateMachine):
#... (some parts omitted)
# Helper functions
def spot_center_ok(self) -> bool:
return self._spot_center_ok
#...
step = start_position.to(measure_spot_center)
step |= measure_spot_center.to(move_spot_center, unless=spot_center_ok) # DOES NOT WORK (Runtime error because self is not passed to function)
step |= measure_spot_center.to(move_spot_center, unless=self.spot_center_ok) # SYNTAX ERROR (Because self is not known)
step |= measure_spot_center.to(move_spot_center, unless=spot_center_ok.__name__) # WORKAROUND
#...
Is there any possibility to accomplish this any 'nicer' way?
Oh, I've just realized what is happening.
The Python interpreter cleverly differentiates between methods simply declared in the current scope and those that have been "bound" to a class instance.
Consider this example:
def fake_unless_for_study_purposes(method_reference):
# Here, `method_reference` will update the state machine registry with the reference to the method it should call.
# However, this reference points to an unbound method, one that doesn't recognize the instance, and thus lacks the `self` parameter defined.
class SomeClass:
def some_method(self): pass
# At this point, `some_method` is an unbound method, not yet associated with any instance.
fake_unless_for_study_purposes(some_method)
To navigate around this limitation, we can leverage the fact that using self as the first parameter to refer to the instance is merely a convention. By switching it to machine, we allow the StateMachine's dynamic dispatcher (a sort of dependency injector) to inject the machine instance into the reference.
Here's how you can apply this workaround:
class Sequence(StateMachine):
#... (some parts omitted)
# Helper functions
def spot_center_ok(machine) -> bool:
return machine._spot_center_ok
#...
step = start_position.to(measure_spot_center)
step |= measure_spot_center.to(move_spot_center, unless=spot_center_ok)
Please try the workaround and let me see if it works for you.
- [ ] Include
machineon the list of availableparameterson the dynamic dispatch docs: https://python-statemachine.readthedocs.io/en/latest/actions.html#dynamic-dispatch
Your workaround is working.
The only issue I now have is with the Pylance linter. It complains that a class function should have a "self" parameter. I could suppress this warning manually, but then I am as far as I was before (with __name__) in terms of what I consider "hacky".
Maybe it would be a possible solution to add the "self" keyword to the "dependency injection" so that the same "machine" parameter gets injected?
Nice. I'll try to add self and see if it works.