Inspired by several issues, including #41 #127 #130 #116 #112 #116, as well as developments in modern Python syntax and tooling (i.e. annotations and mypy) I started noodling around in the typical branch to try to come up with a new interface.
The general gist is:
Create a machine object to decorate your various classes and methods that are parts of the state machine.
Define a typing.Protocol to describe your inputs.
Create a "core" class that holds any data that is shared among states
Define a new class for each state.
Inputs now have only a single output per transition
State classes may request dependency injection by declaring an annotated constructor parameter that has no default with:
the name & matching type of an argument used by the input that causes the transition into them, or
the another state class that must have been transitioned to before them
the Protocol object to get a reference to the "outside" of the state machine (this, along with some subtle semantic changes, addresses #41 pretty comprehensively without requiring a new specific API)
added the concept of an explicit error state which is transitioned to after any unhandled transitions, in order to allow for explicit recoverability after raising an exception by specifying a custom one.
The benefits of this approach:
first and foremost, you can structurally tell whether the state- or input-based data you require has been initialized without looking at the state transition graph; "does this attribute exist on the state class you're trying to implement" is a much simpler question to answer
no need for special "feedback" handling
it's much easier to categorize and hide state-machine implementation details, as only the explicit Protocol is exposed to callers, and state-specific classes may have whatever internal methods they require; no need for lots of _actually methods
in particular: state objects are actually useful now and don't produce a useless / misleading / not-actually-callable def in the middle of your class body; it's a class, that can be used as a regular class if you want (for easier testing, etc)
dependency injection and state core easily resolve both forms of inter-state dependencies'
everything is a decorator now so the API is more self-consistent; no need for lots of awkward class-scoped code execution
magical pseudo-method behavior now lives on a synthetic class that does not share an implementation namespace with you, except for the explicit Protocol methods that you declare.
Open questions:
[ ] I don't love the @handle / @implement decorator naming; wondering if I should just use upon
[ ] should @implement-ed methods be treated more like just a convenient way to declare default fallback methods that exist on every state? hmm probably, right now the precedence behavior is an accident, and it fails silently if you do both, so it should either do this or fail noisily
[ ] what's a better name for TypicalBuilder?
[ ] Should TypicalClass exist at all, or could we just give _realSyntheticType some kind of constructor that does all the work it's currently doing? (If we do this, how do we communicate the custom constructor signature?)
Here's a taste of what it looks like to use:
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol, Annotated as A
from automat import TypicalBuilder, Enter
class CoffeeMachine(Protocol):
def put_in_beans(self, beans: str) -> None:
"put in some beans"
def brew_button(self) -> None:
"press the brew button"
@dataclass
class BrewerStateCore(object):
heat: int = 0
coffee = TypicalBuilder(CoffeeMachine, BrewerStateCore)
@coffee.state()
class NoBeanHaver(object):
@coffee.handle(CoffeeMachine.brew_button)
def no_beans(self) -> None:
print("no beans, not heating")
@coffee.handle(CoffeeMachine.put_in_beans)
def add_beans(self, beans) -> A[None, Enter(BeanHaver)]:
print("put in some beans", repr(beans))
@coffee.state(persist=False)
@dataclass
class BeanHaver:
core: BrewerStateCore
beans: str
@coffee.handle(CoffeeMachine.brew_button)
def heat_the_heating_element(self) -> A[None, Enter(NoBeanHaver)]:
self.core.heat += 1
print("yay brewing:", repr(self.beans))
@coffee.handle(CoffeeMachine.put_in_beans)
def too_many_beans(self, beans: object) -> None:
print("beans overflowing:", repr(beans), self.beans)
CoffeeStateMachine = coffee.buildClass()
print("Created:", CoffeeStateMachine)
x: CoffeeMachine = CoffeeStateMachine(3)
print(isinstance(x, CoffeeStateMachine))
x.brew_button()
x.brew_button()
x.put_in_beans("old beans")
x.put_in_beans("oops too many beans")
x.brew_button()
x.brew_button()
x.put_in_beans("new beans")
x.brew_button()
_magicValueForParameter might be a tag too magical. For example, what if beans has to be lower-cased? NoBeanHaver.add_beans would love to lower-case the beans, but it can't say "send this along to the state I'm entering".
Half-follow-up: I'm not sure the "Core" stuff is all that useful? If there was an explicit way to say "send these parameters to the next state", then relevant methods could send a "common" dataclass voluntarily.
How does the initial state specified? Is it just the first decorated state? (This feels a bit too magical)
Silly question: the heat only goes up, each time the beans are brewed. As the sole parameter of the Core class, I was trying to understand what it stands for, and wasn't sure because of that issue 🙂
Inspired by several issues, including #41 #127 #130 #116 #112 #116, as well as developments in modern Python syntax and tooling (i.e. annotations and mypy) I started noodling around in the
typical
branch to try to come up with a new interface.The general gist is:
typing.Protocol
to describe your inputs.The benefits of this approach:
_actually
methodsdef
in the middle of your class body; it's a class, that can be used as a regular class if you want (for easier testing, etc)Open questions:
@handle
/@implement
decorator naming; wondering if I should just useupon
@implement
-ed methods be treated more like just a convenient way to declare default fallback methods that exist on every state? hmm probably, right now the precedence behavior is an accident, and it fails silently if you do both, so it should either do this or fail noisilyTypicalBuilder
?TypicalClass
exist at all, or could we just give_realSyntheticType
some kind of constructor that does all the work it's currently doing? (If we do this, how do we communicate the custom constructor signature?)Here's a taste of what it looks like to use: