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

automat2: type-hints based API with a separate class per state #145

Closed
4 tasks
glyph opened this issue Jun 13, 2022 · 1 comment · Fixed by #137
Closed
4 tasks

automat2: type-hints based API with a separate class per state #145

glyph opened this issue Jun 13, 2022 · 1 comment · Fixed by #137

Comments

@glyph
Copy link
Owner

glyph commented Jun 13, 2022

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 add "feedback" API - outputs that produce inputs have problems #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()
@glyph glyph changed the title automat2: type-hints based API with separate automat2: type-hints based API with separate instance objects per state Jun 13, 2022
@glyph glyph changed the title automat2: type-hints based API with separate instance objects per state automat2: type-hints based API with a separate class per state Jun 13, 2022
@glyph glyph mentioned this issue Jul 1, 2022
11 tasks
@moshez
Copy link
Collaborator

moshez commented Apr 18, 2023

Some light feedback:

  • _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 🙂

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants