diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..7bcde5d --- /dev/null +++ b/.coveragerc @@ -0,0 +1,8 @@ +[report] +precision = 2 +ignore_errors = True +exclude_lines = + pragma: no cover + if TYPE_CHECKING + \s*\.\.\.$ + raise NotImplementedError diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed3b92c..f9d9c73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.10", "3.11"] + python: ["3.8", "3.10", "3.11"] TOX_ENV: ["extras", "noextras", "mypy"] include: - python: "3.11" @@ -28,17 +28,24 @@ jobs: uses: actions/setup-python@v3 with: python-version: ${{ matrix.python }} + - name: Install graphviz + run: | + sudo apt-get install -y graphviz + dot -V + if: ${{ matrix.TOX_ENV == 'extras' }} - name: Tox Run run: | pip install tox; - TOX_ENV="py$(echo ${{ matrix.python }} | sed -e s/\.//g)-${{ matrix.TOX_ENV }}"; + TOX_ENV="py$(echo ${{ matrix.python }} | sed -e 's/\.//g')-${{ matrix.TOX_ENV }}"; echo "Starting: ${TOX_ENV} ${PUSH_DOCS}" if [[ -n "${TOX_ENV}" ]]; then tox -e "$TOX_ENV"; - if [[ "${{ matrix.TOX_ENV }}" != "mypy" ]]; then + if [[ "${{ matrix.TOX_ENV }}" != "mypy" && "${{ matrix.TOX_ENV }}" != "lint" ]]; then tox -e coverage-report; fi; fi; - name: Upload coverage report if: ${{ matrix.TOX_ENV != 'mypy' }} - uses: codecov/codecov-action@v3.1.0 + uses: codecov/codecov-action@v4.5.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 73257d9..77868b6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ .tox/ -.coverage* +.coverage.* .eggs/ *.egg-info/ *.py[co] build/ dist/ docs/_build/ +coverage.xml + diff --git a/.pydoctor.cfg b/.pydoctor.cfg new file mode 100644 index 0000000..f52a1d6 --- /dev/null +++ b/.pydoctor.cfg @@ -0,0 +1,23 @@ +[tool:pydoctor] +quiet=1 +warnings-as-errors=true +project-name=Automat +project-url=../index.html +docformat=epytext +theme=readthedocs +intersphinx= + https://graphviz.readthedocs.io/en/stable/objects.inv + https://docs.python.org/3/objects.inv + https://cryptography.io/en/latest/objects.inv + https://pyopenssl.readthedocs.io/en/stable/objects.inv + https://hyperlink.readthedocs.io/en/stable/objects.inv + https://twisted.org/constantly/docs/objects.inv + https://twisted.org/incremental/docs/objects.inv + https://python-hyper.org/projects/hyper-h2/en/stable/objects.inv + https://priority.readthedocs.io/en/stable/objects.inv + https://zopeinterface.readthedocs.io/en/latest/objects.inv + https://automat.readthedocs.io/en/latest/objects.inv + https://docs.twisted.org/en/stable/objects.inv +project-base-dir=automat +html-output=docs/_build/api +html-viewsource-base=https://github.com/glyph/automat/tree/trunk diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..31dbf0d --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,35 @@ +# Read the Docs configuration file for Sphinx projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.12" + # You can also specify other tool versions: + # nodejs: "20" + # rust: "1.70" + # golang: "1.20" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/conf.py + # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs + # builder: "dirhtml" + # Fail on all warnings to avoid broken references + # fail_on_warning: true + +# Optionally build your docs in additional formats such as PDF and ePub +# formats: +# - pdf +# - epub + +# Optional but recommended, declare the Python requirements required +# to build your documentation +# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +python: + install: + - requirements: docs/requirements.txt diff --git a/README.md b/README.md index a385ce9..a95e125 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,6 @@ automata (particularly deterministic finite-state transducers). Read more here, or on [Read the Docs](https://automat.readthedocs.io/), or watch the following videos for an overview and presentation -Overview and presentation by **Glyph Lefkowitz** at the first talk of the first Pyninsula meetup, on February 21st, 2017: -[![Glyph Lefkowitz - Automat - Pyninsula #0](https://img.youtube.com/vi/0wOZBpD1VVk/0.jpg)](https://www.youtube.com/watch?v=0wOZBpD1VVk) - -Presentation by **Clinton Roy** at PyCon Australia, on August 6th 2017: -[![Clinton Roy - State Machines - Pycon Australia 2017](https://img.youtube.com/vi/TedUKXhu9kE/0.jpg)](https://www.youtube.com/watch?v=TedUKXhu9kE) - ### Why use state machines? ### Sometimes you have to create an object whose behavior varies with its state, @@ -33,14 +27,14 @@ one of these configurations, and the "open lid" button should only work if the coffee is not, in fact, brewing. With diligence and attention to detail, you can implement this correctly using -a collection of attributes on an object; `has_water`, `has_beans`, -`is_lid_open` and so on. However, you have to keep all these attributes -consistent. As the coffee maker becomes more complex - perhaps you add an -additional chamber for flavorings so you can make hazelnut coffee, for -example - you have to keep adding more and more checks and more and more -reasoning about which combinations of states are allowed. - -Rather than adding tedious 'if' checks to every single method to make sure that +a collection of attributes on an object; `hasWater`, `hasBeans`, `isLidOpen` +and so on. However, you have to keep all these attributes consistent. As the +coffee maker becomes more complex - perhaps you add an additional chamber for +flavorings so you can make hazelnut coffee, for example - you have to keep +adding more and more checks and more and more reasoning about which +combinations of states are allowed. + +Rather than adding tedious `if` checks to every single method to make sure that each of these flags are exactly what you expect, you can use a state machine to ensure that if your code runs at all, it will be run with all the required values initialized, because they have to be called in the order you declare @@ -71,360 +65,87 @@ as follows in naive Python: ```python class CoffeeMachine(object): - def brew_button(self): - if self.has_water and self.has_beans and not self.is_lid_open: - self.heat_the_heating_element() + def brewButton(self) -> None: + if self.hasWater and self.hasBeans and not self.isLidOpen: + self.heatTheHeatingElement() # ... ``` -With Automat, you'd create a class with a `MethodicalMachine` attribute: +With Automat, you'd begin with a `typing.Protocol` that describes all of your +inputs: ```python -from automat import MethodicalMachine - -class CoffeeBrewer(object): - _machine = MethodicalMachine() -``` - -and then you would break the above logic into two pieces - the `brew_button` -*input*, declared like so: +from typing import Protocol -```python - @_machine.input() - def brew_button(self): +class CoffeeBrewer(Protocol): + def brewButton(self) -> None: "The user pressed the 'brew' button." -``` - -It wouldn't do any good to declare a method *body* on this, however, because -input methods don't actually execute their bodies when called; doing actual -work is the *output*'s job: - -```python - @_machine.output() - def _heat_the_heating_element(self): - "Heat up the heating element, which should cause coffee to happen." - self._heating_element.turn_on() -``` - -As well as a couple of *states* - and for simplicity's sake let's say that the -only two states are `have_beans` and `dont_have_beans`: - -```python - @_machine.state() - def have_beans(self): - "In this state, you have some beans." - @_machine.state(initial=True) - def dont_have_beans(self): - "In this state, you don't have any beans." -``` - -`dont_have_beans` is the `initial` state because `CoffeeBrewer` starts without beans -in it. - -(And another input to put some beans in:) - -```python - @_machine.input() - def put_in_beans(self): - "The user put in some beans." -``` - -Finally, you hook everything together with the `upon` method of the functions -decorated with `_machine.state`: - -```python - - # When we don't have beans, upon putting in beans, we will then have beans - # (and produce no output) - dont_have_beans.upon(put_in_beans, enter=have_beans, outputs=[]) - - # When we have beans, upon pressing the brew button, we will then not have - # beans any more (as they have been entered into the brewing chamber) and - # our output will be heating the heating element. - have_beans.upon(brew_button, enter=dont_have_beans, - outputs=[_heat_the_heating_element]) -``` - -To *users* of this coffee machine class though, it still looks like a POPO -(Plain Old Python Object): - -```python ->>> coffee_machine = CoffeeMachine() ->>> coffee_machine.put_in_beans() ->>> coffee_machine.brew_button() -``` - -All of the *inputs* are provided by calling them like methods, all of the -*outputs* are automatically invoked when they are produced according to the -outputs specified to `upon` and all of the states are simply opaque tokens - -although the fact that they're defined as methods like inputs and outputs -allows you to put docstrings on them easily to document them. - -## How do I get the current state of a state machine? - -Don't do that. - -One major reason for having a state machine is that you want the callers of the -state machine to just provide the appropriate input to the machine at the -appropriate time, and *not have to check themselves* what state the machine is -in. So if you are tempted to write some code like this: - -```python -if connection_state_machine.state == "CONNECTED": - connection_state_machine.send_message() -else: - print("not connected") -``` - -Instead, just make your calling code do this: - -```python -connection_state_machine.send_message() -``` - -and then change your state machine to look like this: - -```python - @_machine.state() - def connected(self): - "connected" - @_machine.state() - def not_connected(self): - "not connected" - @_machine.input() - def send_message(self): - "send a message" - @_machine.output() - def _actually_send_message(self): - self._transport.send(b"message") - @_machine.output() - def _report_sending_failure(self): - print("not connected") - connected.upon(send_message, enter=connected, [_actually_send_message]) - not_connected.upon(send_message, enter=not_connected, [_report_sending_failure]) -``` - -so that the responsibility for knowing which state the state machine is in -remains within the state machine itself. - -## Input for Inputs and Output for Outputs - -Quite often you want to be able to pass parameters to your methods, as well as -inspecting their results. For example, when you brew the coffee, you might -expect a cup of coffee to result, and you would like to see what kind of coffee -it is. And if you were to put delicious hand-roasted small-batch artisanal -beans into the machine, you would expect a *better* cup of coffee than if you -were to use mass-produced beans. You would do this in plain old Python by -adding a parameter, so that's how you do it in Automat as well. - -```python - @_machine.input() - def put_in_beans(self, beans): + def putInBeans(self) -> None: "The user put in some beans." ``` -However, one important difference here is that *we can't add any -implementation code to the input method*. Inputs are purely a declaration of -the interface; the behavior must all come from outputs. Therefore, the change -in the state of the coffee machine must be represented as an output. We can -add an output method like this: - -```python - @_machine.output() - def _save_beans(self, beans): - "The beans are now in the machine; save them." - self._beans = beans -``` - -and then connect it to the `put_in_beans` by changing the transition from -`dont_have_beans` to `have_beans` like so: - -```python - dont_have_beans.upon(put_in_beans, enter=have_beans, - outputs=[_save_beans]) -``` - -Now, when you call: - -```python -coffee_machine.put_in_beans("real good beans") -``` - -the machine will remember the beans for later. - -So how do we get the beans back out again? One of our outputs needs to have a -return value. It would make sense if our `brew_button` method returned the cup -of coffee that it made, so we should add an output. So, in addition to heating -the heating element, let's add a return value that describes the coffee. First -a new output: - -```python - @_machine.output() - def _describe_coffee(self): - return "A cup of coffee made with {}.".format(self._beans) -``` - -Note that we don't need to check first whether `self._beans` exists or not, -because we can only reach this output method if the state machine says we've -gone through a set of states that sets this attribute. - -Now, we need to hook up `_describe_coffee` to the process of brewing, so change -the brewing transition to: - -```python - have_beans.upon(brew_button, enter=dont_have_beans, - outputs=[_heat_the_heating_element, - _describe_coffee]) -``` - -Now, we can call it: - -```python ->>> coffee_machine.brew_button() -[None, 'A cup of coffee made with real good beans.'] -``` - -Except... wait a second, what's that `None` doing there? - -Since every input can produce multiple outputs, in automat, the default return -value from every input invocation is a `list`. In this case, we have both -`_heat_the_heating_element` and `_describe_coffee` outputs, so we're seeing -both of their return values. However, this can be customized, with the -`collector` argument to `upon`; the `collector` is a callable which takes an -iterable of all the outputs' return values and "collects" a single return value -to return to the caller of the state machine. - -In this case, we only care about the last output, so we can adjust the call to -`upon` like this: +We'll then need a concrete class to contain the shared core of state shared +among the different states: ```python - have_beans.upon(brew_button, enter=dont_have_beans, - outputs=[_heat_the_heating_element, - _describe_coffee], - collector=lambda iterable: list(iterable)[-1] - ) -``` +from dataclasses import dataclass -And now, we'll get just the return value we want: - -```python ->>> coffee_machine.brew_button() -'A cup of coffee made with real good beans.' +@dataclass +class BrewerCore: + heatingElement: HeatingElement ``` -## If I can't get the state of the state machine, how can I save it to (a database, an API response, a file on disk...) - -There are APIs for serializing the state machine. - -First, you have to decide on a persistent representation of each state, via the -`serialized=` argument to the `MethodicalMachine.state()` decorator. - -Let's take this very simple "light switch" state machine, which can be on or -off, and flipped to reverse its state: +Next, we need to describe our state machine, including all of our states. For +simplicity's sake let's say that the only two states are `noBeans` and +`haveBeans`: ```python -class LightSwitch(object): - _machine = MethodicalMachine() - @_machine.state(serialized="on") - def on_state(self): - "the switch is on" - @_machine.state(serialized="off", initial=True) - def off_state(self): - "the switch is off" - @_machine.input() - def flip(self): - "flip the switch" - on_state.upon(flip, enter=off_state, outputs=[]) - off_state.upon(flip, enter=on_state, outputs=[]) -``` +from automat import TypeMachineBuilder -In this case, we've chosen a serialized representation for each state via the -`serialized` argument. The on state is represented by the string `"on"`, and -the off state is represented by the string `"off"`. - -Now, let's just add an input that lets us tell if the switch is on or not. - -```python - @_machine.input() - def query_power(self): - "return True if powered, False otherwise" - @_machine.output() - def _is_powered(self): - return True - @_machine.output() - def _not_powered(self): - return False - on_state.upon(query_power, enter=on_state, outputs=[_is_powered], - collector=next) - off_state.upon(query_power, enter=off_state, outputs=[_not_powered], - collector=next) +builder = TypeMachineBuilder(CoffeeBrewer, BrewerCore) +noBeans = builder.state("noBeans") +haveBeans = builder.state("haveBeans") ``` -To save the state, we have the `MethodicalMachine.serializer()` method. A -method decorated with `@serializer()` gets an extra argument injected at the -beginning of its argument list: the serialized identifier for the state. In -this case, either `"on"` or `"off"`. Since state machine output methods can -also affect other state on the object, a serializer method is expected to -return *all* relevant state for serialization. - -For our simple light switch, such a method might look like this: +Next we can describe a simple transition; when we put in beans, we move to the +`haveBeans` state, with no other behavior. ```python - @_machine.serializer() - def save(self, state): - return {"is-it-on": state} +# When we don't have beans, upon putting in beans, we will then have beans +noBeans.upon(CoffeeBrewer.putInBeans).to(haveBeans).returns(None) ``` -Serializers can be public methods, and they can return whatever you like. If -necessary, you can have different serializers - just multiple methods decorated -with `@_machine.serializer()` - for different formats; return one data-structure -for JSON, one for XML, one for a database row, and so on. - -When it comes time to unserialize, though, you generally want a private method, -because an unserializer has to take a not-fully-initialized instance and -populate it with state. It is expected to *return* the serialized machine -state token that was passed to the serializer, but it can take whatever -arguments you like. Of course, in order to return that, it probably has to -take it somewhere in its arguments, so it will generally take whatever a paired -serializer has returned as an argument. - -So our unserializer would look like this: +And then another transition that we describe with a decorator, one that *does* +have some behavior, that needs to heat up the heating element to brew the +coffee: ```python - @_machine.unserializer() - def _restore(self, blob): - return blob["is-it-on"] +@haveBeans.upon(CoffeeBrewer.brewButton).to(noBeans) +def heatUp(inputs: CoffeeBrewer, core: BrewerCore) -> None: + """ + When we have beans, upon pressing the brew button, we will then not have + beans any more (as they have been entered into the brewing chamber) and + our output will be heating the heating element. + """ + print("Brewing the coffee...") + core.heatingElement.turnOn() ``` -Generally you will want a classmethod deserialization constructor which you -write yourself to call this, so that you know how to create an instance of your -own object, like so: +Then we finalize the state machine by building it, which gives us a callable +that takes a `BrewerCore` and returns a synthetic `CoffeeBrewer` ```python - @classmethod - def from_blob(cls, blob): - self = cls() - self._restore(blob) - return self +newCoffeeMachine = builder.build() ``` -Saving and loading our `LightSwitch` along with its state-machine state can now -be accomplished as follows: - ```python ->>> switch1 = LightSwitch() ->>> switch1.query_power() -False ->>> switch1.flip() -[] ->>> switch1.query_power() -True ->>> blob = switch1.save() ->>> switch2 = LightSwitch.from_blob(blob) ->>> switch2.query_power() -True +>>> coffee = newCoffeeMachine(BrewerCore(HeatingElement())) +>>> machine.putInBeans() +>>> machine.brewButton() +Brewing the coffee... ``` -More comprehensive (tested, working) examples are present in `docs/examples`. - -Go forth and machine all the state! +All of the *inputs* are provided by calling them like methods, all of the +*output behaviors* are automatically invoked when they are produced according +to the outputs specified to `upon` and all of the states are simply opaque +tokens. diff --git a/automat/__init__.py b/automat/__init__.py index 3e67670..c4b34e5 100644 --- a/automat/__init__.py +++ b/automat/__init__.py @@ -1,8 +1,16 @@ # -*- test-case-name: automat -*- -from ._methodical import MethodicalMachine +""" +State-machines. +""" +from ._typed import TypeMachineBuilder, pep614, AlreadyBuiltError, TypeMachine from ._core import NoTransition +from ._methodical import MethodicalMachine __all__ = [ - "MethodicalMachine", + "TypeMachineBuilder", + "TypeMachine", "NoTransition", + "AlreadyBuiltError", + "pep614", + "MethodicalMachine", ] diff --git a/automat/_core.py b/automat/_core.py index ef80269..fc637b3 100644 --- a/automat/_core.py +++ b/automat/_core.py @@ -5,23 +5,41 @@ Perhaps something that could be replaced with or integrated into machinist. """ +from __future__ import annotations +import sys from itertools import chain +from typing import Callable, Generic, Optional, Sequence, TypeVar, Hashable + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: + from typing_extensions import TypeAlias _NO_STATE = "" +State = TypeVar("State", bound=Hashable) +Input = TypeVar("Input", bound=Hashable) +Output = TypeVar("Output", bound=Hashable) -class NoTransition(Exception): +class NoTransition(Exception, Generic[State, Input]): """ A finite state machine in C{state} has no transition for C{symbol}. - @param state: the finite state machine's state at the time of the - illegal transition. + @ivar state: See C{state} init parameter. - @param symbol: the input symbol for which no transition exists. + @ivar symbol: See C{symbol} init parameter. """ - def __init__(self, state, symbol): + def __init__(self, state: State, symbol: Input): + """ + Construct a L{NoTransition}. + + @param state: the finite state machine's state at the time of the + illegal transition. + + @param symbol: the input symbol for which no transition exists. + """ self.state = state self.symbol = symbol super(Exception, self).__init__( @@ -29,29 +47,33 @@ def __init__(self, state, symbol): ) -class Automaton(object): +class Automaton(Generic[State, Input, Output]): """ A declaration of a finite state machine. Note that this is not the machine itself; it is immutable. """ - def __init__(self): + def __init__(self, initial: State | None = None) -> None: """ Initialize the set of transitions and the initial state. """ - self._initialState = _NO_STATE - self._transitions = set() + if initial is None: + initial = _NO_STATE # type:ignore[assignment] + assert initial is not None + self._initialState: State = initial + self._transitions: set[tuple[State, Input, State, Sequence[Output]]] = set() + self._unhandledTransition: Optional[tuple[State, Sequence[Output]]] = None @property - def initialState(self): + def initialState(self) -> State: """ Return this automaton's initial state. """ return self._initialState @initialState.setter - def initialState(self, state): + def initialState(self, state: State) -> None: """ Set this automaton's initial state. Raises a ValueError if this automaton already has an initial state. @@ -64,7 +86,13 @@ def initialState(self, state): self._initialState = state - def addTransition(self, inState, inputSymbol, outState, outputSymbols): + def addTransition( + self, + inState: State, + inputSymbol: Input, + outState: State, + outputSymbols: tuple[Output, ...], + ): """ Add the given transition to the outputSymbol. Raise ValueError if there is already a transition with the same inState and inputSymbol. @@ -72,22 +100,31 @@ def addTransition(self, inState, inputSymbol, outState, outputSymbols): # keeping self._transitions in a flat list makes addTransition # O(n^2), but state machines don't tend to have hundreds of # transitions. - for (anInState, anInputSymbol, anOutState, _) in self._transitions: + for anInState, anInputSymbol, anOutState, _ in self._transitions: if anInState == inState and anInputSymbol == inputSymbol: raise ValueError( - "already have transition from {} via {}".format( - inState, inputSymbol + "already have transition from {} to {} via {}".format( + inState, anOutState, inputSymbol ) ) self._transitions.add((inState, inputSymbol, outState, tuple(outputSymbols))) - def allTransitions(self): + def unhandledTransition( + self, outState: State, outputSymbols: Sequence[Output] + ) -> None: + """ + All unhandled transitions will be handled by transitioning to the given + error state and error-handling output symbols. + """ + self._unhandledTransition = (outState, tuple(outputSymbols)) + + def allTransitions(self) -> frozenset[tuple[State, Input, State, Sequence[Output]]]: """ All transitions. """ return frozenset(self._transitions) - def inputAlphabet(self): + def inputAlphabet(self) -> set[Input]: """ The full set of symbols acceptable to this automaton. """ @@ -96,7 +133,7 @@ def inputAlphabet(self): for (inState, inputSymbol, outState, outputSymbol) in self._transitions } - def outputAlphabet(self): + def outputAlphabet(self) -> set[Output]: """ The full set of symbols which can be produced by this automaton. """ @@ -107,7 +144,7 @@ def outputAlphabet(self): ) ) - def states(self): + def states(self) -> frozenset[State]: """ All valid states; "Q" in the mathematical description of a state machine. @@ -119,30 +156,40 @@ def states(self): ) ) - def outputForInput(self, inState, inputSymbol): + def outputForInput( + self, inState: State, inputSymbol: Input + ) -> tuple[State, Sequence[Output]]: """ A 2-tuple of (outState, outputSymbols) for inputSymbol. """ - for (anInState, anInputSymbol, outState, outputSymbols) in self._transitions: + for anInState, anInputSymbol, outState, outputSymbols in self._transitions: if (inState, inputSymbol) == (anInState, anInputSymbol): return (outState, list(outputSymbols)) - raise NoTransition(state=inState, symbol=inputSymbol) + if self._unhandledTransition is None: + raise NoTransition(state=inState, symbol=inputSymbol) + return self._unhandledTransition + +OutputTracer = Callable[[Output], None] +Tracer: TypeAlias = "Callable[[State, Input, State], OutputTracer[Output] | None]" -class Transitioner(object): + +class Transitioner(Generic[State, Input, Output]): """ The combination of a current state and an L{Automaton}. """ - def __init__(self, automaton, initialState): - self._automaton = automaton - self._state = initialState - self._tracer = None + def __init__(self, automaton: Automaton[State, Input, Output], initialState: State): + self._automaton: Automaton[State, Input, Output] = automaton + self._state: State = initialState + self._tracer: Tracer[State, Input, Output] | None = None - def setTrace(self, tracer): + def setTrace(self, tracer: Tracer[State, Input, Output] | None) -> None: self._tracer = tracer - def transition(self, inputSymbol): + def transition( + self, inputSymbol: Input + ) -> tuple[Sequence[Output], OutputTracer[Output] | None]: """ Transition between states, returning any outputs. """ @@ -151,8 +198,6 @@ def transition(self, inputSymbol): ) outTracer = None if self._tracer: - outTracer = self._tracer( - self._state._name(), inputSymbol._name(), outState._name() - ) + outTracer = self._tracer(self._state, inputSymbol, outState) self._state = outState return (outputSymbols, outTracer) diff --git a/automat/_discover.py b/automat/_discover.py index ee4749b..ae92f82 100644 --- a/automat/_discover.py +++ b/automat/_discover.py @@ -1,10 +1,17 @@ +from __future__ import annotations + import collections import inspect +from typing import Any, Iterator + +from twisted.python.modules import PythonAttribute, PythonModule, getModule + from automat import MethodicalMachine -from twisted.python.modules import PythonModule, getModule +from ._typed import TypeMachine, InputProtocol, Core -def isOriginalLocation(attr): + +def isOriginalLocation(attr: PythonAttribute | PythonModule) -> bool: """ Attempt to discover if this appearance of a PythonAttribute representing a class refers to the module where that class was @@ -21,7 +28,9 @@ def isOriginalLocation(attr): return currentModule.name == sourceModule.__name__ -def findMachinesViaWrapper(within): +def findMachinesViaWrapper( + within: PythonModule | PythonAttribute, +) -> Iterator[tuple[str, MethodicalMachine | TypeMachine[InputProtocol, Core]]]: """ Recursively yield L{MethodicalMachine}s and their FQPNs within a L{PythonModule} or a L{twisted.python.modules.PythonAttribute} @@ -40,13 +49,20 @@ def findMachinesViaWrapper(within): @return: a generator which yields FQPN, L{MethodicalMachine} pairs. """ queue = collections.deque([within]) - visited = set() + visited: set[ + PythonModule + | PythonAttribute + | MethodicalMachine + | TypeMachine[InputProtocol, Core] + | type[Any] + ] = set() while queue: attr = queue.pop() value = attr.load() - - if isinstance(value, MethodicalMachine) and value not in visited: + if ( + isinstance(value, MethodicalMachine) or isinstance(value, TypeMachine) + ) and value not in visited: visited.add(value) yield attr.name, value elif ( @@ -78,7 +94,7 @@ class NoObject(InvalidFQPN): """ -def wrapFQPN(fqpn): +def wrapFQPN(fqpn: str) -> PythonModule | PythonAttribute: """ Given an FQPN, retrieve the object via the global Python module namespace and wrap it with a L{PythonModule} or a @@ -131,19 +147,22 @@ def wrapFQPN(fqpn): return attribute -def findMachines(fqpn): +def findMachines( + fqpn: str, +) -> Iterator[tuple[str, MethodicalMachine | TypeMachine[InputProtocol, Core]]]: """ - Recursively yield L{MethodicalMachine}s and their FQPNs in and - under the a Python object specified by an FQPN. + Recursively yield L{MethodicalMachine}s and their FQPNs in and under the a + Python object specified by an FQPN. - The discovery heuristic considers L{MethodicalMachine} instances - that are module-level attributes or class-level attributes - accessible from module scope. Machines inside nested classes will - be discovered, but those returned from functions or methods will not be. + The discovery heuristic considers L{MethodicalMachine} instances that are + module-level attributes or class-level attributes accessible from module + scope. Machines inside nested classes will be discovered, but those + returned from functions or methods will not be. - @type within: an FQPN - @param within: Where to start the search. + @param fqpn: a fully-qualified Python identifier (i.e. the dotted + identifier of an object defined at module or class scope, including the + package and modele names); where to start the search. - @return: a generator which yields FQPN, L{MethodicalMachine} pairs. + @return: a generator which yields (C{FQPN}, L{MethodicalMachine}) pairs. """ return findMachinesViaWrapper(wrapFQPN(fqpn)) diff --git a/automat/_methodical.py b/automat/_methodical.py index e0f8976..6c46c11 100644 --- a/automat/_methodical.py +++ b/automat/_methodical.py @@ -1,17 +1,22 @@ # -*- test-case-name: automat._test.test_methodical -*- +from __future__ import annotations import collections +import sys +from dataclasses import dataclass, field from functools import wraps -from itertools import count - from inspect import getfullargspec as getArgsSpec +from itertools import count +from typing import Any, Callable, Hashable, Iterable, TypeVar -import attr +if sys.version_info < (3, 10): + from typing_extensions import TypeAlias +else: + from typing import TypeAlias -from ._core import Transitioner, Automaton +from ._core import Automaton, OutputTracer, Tracer, Transitioner from ._introspection import preserveName - ArgSpec = collections.namedtuple( "ArgSpec", [ @@ -86,34 +91,43 @@ def g(self, **kw): return g -@attr.s(frozen=True) +@dataclass(frozen=True) class MethodicalState(object): """ A state for a L{MethodicalMachine}. """ - machine = attr.ib(repr=False) - method = attr.ib() - serialized = attr.ib(repr=False) - - def upon(self, input, enter=None, outputs=None, collector=list): + machine: MethodicalMachine = field(repr=False) + method: Callable[..., Any] = field() + serialized: bool = field(repr=False) + + def upon( + self, + input: MethodicalInput, + enter: MethodicalState | None = None, + outputs: Iterable[MethodicalOutput] | None = None, + collector: Callable[[Iterable[T]], object] = list, + ) -> None: """ - Declare a state transition within the :class:`automat.MethodicalMachine` - associated with this :class:`automat.MethodicalState`: - upon the receipt of the `input`, enter the `state`, - emitting each output in `outputs`. - - :param MethodicalInput input: The input triggering a state transition. - :param MethodicalState enter: The resulting state. - :param Iterable[MethodicalOutput] outputs: The outputs to be triggered - as a result of the declared state transition. - :param Callable collector: The function to be used when collecting - output return values. - - :raises TypeError: if any of the `outputs` signatures do not match - the `inputs` signature. - :raises ValueError: if the state transition from `self` via `input` - has already been defined. + Declare a state transition within the L{MethodicalMachine} associated + with this L{MethodicalState}: upon the receipt of the `input`, enter + the `state`, emitting each output in `outputs`. + + @param input: The input triggering a state transition. + + @param enter: The resulting state. + + @param outputs: The outputs to be triggered as a result of the declared + state transition. + + @param collector: The function to be used when collecting output return + values. + + @raises TypeError: if any of the `outputs` signatures do not match the + `inputs` signature. + + @raises ValueError: if the state transition from `self` via `input` has + already been defined. """ if enter is None: enter = self @@ -135,11 +149,15 @@ def upon(self, input, enter=None, outputs=None, collector=list): ) self.machine._oneTransition(self, input, enter, outputs, collector) - def _name(self): + def _name(self) -> str: return self.method.__name__ -def _transitionerFromInstance(oself, symbol, automaton): +def _transitionerFromInstance( + oself: object, + symbol: str, + automaton: Automaton[MethodicalState, MethodicalInput, MethodicalOutput], +) -> Transitioner[MethodicalState, MethodicalInput, MethodicalOutput]: """ Get a L{Transitioner} """ @@ -161,7 +179,7 @@ def _docstring(): """docstring""" -def assertNoCode(inst, attribute, f): +def assertNoCode(f: Callable[..., Any]) -> None: # The function body must be empty, i.e. "pass" or "return None", which # both yield the same bytecode: LOAD_CONST (None), RETURN_VALUE. We also # accept functions with only a docstring, which yields slightly different @@ -218,23 +236,32 @@ def _filterArgs(args, kwargs, inputSpec, outputSpec): return return_args, return_kwargs -@attr.s(eq=False, hash=False) +T = TypeVar("T") +R = TypeVar("R") + + +@dataclass(eq=False) class MethodicalInput(object): """ An input for a L{MethodicalMachine}. """ - automaton = attr.ib(repr=False) - method = attr.ib(validator=assertNoCode) - symbol = attr.ib(repr=False) - collectors = attr.ib(default=attr.Factory(dict), repr=False) - argSpec = attr.ib(init=False, repr=False) + automaton: Automaton[MethodicalState, MethodicalInput, MethodicalOutput] = field( + repr=False + ) + method: Callable[..., Any] = field() + symbol: str = field(repr=False) + collectors: dict[MethodicalState, Callable[[Iterable[T]], R]] = field( + default_factory=dict, repr=False + ) - @argSpec.default - def _buildArgSpec(self): - return _getArgSpec(self.method) + argSpec: ArgSpec = field(init=False, repr=False) - def __get__(self, oself, type=None): + def __post_init__(self) -> None: + self.argSpec = _getArgSpec(self.method) + assertNoCode(self.method) + + def __get__(self, oself: object, type: None = None) -> object: """ Return a function that takes no arguments and returns values returned by output functions produced by the given L{MethodicalInput} in @@ -244,15 +271,15 @@ def __get__(self, oself, type=None): @preserveName(self.method) @wraps(self.method) - def doInput(*args, **kwargs): + def doInput(*args: object, **kwargs: object) -> object: self.method(oself, *args, **kwargs) previousState = transitioner._state (outputs, outTracer) = transitioner.transition(self) collector = self.collectors[previousState] values = [] for output in outputs: - if outTracer: - outTracer(output._name()) + if outTracer is not None: + outTracer(output) a, k = _filterArgs(args, kwargs, self.argSpec, output.argSpec) value = output(oself, *a, **k) values.append(value) @@ -260,23 +287,22 @@ def doInput(*args, **kwargs): return doInput - def _name(self): + def _name(self) -> str: return self.method.__name__ -@attr.s(frozen=True) +@dataclass(frozen=True) class MethodicalOutput(object): """ An output for a L{MethodicalMachine}. """ - machine = attr.ib(repr=False) - method = attr.ib() - argSpec = attr.ib(init=False, repr=False) + machine: MethodicalMachine = field(repr=False) + method: Callable[..., Any] + argSpec: ArgSpec = field(init=False, repr=False, compare=False) - @argSpec.default - def _buildArgSpec(self): - return _getArgSpec(self.method) + def __post_init__(self) -> None: + self.__dict__["argSpec"] = _getArgSpec(self.method) def __get__(self, oself, type=None): """ @@ -295,20 +321,47 @@ def __call__(self, oself, *args, **kwargs): """ return self.method(oself, *args, **kwargs) - def _name(self): + def _name(self) -> str: return self.method.__name__ -@attr.s(eq=False, hash=False) +StringOutputTracer = Callable[[str], None] +StringTracer: TypeAlias = "Callable[[str, str, str], StringOutputTracer | None]" + + +def wrapTracer( + wrapped: StringTracer | None, +) -> Tracer[MethodicalState, MethodicalInput, MethodicalOutput] | None: + if wrapped is None: + return None + + def tracer( + state: MethodicalState, + input: MethodicalInput, + output: MethodicalState, + ) -> OutputTracer[MethodicalOutput] | None: + result = wrapped(state._name(), input._name(), output._name()) + if result is not None: + return lambda out: result(out._name()) + return None + + return tracer + + +@dataclass(eq=False) class MethodicalTracer(object): - automaton = attr.ib(repr=False) - symbol = attr.ib(repr=False) + automaton: Automaton[MethodicalState, MethodicalInput, MethodicalOutput] = field( + repr=False + ) + symbol: str = field(repr=False) - def __get__(self, oself, type=None): + def __get__( + self, oself: object, type: object = None + ) -> Callable[[StringTracer], None]: transitioner = _transitionerFromInstance(oself, self.symbol, self.automaton) - def setTrace(tracer): - transitioner.setTrace(tracer) + def setTrace(tracer: StringTracer | None) -> None: + transitioner.setTrace(wrapTracer(tracer)) return setTrace @@ -325,8 +378,8 @@ def gensym(): class MethodicalMachine(object): """ - A :class:`MethodicalMachine` is an interface to an `Automaton` - that uses methods on a class. + A L{MethodicalMachine} is an interface to an L{Automaton} that uses methods + on a class. """ def __init__(self): @@ -345,25 +398,26 @@ def __get__(self, oself, type=None): return self @_keywords_only - def state(self, initial=False, terminal=False, serialized=None): + def state( + self, initial: bool = False, terminal: bool = False, serialized: Hashable = None + ): """ Declare a state, possibly an initial state or a terminal state. This is a decorator for methods, but it will modify the method so as not to be callable any more. - :param bool initial: is this state the initial state? - Only one state on this :class:`automat.MethodicalMachine` - may be an initial state; more than one is an error. + @param initial: is this state the initial state? Only one state on + this L{automat.MethodicalMachine} may be an initial state; more + than one is an error. - :param bool terminal: Is this state a terminal state? - i.e. a state that the machine can end up in? - (This is purely informational at this point.) + @param terminal: Is this state a terminal state? i.e. a state that the + machine can end up in? (This is purely informational at this + point.) - :param Hashable serialized: a serializable value - to be used to represent this state to external systems. - This value should be hashable; - :py:func:`unicode` is a good type to use. + @param serialized: a serializable value to be used to represent this + state to external systems. This value should be hashable; L{str} + is a good type to use. """ def decorator(stateMethod): @@ -468,7 +522,7 @@ def unserialize(oself, *args, **kwargs): return decorator @property - def _setTrace(self): + def _setTrace(self) -> MethodicalTracer: return MethodicalTracer(self._automaton, self._symbol) def asDigraph(self): diff --git a/automat/_runtimeproto.py b/automat/_runtimeproto.py new file mode 100644 index 0000000..c9c7409 --- /dev/null +++ b/automat/_runtimeproto.py @@ -0,0 +1,62 @@ +""" +Workaround for U{the lack of TypeForm +}. +""" + +from __future__ import annotations + +import sys + +from typing import TYPE_CHECKING, Callable, Protocol, TypeVar + +from inspect import signature, Signature + +T = TypeVar("T") + +ProtocolAtRuntime = Callable[[], T] + + +def runtime_name(x: ProtocolAtRuntime[T]) -> str: + return x.__name__ + + +from inspect import getmembers, isfunction + +emptyProtocolMethods: frozenset[str] +if not TYPE_CHECKING: + emptyProtocolMethods = frozenset( + name + for name, each in getmembers(type("Example", tuple([Protocol]), {}), isfunction) + ) + + +def actuallyDefinedProtocolMethods(protocol: object) -> frozenset[str]: + """ + Attempt to ignore implementation details, and get all the methods that the + protocol actually defines. + + that includes locally defined methods and also those defined in inherited + superclasses. + """ + return ( + frozenset(name for name, each in getmembers(protocol, isfunction)) + - emptyProtocolMethods + ) + + +def _fixAnnotation(method: Callable[..., object], it: object, ann: str) -> None: + annotation = getattr(it, ann) + if isinstance(annotation, str): + setattr(it, ann, eval(annotation, method.__globals__)) + + +def _liveSignature(method: Callable[..., object]) -> Signature: + """ + Get a signature with evaluated annotations. + """ + # TODO: could this be replaced with get_type_hints? + result = signature(method) + for param in result.parameters.values(): + _fixAnnotation(method, param, "_annotation") + _fixAnnotation(method, result, "_return_annotation") + return result diff --git a/automat/_test/test_core.py b/automat/_test/test_core.py index dd66f61..fa0c703 100644 --- a/automat/_test/test_core.py +++ b/automat/_test/test_core.py @@ -1,7 +1,7 @@ -from .._core import Automaton, NoTransition - from unittest import TestCase +from .._core import Automaton, NoTransition, Transitioner + class CoreTests(TestCase): """ @@ -26,6 +26,19 @@ def test_NoTransition(self): self.assertIn(state, str(noTransitionException)) self.assertIn(symbol, str(noTransitionException)) + def test_unhandledTransition(self) -> None: + """ + Automaton.unhandledTransition sets the outputs and end-state to be used + for all unhandled transitions. + """ + a: Automaton[str, str, str] = Automaton("start") + a.addTransition("oops-state", "check", "start", tuple(["checked"])) + a.unhandledTransition("oops-state", ["oops-out"]) + t = Transitioner(a, "start") + self.assertEqual(t.transition("check"), (tuple(["oops-out"]), None)) + self.assertEqual(t.transition("check"), (["checked"], None)) + self.assertEqual(t.transition("check"), (tuple(["oops-out"]), None)) + def test_noOutputForInput(self): """ L{Automaton.outputForInput} raises L{NoTransition} if no diff --git a/automat/_test/test_discover.py b/automat/_test/test_discover.py index 6f340ae..6e56b94 100644 --- a/automat/_test/test_discover.py +++ b/automat/_test/test_discover.py @@ -186,26 +186,6 @@ class FindMachinesViaWrapperTests(_WritesPythonModules): L{twisted.python.modules.PythonAttribute}. """ - TEST_MODULE_SOURCE = """ - from automat import MethodicalMachine - - - class PythonClass(object): - _classMachine = MethodicalMachine() - - class NestedClass(object): - _nestedClassMachine = MethodicalMachine() - - ignoredAttribute = "I am ignored." - - def ignoredMethod(self): - "I am also ignored." - - rootLevelMachine = MethodicalMachine() - ignoredPythonObject = PythonClass() - anotherIgnoredPythonObject = "I am ignored." - """ - def setUp(self): super(FindMachinesViaWrapperTests, self).setUp() from .._discover import findMachinesViaWrapper @@ -231,6 +211,31 @@ def test_yieldsMachine(self): list(self.findMachinesViaWrapper(rootMachine)), ) + def test_yieldsTypeMachine(self) -> None: + """ + When given a L{twisted.python.modules.PythonAttribute} that refers + directly to a L{TypeMachine}, L{findMachinesViaWrapper} yields that + machine and its FQPN. + """ + source = """\ + from automat import TypeMachineBuilder + from typing import Protocol, Callable + class P(Protocol): + def method(self) -> None: ... + class C:... + def buildBuilder() -> Callable[[C], P]: + builder = TypeMachineBuilder(P, C) + return builder.build() + rootMachine = buildBuilder() + """ + + moduleDict = self.makeModuleAsDict(source, self.pathDir, "root.py") + rootMachine = moduleDict["root.rootMachine"] + self.assertIn( + ("root.rootMachine", rootMachine.load()), + list(self.findMachinesViaWrapper(rootMachine)), + ) + def test_yieldsMachineInClass(self): """ When given a L{twisted.python.modules.PythonAttribute} that refers diff --git a/automat/_test/test_type_based.py b/automat/_test/test_type_based.py new file mode 100644 index 0000000..2666353 --- /dev/null +++ b/automat/_test/test_type_based.py @@ -0,0 +1,534 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Generic, List, Protocol, TypeVar +from unittest import TestCase, skipIf + +from .. import AlreadyBuiltError, NoTransition, TypeMachineBuilder, pep614 + +try: + from zope.interface import Interface, implementer # type:ignore[import-untyped] +except ImportError: + hasInterface = False +else: + hasInterface = True + + class ISomething(Interface): + def something() -> int: ... # type:ignore[misc,empty-body] + + +T = TypeVar("T") + + +class ProtocolForTesting(Protocol): + + def change(self) -> None: + "Switch to the other state." + + def value(self) -> int: + "Give a value specific to the given state." + + +class ArgTaker(Protocol): + def takeSomeArgs(self, arg1: int = 0, arg2: str = "") -> None: ... + def value(self) -> int: ... + + +class NoOpCore: + "Just an object, you know?" + + +@dataclass +class Gen(Generic[T]): + t: T + + +def buildTestBuilder() -> tuple[ + TypeMachineBuilder[ProtocolForTesting, NoOpCore], + Callable[[NoOpCore], ProtocolForTesting], +]: + builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) + first = builder.state("first") + second = builder.state("second") + + first.upon(ProtocolForTesting.change).to(second).returns(None) + second.upon(ProtocolForTesting.change).to(first).returns(None) + + @pep614(first.upon(ProtocolForTesting.value).loop()) + def firstValue(machine: ProtocolForTesting, core: NoOpCore) -> int: + return 3 + + @pep614(second.upon(ProtocolForTesting.value).loop()) + def secondValue(machine: ProtocolForTesting, core: NoOpCore) -> int: + return 4 + + return builder, builder.build() + + +builder, machineFactory = buildTestBuilder() + + +def needsSomething(proto: ProtocolForTesting, core: NoOpCore, value: str) -> int: + "we need data to build this state" + return 3 # pragma: no cover + + +def needsNothing(proto: ArgTaker, core: NoOpCore) -> str: + return "state-specific data" # pragma: no cover + + +class SimpleProtocol(Protocol): + def method(self) -> None: + "A method" + + +class Counter(Protocol): + def start(self) -> None: + "enter the counting state" + + def increment(self) -> None: + "increment the counter" + + def stop(self) -> int: + "stop" + + +@dataclass +class Count: + value: int = 0 + + +class TypeMachineTests(TestCase): + + def test_oneTransition(self) -> None: + + machine = machineFactory(NoOpCore()) + + self.assertEqual(machine.value(), 3) + machine.change() + self.assertEqual(machine.value(), 4) + self.assertEqual(machine.value(), 4) + machine.change() + self.assertEqual(machine.value(), 3) + + def test_stateSpecificData(self) -> None: + + builder = TypeMachineBuilder(Counter, NoOpCore) + initial = builder.state("initial") + counting = builder.state("counting", lambda machine, core: Count()) + initial.upon(Counter.start).to(counting).returns(None) + + @pep614(counting.upon(Counter.increment).loop()) + def incf(counter: Counter, core: NoOpCore, count: Count) -> None: + count.value += 1 + + @pep614(counting.upon(Counter.stop).to(initial)) + def finish(counter: Counter, core: NoOpCore, count: Count) -> int: + return count.value + + machineFactory = builder.build() + machine = machineFactory(NoOpCore()) + machine.start() + machine.increment() + machine.increment() + self.assertEqual(machine.stop(), 2) + machine.start() + machine.increment() + self.assertEqual(machine.stop(), 1) + + def test_stateSpecificDataWithoutData(self) -> None: + """ + To facilitate common implementations of transition behavior methods, + sometimes you want to implement a transition within a data state + without taking a data parameter. To do this, pass the 'nodata=True' + parameter to 'upon'. + """ + builder = TypeMachineBuilder(Counter, NoOpCore) + initial = builder.state("initial") + counting = builder.state("counting", lambda machine, core: Count()) + startCalls = [] + + @pep614(initial.upon(Counter.start).to(counting)) + @pep614(counting.upon(Counter.start, nodata=True).loop()) + def start(counter: Counter, core: NoOpCore) -> None: + startCalls.append("started!") + + @pep614(counting.upon(Counter.increment).loop()) + def incf(counter: Counter, core: NoOpCore, count: Count) -> None: + count.value += 1 + + @pep614(counting.upon(Counter.stop).to(initial)) + def finish(counter: Counter, core: NoOpCore, count: Count) -> int: + return count.value + + machineFactory = builder.build() + machine = machineFactory(NoOpCore()) + machine.start() + self.assertEqual(len(startCalls), 1) + machine.start() + self.assertEqual(len(startCalls), 2) + machine.increment() + self.assertEqual(machine.stop(), 1) + + def test_incompleteTransitionDefinition(self) -> None: + builder = TypeMachineBuilder(SimpleProtocol, NoOpCore) + sample = builder.state("sample") + sample.upon(SimpleProtocol.method).loop() # oops, no '.returns(None)' + with self.assertRaises(ValueError) as raised: + builder.build() + self.assertIn( + "incomplete transition from sample to sample upon SimpleProtocol.method", + str(raised.exception), + ) + + def test_dataToData(self) -> None: + builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) + + @dataclass + class Data1: + value: int + + @dataclass + class Data2: + stuff: List[str] + + initial = builder.state("initial") + counting = builder.state("counting", lambda proto, core: Data1(1)) + appending = builder.state("appending", lambda proto, core: Data2([])) + + initial.upon(ProtocolForTesting.change).to(counting).returns(None) + + @pep614(counting.upon(ProtocolForTesting.value).loop()) + def countup(p: ProtocolForTesting, c: NoOpCore, d: Data1) -> int: + d.value *= 2 + return d.value + + counting.upon(ProtocolForTesting.change).to(appending).returns(None) + + @pep614(appending.upon(ProtocolForTesting.value).loop()) + def appendup(p: ProtocolForTesting, c: NoOpCore, d: Data2) -> int: + d.stuff.extend("abc") + return len(d.stuff) + + machineFactory = builder.build() + machine = machineFactory(NoOpCore()) + machine.change() + self.assertEqual(machine.value(), 2) + self.assertEqual(machine.value(), 4) + machine.change() + self.assertEqual(machine.value(), 3) + self.assertEqual(machine.value(), 6) + + def test_dataFactoryArgs(self) -> None: + """ + Any data factory that takes arguments will constrain the allowed + signature of all protocol methods that transition into that state. + """ + builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) + initial = builder.state("initial") + data = builder.state("data", needsSomething) + data2 = builder.state("data2", needsSomething) + # toState = initial.to(data) + + # 'assertions' in the form of expected type errors: + # (no data -> data) + uponNoData = initial.upon(ProtocolForTesting.change) + uponNoData.to(data) # type:ignore[arg-type] + + # (data -> data) + uponData = data.upon(ProtocolForTesting.change) + uponData.to(data2) # type:ignore[arg-type] + + def test_dataFactoryNoArgs(self) -> None: + """ + Inverse of C{test_dataFactoryArgs} where the data factory specifically + does I{not} take arguments, but the input specified does. + """ + builder = TypeMachineBuilder(ArgTaker, NoOpCore) + initial = builder.state("initial") + data = builder.state("data", needsNothing) + ( + initial.upon(ArgTaker.takeSomeArgs) + .to(data) # type:ignore[arg-type] + .returns(None) + ) + + def test_invalidTransition(self) -> None: + """ + Invalid transitions raise a NoTransition exception. + """ + builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) + builder.state("initial") + factory = builder.build() + machine = factory(NoOpCore()) + with self.assertRaises(NoTransition): + machine.change() + + def test_reentrancy(self) -> None: + """ + During the execution of a transition behavior implementation function, + you may invoke other methods on your state machine. However, the + execution of the behavior of those methods will be deferred until the + current behavior method is done executing. In order to implement that + deferral, we restrict the set of methods that can be invoked to those + that return None. + + @note: it may be possible to implement deferral via Awaitables or + Deferreds later, but we are starting simple. + """ + + class SomeMethods(Protocol): + def start(self) -> None: + "Start the machine." + + def later(self) -> None: + "Do some deferrable work." + + builder = TypeMachineBuilder(SomeMethods, NoOpCore) + + initial = builder.state("initial") + second = builder.state("second") + + order = [] + + @pep614(initial.upon(SomeMethods.start).to(second)) + def startup(methods: SomeMethods, core: NoOpCore) -> None: + order.append("startup") + methods.later() + order.append("startup done") + + @pep614(second.upon(SomeMethods.later).loop()) + def later(methods: SomeMethods, core: NoOpCore) -> None: + order.append("later") + + machineFactory = builder.build() + machine = machineFactory(NoOpCore()) + machine.start() + self.assertEqual(order, ["startup", "startup done", "later"]) + + def test_reentrancyNotNoneError(self) -> None: + class SomeMethods(Protocol): + def start(self) -> None: + "Start the machine." + + def later(self) -> int: + "Do some deferrable work." + + builder = TypeMachineBuilder(SomeMethods, NoOpCore) + + initial = builder.state("initial") + second = builder.state("second") + + order = [] + + @pep614(initial.upon(SomeMethods.start).to(second)) + def startup(methods: SomeMethods, core: NoOpCore) -> None: + order.append("startup") + methods.later() + order.append("startup done") # pragma: no cover + + @pep614(second.upon(SomeMethods.later).loop()) + def later(methods: SomeMethods, core: NoOpCore) -> int: + order.append("later") + return 3 + + machineFactory = builder.build() + machine = machineFactory(NoOpCore()) + with self.assertRaises(RuntimeError): + machine.start() + self.assertEqual(order, ["startup"]) + # We do actually do the state transition, which happens *before* the + # output is generated; TODO: maybe we should have exception handling + # that transitions into an error state that requires explicit recovery? + self.assertEqual(machine.later(), 3) + self.assertEqual(order, ["startup", "later"]) + + def test_buildLock(self) -> None: + """ + ``.build()`` locks the builder so it can no longer be modified. + """ + builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) + state = builder.state("test-state") + state2 = builder.state("state2") + state3 = builder.state("state3") + upon = state.upon(ProtocolForTesting.change) + to = upon.to(state2) + to2 = upon.to(state3) + to.returns(None) + with self.assertRaises(ValueError) as ve: + to2.returns(None) + with self.assertRaises(AlreadyBuiltError): + to.returns(None) + builder.build() + with self.assertRaises(AlreadyBuiltError): + builder.state("hello") + with self.assertRaises(AlreadyBuiltError): + builder.build() + + def test_methodMembership(self) -> None: + """ + Input methods must be members of their protocol. + """ + builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) + state = builder.state("test-state") + + def stateful(proto: ProtocolForTesting, core: NoOpCore) -> int: + return 4 # pragma: no cover + + state2 = builder.state("state2", stateful) + + def change(self: ProtocolForTesting) -> None: ... + + def rogue(self: ProtocolForTesting) -> int: + return 3 # pragma: no cover + + with self.assertRaises(ValueError): + state.upon(change) + with self.assertRaises(ValueError) as ve: + state2.upon(change) + with self.assertRaises(ValueError): + state.upon(rogue) + + def test_startInAlternateState(self) -> None: + """ + The state machine can be started in an alternate state. + """ + builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) + one = builder.state("one") + two = builder.state("two") + + @dataclass + class Three: + proto: ProtocolForTesting + core: NoOpCore + value: int = 0 + + three = builder.state("three", Three) + one.upon(ProtocolForTesting.change).to(two).returns(None) + one.upon(ProtocolForTesting.value).loop().returns(1) + two.upon(ProtocolForTesting.change).to(three).returns(None) + two.upon(ProtocolForTesting.value).loop().returns(2) + + @pep614(three.upon(ProtocolForTesting.value).loop()) + def threevalue(proto: ProtocolForTesting, core: NoOpCore, three: Three) -> int: + return 3 + three.value + + onetwothree = builder.build() + + # confirm positive behavior first, particularly the value of the three + # state's change + normal = onetwothree(NoOpCore()) + self.assertEqual(normal.value(), 1) + normal.change() + self.assertEqual(normal.value(), 2) + normal.change() + self.assertEqual(normal.value(), 3) + + # now try deserializing it in each state + self.assertEqual(onetwothree(NoOpCore()).value(), 1) + self.assertEqual(onetwothree(NoOpCore(), two).value(), 2) + self.assertEqual( + onetwothree( + NoOpCore(), three, lambda proto, core: Three(proto, core, 4) + ).value(), + 7, + ) + + def test_genericData(self) -> None: + """ + Test to cover get_origin in generic assertion. + """ + builder = TypeMachineBuilder(ArgTaker, NoOpCore) + one = builder.state("one") + + def dat( + proto: ArgTaker, core: NoOpCore, arg1: int = 0, arg2: str = "" + ) -> Gen[int]: + return Gen(arg1) + + two = builder.state("two", dat) + one.upon(ArgTaker.takeSomeArgs).to(two).returns(None) + + @pep614(two.upon(ArgTaker.value).loop()) + def val(proto: ArgTaker, core: NoOpCore, data: Gen[int]) -> int: + return data.t + + b = builder.build() + m = b(NoOpCore()) + m.takeSomeArgs(3) + self.assertEqual(m.value(), 3) + + @skipIf(not hasInterface, "zope.interface not installed") + def test_interfaceData(self) -> None: + """ + Test to cover providedBy assertion. + """ + builder = TypeMachineBuilder(ArgTaker, NoOpCore) + one = builder.state("one") + + @implementer(ISomething) + @dataclass + class Something: + val: int + + def something(self) -> int: + return self.val + + def dat( + proto: ArgTaker, core: NoOpCore, arg1: int = 0, arg2: str = "" + ) -> ISomething: + return Something(arg1) # type:ignore[return-value] + + two = builder.state("two", dat) + one.upon(ArgTaker.takeSomeArgs).to(two).returns(None) + + @pep614(two.upon(ArgTaker.value).loop()) + def val(proto: ArgTaker, core: NoOpCore, data: ISomething) -> int: + return data.something() # type:ignore[misc] + + b = builder.build() + m = b(NoOpCore()) + m.takeSomeArgs(3) + self.assertEqual(m.value(), 3) + + def test_noMethodsInAltStateDataFactory(self) -> None: + """ + When the state machine is received by a data factory during + construction, it is in an invalid state. It may be invoked after + construction is complete. + """ + builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore) + + @dataclass + class Data: + value: int + proto: ProtocolForTesting + + start = builder.state("start") + data = builder.state("data", lambda proto, core: Data(3, proto)) + + @pep614(data.upon(ProtocolForTesting.value).loop()) + def getval(proto: ProtocolForTesting, core: NoOpCore, data: Data) -> int: + return data.value + + @pep614(start.upon(ProtocolForTesting.value).loop()) + def minusone(proto: ProtocolForTesting, core: NoOpCore) -> int: + return -1 + + factory = builder.build() + self.assertEqual(factory(NoOpCore()).value(), -1) + + def touchproto(proto: ProtocolForTesting, core: NoOpCore) -> Data: + return Data(proto.value(), proto) + + catchdata = [] + + def notouchproto(proto: ProtocolForTesting, core: NoOpCore) -> Data: + catchdata.append(new := Data(4, proto)) + return new + + with self.assertRaises(NoTransition): + factory(NoOpCore(), data, touchproto) + machine = factory(NoOpCore(), data, notouchproto) + self.assertIs(machine, catchdata[0].proto) + self.assertEqual(machine.value(), 4) diff --git a/automat/_test/test_visualize.py b/automat/_test/test_visualize.py index 503bbee..552059c 100644 --- a/automat/_test/test_visualize.py +++ b/automat/_test/test_visualize.py @@ -1,14 +1,16 @@ -from __future__ import print_function -import functools +from __future__ import annotations +import functools import os import subprocess +from dataclasses import dataclass +from typing import Protocol from unittest import TestCase, skipIf -import attr +from automat import TypeMachineBuilder, pep614 from .._methodical import MethodicalMachine - +from .._typed import TypeMachine from .test_discover import isTwistedInstalled @@ -66,6 +68,32 @@ def out(self): return mm +class Sample(Protocol): + def go(self) -> None: ... +class Core: ... + + +def sampleTypeMachine() -> TypeMachine[Sample, Core]: + """ + Create a sample L{TypeMachine} with some sample states. + """ + builder = TypeMachineBuilder(Sample, Core) + begin = builder.state("begin") + + def buildit(proto: Sample, core: Core) -> int: + return 3 # pragma: no cover + + data = builder.state("data", buildit) + end = builder.state("end") + begin.upon(Sample.go).to(data).returns(None) + data.upon(Sample.go).to(end).returns(None) + + @pep614(end.upon(Sample.go).to(begin)) + def out(sample: Sample, core: Core) -> None: ... + + return builder.build() + + @skipIf(not isGraphvizModuleInstalled(), "Graphviz module is not installed.") @skipIf(not isTwistedInstalled(), "Twisted is not installed.") class ElementMakerTests(TestCase): @@ -104,13 +132,13 @@ def test_noAttrs(self): self.assertEqual(expected, self.elementMaker("div")) -@attr.s +@dataclass class HTMLElement(object): """Holds an HTML element, as created by elementMaker.""" - name = attr.ib() - children = attr.ib() - attributes = attr.ib() + name: str + children: list[HTMLElement] + attributes: dict[str, str] def findElements(element, predicate): @@ -224,12 +252,14 @@ class IntegrationTests(TestCase): Automat. """ - def test_validGraphviz(self): + def test_validGraphviz(self) -> None: """ - L{graphviz} emits valid graphviz data. + C{graphviz} emits valid graphviz data. """ + digraph = sampleMachine().asDigraph() + text = "".join(digraph).encode("utf-8") p = subprocess.Popen("dot", stdin=subprocess.PIPE, stdout=subprocess.PIPE) - out, err = p.communicate("".join(sampleMachine().asDigraph()).encode("utf-8")) + out, err = p.communicate(text) self.assertEqual(p.returncode, 0) @@ -243,8 +273,8 @@ class SpotChecks(TestCase): def test_containsMachineFeatures(self): """ - The output of L{graphviz} should contain the names of the states, - inputs, outputs in the state machine. + The output of L{graphviz.Digraph} should contain the names of the + states, inputs, outputs in the state machine. """ gvout = "".join(sampleMachine().asDigraph()) self.assertIn("begin", gvout) @@ -252,6 +282,18 @@ def test_containsMachineFeatures(self): self.assertIn("go", gvout) self.assertIn("out", gvout) + def test_containsTypeMachineFeatures(self): + """ + The output of L{graphviz.Digraph} should contain the names of the states, + inputs, outputs in the state machine. + """ + gvout = "".join(sampleTypeMachine().asDigraph()) + self.assertIn("begin", gvout) + self.assertIn("end", gvout) + self.assertIn("go", gvout) + self.assertIn("data:buildit", gvout) + self.assertIn("out", gvout) + class RecordsDigraphActions(object): """ diff --git a/automat/_typed.py b/automat/_typed.py new file mode 100644 index 0000000..c5d9e12 --- /dev/null +++ b/automat/_typed.py @@ -0,0 +1,736 @@ +# -*- test-case-name: automat._test.test_type_based -*- +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from typing import ( + TYPE_CHECKING, + get_origin, + Any, + Callable, + Generic, + Iterable, + Literal, + Protocol, + TypeVar, + overload, +) + +if TYPE_CHECKING: + from graphviz import Digraph +try: + from zope.interface.interface import InterfaceClass # type:ignore[import-untyped] +except ImportError: + hasInterface = False +else: + hasInterface = True + +if sys.version_info < (3, 10): + from typing_extensions import Concatenate, ParamSpec, TypeAlias +else: + from typing import Concatenate, ParamSpec, TypeAlias + +from ._core import Automaton, Transitioner +from ._runtimeproto import ( + ProtocolAtRuntime, + _liveSignature, + actuallyDefinedProtocolMethods, + runtime_name, +) + + +class AlreadyBuiltError(Exception): + """ + The L{TypeMachine} is already built, and thus can no longer be + modified. + """ + + +InputProtocol = TypeVar("InputProtocol") +Core = TypeVar("Core") +Data = TypeVar("Data") +P = ParamSpec("P") +P1 = ParamSpec("P1") +R = TypeVar("R") +OtherData = TypeVar("OtherData") +Decorator = Callable[[Callable[P, R]], Callable[P, R]] +FactoryParams = ParamSpec("FactoryParams") +OtherFactoryParams = ParamSpec("OtherFactoryParams") + + +def pep614(t: R) -> R: + """ + This is a workaround for Python 3.8, which has U{some restrictions on its + grammar for decorators }, and makes + C{@state.to(other).upon(Protocol.input)} invalid syntax; for code that + needs to run on these older Python versions, you can do + C{@pep614(state.to(other).upon(Protocol.input))} instead. + """ + return t + + +@dataclass() +class TransitionRegistrar(Generic[P, P1, R]): + """ + This is a record of a transition that need finalizing; it is the result of + calling L{TypeMachineBuilder.state} and then ``.upon(input).to(state)`` on + the result of that. + + It can be used as a decorator, like:: + + registrar = state.upon(Proto.input).to(state2) + @registrar + def inputImplementation(proto: Proto, core: Core) -> Result: ... + + Or, it can be used used to implement a constant return value with + L{TransitionRegistrar.returns}, like:: + + registrar = state.upon(Proto.input).to(state2) + registrar.returns(value) + + Type parameter P: the precise signature of the decorated implementation + callable. + + Type parameter P1: the precise signature of the input method from the + outward-facing state-machine protocol. + + Type parameter R: the return type of both the protocol method and the input + method. + """ + + _signature: Callable[P1, R] + _old: AnyState + _new: AnyState + _nodata: bool = False + _callback: Callable[P, R] | None = None + + def __post_init__(self) -> None: + self._old.builder._registrars.append(self) + + def __call__(self, impl: Callable[P, R]) -> Callable[P, R]: + """ + Finalize it with C{__call__} to indicate that there is an + implementation to the transition, which can be treated as an output. + """ + if self._callback is not None: + raise AlreadyBuiltError( + f"already registered transition from {self._old.name!r} to {self._new.name!r}" + ) + self._callback = impl + builder = self._old.builder + assert builder is self._new.builder, "states must be from the same builder" + builder._automaton.addTransition( + self._old, + self._signature.__name__, + self._new, + tuple(self._new._produceOutputs(impl, self._old, self._nodata)), + ) + return impl + + def returns(self, result: R) -> None: + """ + Finalize it with C{.returns(constant)} to indicate that there is no + method body, and the given result can just be yielded each time after + the state transition. The only output generated in this case would be + the data-construction factory for the target state. + """ + + def constant(*args: object, **kwargs: object) -> R: + return result + + constant.__name__ = f"returns({result})" + self(constant) + + def _checkComplete(self) -> None: + """ + Raise an exception if the user forgot to decorate a method + implementation or supply a return value for this transition. + """ + # TODO: point at the line where `.to`/`.loop`/`.upon` are called so the + # user can more immediately see the incomplete transition + if not self._callback: + raise ValueError( + f"incomplete transition from {self._old.name} to " + f"{self._new.name} upon {self._signature.__qualname__}: " + "remember to use the transition as a decorator or call " + "`.returns` on it." + ) + + +@dataclass +class UponFromNo(Generic[InputProtocol, Core, P, R]): + """ + Type parameter P: the signature of the input method. + """ + + old: TypedState[InputProtocol, Core] | TypedDataState[InputProtocol, Core, Any, ...] + input: Callable[Concatenate[InputProtocol, P], R] + + @overload + def to( + self, state: TypedState[InputProtocol, Core] + ) -> TransitionRegistrar[Concatenate[InputProtocol, Core, P], P, R]: ... + @overload + def to( + self, + state: TypedDataState[InputProtocol, Core, OtherData, P], + ) -> TransitionRegistrar[ + Concatenate[InputProtocol, Core, P], + Concatenate[InputProtocol, P], + R, + ]: ... + def to( + self, + state: ( + TypedState[InputProtocol, Core] + | TypedDataState[InputProtocol, Core, Any, P] + ), + ) -> ( + TransitionRegistrar[Concatenate[InputProtocol, Core, P], P, R] + | TransitionRegistrar[ + Concatenate[InputProtocol, Core, P], + Concatenate[InputProtocol, P], + R, + ] + ): + """ + Declare a state transition to a new state. + """ + return TransitionRegistrar(self.input, self.old, state, True) + + def loop(self) -> TransitionRegistrar[ + Concatenate[InputProtocol, Core, P], + Concatenate[InputProtocol, P], + R, + ]: + """ + Register a transition back to the same state. + """ + return TransitionRegistrar(self.input, self.old, self.old, True) + + +@dataclass +class UponFromData(Generic[InputProtocol, Core, P, R, Data]): + """ + Type parameter P: the signature of the input method. + """ + + old: TypedDataState[InputProtocol, Core, Data, ...] + input: Callable[Concatenate[InputProtocol, P], R] + + @overload + def to( + self, state: TypedState[InputProtocol, Core] + ) -> TransitionRegistrar[ + Concatenate[InputProtocol, Core, Data, P], Concatenate[InputProtocol, P], R + ]: ... + @overload + def to( + self, + state: TypedDataState[InputProtocol, Core, OtherData, P], + ) -> TransitionRegistrar[ + Concatenate[InputProtocol, Core, Data, P], + Concatenate[InputProtocol, P], + R, + ]: ... + def to( + self, + state: ( + TypedState[InputProtocol, Core] + | TypedDataState[InputProtocol, Core, Any, P] + ), + ) -> ( + TransitionRegistrar[Concatenate[InputProtocol, Core, P], P, R] + | TransitionRegistrar[ + Concatenate[InputProtocol, Core, Data, P], + Concatenate[InputProtocol, P], + R, + ] + ): + """ + Declare a state transition to a new state. + """ + return TransitionRegistrar(self.input, self.old, state) + + def loop(self) -> TransitionRegistrar[ + Concatenate[InputProtocol, Core, Data, P], + Concatenate[InputProtocol, P], + R, + ]: + """ + Register a transition back to the same state. + """ + return TransitionRegistrar(self.input, self.old, self.old) + + +@dataclass(frozen=True) +class TypedState(Generic[InputProtocol, Core]): + """ + The result of L{.state() }. + """ + + name: str + builder: TypeMachineBuilder[InputProtocol, Core] = field(repr=False) + + def upon( + self, input: Callable[Concatenate[InputProtocol, P], R] + ) -> UponFromNo[InputProtocol, Core, P, R]: + ".upon()" + self.builder._checkMembership(input) + return UponFromNo(self, input) + + def _produceOutputs( + self, + impl: Callable[..., object], + old: ( + TypedDataState[InputProtocol, Core, OtherData, OtherFactoryParams] + | TypedState[InputProtocol, Core] + ), + nodata: bool = False, + ) -> Iterable[SomeOutput]: + yield MethodOutput._fromImpl(impl, isinstance(old, TypedDataState)) + + +@dataclass(frozen=True) +class TypedDataState(Generic[InputProtocol, Core, Data, FactoryParams]): + name: str + builder: TypeMachineBuilder[InputProtocol, Core] = field(repr=False) + factory: Callable[Concatenate[InputProtocol, Core, FactoryParams], Data] + + @overload + def upon( + self, input: Callable[Concatenate[InputProtocol, P], R] + ) -> UponFromData[InputProtocol, Core, P, R, Data]: ... + @overload + def upon( + self, input: Callable[Concatenate[InputProtocol, P], R], nodata: Literal[False] + ) -> UponFromData[InputProtocol, Core, P, R, Data]: ... + @overload + def upon( + self, input: Callable[Concatenate[InputProtocol, P], R], nodata: Literal[True] + ) -> UponFromNo[InputProtocol, Core, P, R]: ... + def upon( + self, + input: Callable[Concatenate[InputProtocol, P], R], + nodata: bool = False, + ) -> ( + UponFromData[InputProtocol, Core, P, R, Data] + | UponFromNo[InputProtocol, Core, P, R] + ): + self.builder._checkMembership(input) + if nodata: + return UponFromNo(self, input) + else: + return UponFromData(self, input) + + def _produceOutputs( + self, + impl: Callable[..., object], + old: ( + TypedDataState[InputProtocol, Core, OtherData, OtherFactoryParams] + | TypedState[InputProtocol, Core] + ), + nodata: bool, + ) -> Iterable[SomeOutput]: + if self is not old: + yield DataOutput(self.factory) + yield MethodOutput._fromImpl( + impl, isinstance(old, TypedDataState) and not nodata + ) + + +AnyState: TypeAlias = "TypedState[Any, Any] | TypedDataState[Any, Any, Any, Any]" + + +@dataclass +class TypedInput: + name: str + + +class SomeOutput(Protocol): + """ + A state machine output. + """ + + @property + def name(self) -> str: + "read-only name property" + + def __call__(*args: Any, **kwargs: Any) -> Any: ... + + def __hash__(self) -> int: + "must be hashable" + + +@dataclass +class InputImplementer(Generic[InputProtocol, Core]): + """ + An L{InputImplementer} implements an input protocol in terms of a + state machine. + + When the factory returned from L{TypeMachine} + """ + + __automat_core__: Core + __automat_transitioner__: Transitioner[ + TypedState[InputProtocol, Core] + | TypedDataState[InputProtocol, Core, object, ...], + str, + SomeOutput, + ] + __automat_data__: object | None = None + __automat_postponed__: list[Callable[[], None]] | None = None + + +def implementMethod( + method: Callable[..., object], +) -> Callable[..., object]: + """ + Construct a function for populating in the synthetic provider of the Input + Protocol to a L{TypeMachineBuilder}. It should have a signature matching that + of the C{method} parameter, a function from that protocol. + """ + methodInput = method.__name__ + # side-effects can be re-ordered until later. If you need to compute a + # value in your method, then obviously it can't be invoked reentrantly. + returnAnnotation = _liveSignature(method).return_annotation + returnsNone = returnAnnotation is None + + def implementation( + self: InputImplementer[InputProtocol, Core], *args: object, **kwargs: object + ) -> object: + transitioner = self.__automat_transitioner__ + dataAtStart = self.__automat_data__ + if self.__automat_postponed__ is not None: + if not returnsNone: + raise RuntimeError( + f"attempting to reentrantly run {method.__qualname__} " + f"but it wants to return {returnAnnotation!r} not None" + ) + + def rerunme() -> None: + implementation(self, *args, **kwargs) + + self.__automat_postponed__.append(rerunme) + return None + postponed = self.__automat_postponed__ = [] + try: + [outputs, tracer] = transitioner.transition(methodInput) + result: Any = None + for output in outputs: + # here's the idea: there will be a state-setup output and a + # state-teardown output. state-setup outputs are added to the + # *beginning* of any entry into a state, so that by the time you + # are running the *implementation* of a method that has entered + # that state, the protocol is in a self-consistent state and can + # run reentrant outputs. not clear that state-teardown outputs are + # necessary + result = output(self, dataAtStart, *args, **kwargs) + finally: + self.__automat_postponed__ = None + while postponed: + postponed.pop(0)() + return result + + implementation.__qualname__ = implementation.__name__ = ( + f"" + ) + return implementation + + +@dataclass(frozen=True) +class MethodOutput(Generic[Core]): + """ + This is the thing that goes into the automaton's outputs list, and thus + (per the implementation of L{implementMethod}) takes the 'self' of the + InputImplementer instance (i.e. the synthetic protocol implementation) and the + previous result computed by the former output, which will be None + initially. + """ + + method: Callable[..., Any] + requiresData: bool + _assertion: Callable[[object], None] + + @classmethod + def _fromImpl( + cls: type[MethodOutput[Core]], method: Callable[..., Any], requiresData: bool + ) -> MethodOutput[Core]: + parameter = None + annotation: type[object] = object + + def assertion(data: object) -> None: + """ + No assertion about the data. + """ + + # Do our best to compute the declared signature, so that we caan verify + # it's the right type. We can't always do that. + try: + sig = _liveSignature(method) + except NameError: + ... + # An inner function may refer to type aliases that only appear as + # local variables, and those are just lost here; give up. + else: + if requiresData: + # 0: self, 1: self.__automat_core__, 2: self.__automat_data__ + declaredParams = list(sig.parameters.values()) + if len(declaredParams) >= 3: + parameter = declaredParams[2] + annotation = parameter.annotation + origin = get_origin(annotation) + if origin is not None: + annotation = origin + if hasInterface and isinstance(annotation, InterfaceClass): + + def assertion(data: object) -> None: + assert annotation.providedBy(data), ( + f"expected {parameter} to provide {annotation} " + f"but got {type(data)} instead" + ) + + else: + + def assertion(data: object) -> None: + assert isinstance(data, annotation), ( + f"expected {parameter} to be {annotation} " + f"but got {type(data)} instead" + ) + + return cls(method, requiresData, assertion) + + @property + def name(self) -> str: + return f"{self.method.__name__}" + + def __call__( + self, + machine: InputImplementer[InputProtocol, Core], + dataAtStart: Data, + /, + *args: object, + **kwargs: object, + ) -> object: + extraArgs = [machine, machine.__automat_core__] + if self.requiresData: + self._assertion(dataAtStart) + extraArgs += [dataAtStart] + # if anything is invoked reentrantly here, then we can't possibly have + # set __automat_data__ and the data argument to the reentrant method + # will be wrong. we *need* to split out the construction / state-enter + # hook, because it needs to run separately. + return self.method(*extraArgs, *args, **kwargs) + + +@dataclass(frozen=True) +class DataOutput(Generic[Data]): + """ + Construct an output for the given data objects. + """ + + dataFactory: Callable[..., Data] + + @property + def name(self) -> str: + return f"data:{self.dataFactory.__name__}" + + def __call__( + realself, + self: InputImplementer[InputProtocol, Core], + dataAtStart: object, + *args: object, + **kwargs: object, + ) -> Data: + newData = realself.dataFactory(self, self.__automat_core__, *args, **kwargs) + self.__automat_data__ = newData + return newData + + +INVALID_WHILE_DESERIALIZING: TypedState[Any, Any] = TypedState( + "automat:invalid-while-deserializing", + None, # type:ignore[arg-type] +) + + +@dataclass(frozen=True) +class TypeMachine(Generic[InputProtocol, Core]): + """ + A L{TypeMachine} is a factory for instances of C{InputProtocol}. + """ + + __automat_type__: type[InputImplementer[InputProtocol, Core]] + __automat_automaton__: Automaton[ + TypedState[InputProtocol, Core] | TypedDataState[InputProtocol, Core, Any, ...], + str, + SomeOutput, + ] + + @overload + def __call__(self, core: Core) -> InputProtocol: ... + @overload + def __call__( + self, core: Core, state: TypedState[InputProtocol, Core] + ) -> InputProtocol: ... + @overload + def __call__( + self, + core: Core, + state: TypedDataState[InputProtocol, Core, OtherData, ...], + dataFactory: Callable[[InputProtocol, Core], OtherData], + ) -> InputProtocol: ... + + def __call__( + self, + core: Core, + state: ( + TypedState[InputProtocol, Core] + | TypedDataState[InputProtocol, Core, OtherData, ...] + | None + ) = None, + dataFactory: Callable[[InputProtocol, Core], OtherData] | None = None, + ) -> InputProtocol: + """ + Construct an instance of C{InputProtocol} from an instance of the + C{Core} protocol. + """ + if state is None: + state = initial = self.__automat_automaton__.initialState + elif isinstance(state, TypedDataState): + assert dataFactory is not None, "data state requires a data factory" + # Ensure that the machine is in a state with *no* transitions while + # we are doing the initial construction of its state-specific data. + initial = INVALID_WHILE_DESERIALIZING + else: + initial = state + + internals: InputImplementer[InputProtocol, Core] = self.__automat_type__( + core, txnr := Transitioner(self.__automat_automaton__, initial) + ) + result: InputProtocol = internals # type:ignore[assignment] + + if dataFactory is not None: + internals.__automat_data__ = dataFactory(result, core) + txnr._state = state + return result + + def asDigraph(self) -> Digraph: + from ._visualize import makeDigraph + + return makeDigraph( + self.__automat_automaton__, + stateAsString=lambda state: state.name, + inputAsString=lambda input: input, + outputAsString=lambda output: output.name, + ) + + +@dataclass(eq=False) +class TypeMachineBuilder(Generic[InputProtocol, Core]): + """ + The main entry-point into Automat, used to construct a factory for + instances of C{InputProtocol} that take an instance of C{Core}. + + Describe the machine with L{TypeMachineBuilder.state} L{.upon + } L{.to + }, then build it with + L{TypeMachineBuilder.build}, like so:: + + from typing import Protocol + class Inputs(Protocol): + def method(self) -> None: ... + class Core: ... + + from automat import TypeMachineBuilder + builder = TypeMachineBuilder(Inputs, Core) + state = builder.state("state") + state.upon(Inputs.method).loop().returns(None) + Machine = builder.build() + + machine = Machine(Core()) + machine.method() + """ + + # Public constructor parameters. + inputProtocol: ProtocolAtRuntime[InputProtocol] + coreType: type[Core] + + # Internal state, not in the constructor. + _automaton: Automaton[ + TypedState[InputProtocol, Core] | TypedDataState[InputProtocol, Core, Any, ...], + str, + SomeOutput, + ] = field(default_factory=Automaton, repr=False, init=False) + _initial: bool = field(default=True, init=False) + _registrars: list[TransitionRegistrar[..., ..., Any]] = field( + default_factory=list, init=False + ) + _built: bool = field(default=False, init=False) + + @overload + def state(self, name: str) -> TypedState[InputProtocol, Core]: ... + @overload + def state( + self, + name: str, + dataFactory: Callable[Concatenate[InputProtocol, Core, P], Data], + ) -> TypedDataState[InputProtocol, Core, Data, P]: ... + def state( + self, + name: str, + dataFactory: Callable[Concatenate[InputProtocol, Core, P], Data] | None = None, + ) -> TypedState[InputProtocol, Core] | TypedDataState[InputProtocol, Core, Data, P]: + """ + Construct a state. + """ + if self._built: + raise AlreadyBuiltError( + "Cannot add states to an already-built state machine." + ) + if dataFactory is None: + state = TypedState(name, self) + if self._initial: + self._initial = False + self._automaton.initialState = state + return state + else: + assert not self._initial, "initial state cannot require state-specific data" + return TypedDataState(name, self, dataFactory) + + def build(self) -> TypeMachine[InputProtocol, Core]: + """ + Create a L{TypeMachine}, and prevent further modification to the state + machine being built. + """ + # incompleteness check + if self._built: + raise AlreadyBuiltError("Cannot build a state machine twice.") + self._built = True + + for registrar in self._registrars: + registrar._checkComplete() + + # We were only hanging on to these for error-checking purposes, so we + # can drop them now. + del self._registrars[:] + + runtimeType: type[InputImplementer[InputProtocol, Core]] = type( + f"Typed<{runtime_name(self.inputProtocol)}>", + tuple([InputImplementer]), + { + method_name: implementMethod(getattr(self.inputProtocol, method_name)) + for method_name in actuallyDefinedProtocolMethods(self.inputProtocol) + }, + ) + + return TypeMachine(runtimeType, self._automaton) + + def _checkMembership(self, input: Callable[..., object]) -> None: + """ + Ensure that ``input`` is a valid member function of the input protocol, + not just a function that happens to take the right first argument. + """ + if (checked := getattr(self.inputProtocol, input.__name__, None)) is not input: + raise ValueError( + f"{input.__qualname__} is not a member of {self.inputProtocol.__module__}.{self.inputProtocol.__name__}" + ) diff --git a/automat/_visualize.py b/automat/_visualize.py index 48cd5fe..a2b35e5 100644 --- a/automat/_visualize.py +++ b/automat/_visualize.py @@ -1,21 +1,27 @@ -from __future__ import print_function +from __future__ import annotations + import argparse import sys +from functools import wraps +from typing import Callable, Iterator import graphviz +from ._core import Automaton, Input, Output, State from ._discover import findMachines +from ._methodical import MethodicalMachine +from ._typed import TypeMachine, InputProtocol, Core -def _gvquote(s): +def _gvquote(s: str) -> str: return '"{}"'.format(s.replace('"', r"\"")) -def _gvhtml(s): +def _gvhtml(s: str) -> str: return "<{}>".format(s) -def elementMaker(name, *children, **attrs): +def elementMaker(name: str, *children: str, **attrs: str) -> str: """ Construct a string from the HTML element description. """ @@ -29,7 +35,12 @@ def elementMaker(name, *children, **attrs): ) -def tableMaker(inputLabel, outputLabels, port, _E=elementMaker): +def tableMaker( + inputLabel: str, + outputLabels: list[str], + port: str, + _E: Callable[..., str] = elementMaker, +) -> str: """ Construct an HTML table to label a state transition. """ @@ -42,7 +53,7 @@ def tableMaker(inputLabel, outputLabels, port, _E=elementMaker): _E("font", inputLabel, face="menlo-italic"), color="purple", port=port, - **colspan + **colspan, ) pointSize = {"point-size": "9"} @@ -59,10 +70,28 @@ def tableMaker(inputLabel, outputLabels, port, _E=elementMaker): return _E("table", *rows) -def makeDigraph(automaton, inputAsString=repr, outputAsString=repr, stateAsString=repr): +def escapify(x: Callable[[State], str]) -> Callable[[State], str]: + @wraps(x) + def impl(t: State) -> str: + return x(t).replace("<", "<").replace(">", ">") + + return impl + + +def makeDigraph( + automaton: Automaton[State, Input, Output], + inputAsString: Callable[[Input], str] = repr, + outputAsString: Callable[[Output], str] = repr, + stateAsString: Callable[[State], str] = repr, +) -> graphviz.Digraph: """ Produce a L{graphviz.Digraph} object from an automaton. """ + + inputAsString = escapify(inputAsString) + outputAsString = escapify(outputAsString) + stateAsString = escapify(stateAsString) + digraph = graphviz.Digraph( graph_attr={"pack": "true", "dpi": "100"}, node_attr={"fontname": "Menlo"}, @@ -108,12 +137,15 @@ def makeDigraph(automaton, inputAsString=repr, outputAsString=repr, stateAsStrin def tool( - _progname=sys.argv[0], - _argv=sys.argv[1:], - _syspath=sys.path, - _findMachines=findMachines, - _print=print, -): + _progname: str = sys.argv[0], + _argv: list[str] = sys.argv[1:], + _syspath: list[str] = sys.path, + _findMachines: Callable[ + [str], + Iterator[tuple[str, MethodicalMachine | TypeMachine[InputProtocol, Core]]], + ] = findMachines, + _print: Callable[..., None] = print, +) -> None: """ Entry point for command line utility. """ diff --git a/automat/py.typed b/automat/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/docs/_static/garage_door.machineFactory.dot.png b/docs/_static/garage_door.machineFactory.dot.png new file mode 100644 index 0000000..c2e03d6 Binary files /dev/null and b/docs/_static/garage_door.machineFactory.dot.png differ diff --git a/docs/about.rst b/docs/about.rst deleted file mode 100644 index fb27d4b..0000000 --- a/docs/about.rst +++ /dev/null @@ -1,531 +0,0 @@ -=========== -Quick Start -=========== -.. people like things that are quick and easy - - -What makes Automat different? -============================= -There are `dozens of libraries on PyPI implementing state machines -`_. -So it behooves me to say why yet another one would be a good idea. - -Automat is designed around this principle: -while organizing your code around state machines is a good idea, -your callers don't, and shouldn't have to, care that you've done so. -In Python, the "input" to a stateful system is a method call; -the "output" may be a method call, if you need to invoke a side effect, -or a return value, if you are just performing a computation in memory. -Most other state-machine libraries require you to explicitly create an input object, -provide that object to a generic "input" method, and then receive results, -sometimes in terms of that library's interfaces and sometimes in terms of -classes you define yourself. - -For example, a snippet of the coffee-machine example above might be implemented -as follows in naive Python: - - -.. code-block:: python - - class CoffeeMachine(object): - def brew_button(self): - if self.has_water and self.has_beans and not self.is_lid_open: - self.heat_the_heating_element() - # ... - - -With Automat, you'd create a class with a :py:class:`automat.MethodicalMachine` attribute: - - -.. code-block:: python - - from automat import MethodicalMachine - - class CoffeeBrewer(object): - _machine = MethodicalMachine() - - -and then you would break the above logic into two pieces - the `brew_button` -*input*, declared like so: - - -.. code-block:: python - - class CoffeeBrewer(object): - _machine = MethodicalMachine() - - @_machine.input() - def brew_button(self): - "The user pressed the 'brew' button." - - -It wouldn't do any good to declare a method *body* on this, however, -because input methods don't actually execute their bodies when called; -doing actual work is the *output*'s job: - - -.. code-block:: python - - class CoffeeBrewer(object): - _machine = MethodicalMachine() - - # ... - - @_machine.output() - def _heat_the_heating_element(self): - "Heat up the heating element, which should cause coffee to happen." - self._heating_element.turn_on() - - -As well as a couple of *states* - and for simplicity's sake let's say that the -only two states are `have_beans` and `dont_have_beans`: - - -.. code-block:: python - - class CoffeeBrewer(object): - _machine = MethodicalMachine() - - # ... - - @_machine.state() - def have_beans(self): - "In this state, you have some beans." - - @_machine.state(initial=True) - def dont_have_beans(self): - "In this state, you don't have any beans." - - -`dont_have_beans` is the `initial` state -because `CoffeeBrewer` starts without beans in it. - -(And another input to put some beans in:) - -.. code-block:: python - - class CoffeeBrewer(object): - _machine = MethodicalMachine() - - # ... - - @_machine.input() - def put_in_beans(self): - "The user put in some beans." - - -Finally, you hook everything together with the :py:meth:`.upon` method -of the functions decorated with `_machine.state`: - -.. code-block:: python - - class CoffeeBrewer(object): - _machine = MethodicalMachine() - - # ... - - # When we don't have beans, upon putting in beans, we will then have beans - # (and produce no output) - dont_have_beans.upon(put_in_beans, enter=have_beans, outputs=[]) - - # When we have beans, upon pressing the brew button, we will then not have - # beans any more (as they have been entered into the brewing chamber) and - # our output will be heating the heating element. - have_beans.upon(brew_button, enter=dont_have_beans, - outputs=[_heat_the_heating_element]) - - -To *users* of this coffee machine class though, it still looks like a POPO -(Plain Old Python Object): - - ->>> coffee_machine = CoffeeMachine() ->>> coffee_machine.put_in_beans() ->>> coffee_machine.brew_button() - - -All of the *inputs* are provided by calling them like methods, -all of the *outputs* are automatically invoked when they are produced -according to the outputs specified to :py:meth:`automat.MethodicalState.upon` -and all of the states are simply opaque tokens - -although the fact that they're defined as methods like inputs and outputs -allows you to put docstrings on them easily to document them. - - -How do I get the current state of a state machine? -================================================== - -Don't do that. - -One major reason for having a state machine is that you want the callers of the -state machine to just provide the appropriate input to the machine at the -appropriate time, and *not have to check themselves* what state the machine is -in. So if you are tempted to write some code like this: - - -.. code-block:: python - - if connection_state_machine.state == "CONNECTED": - connection_state_machine.send_message() - else: - print("not connected") - - -Instead, just make your calling code do this: - - -.. code-block:: python - - connection_state_machine.send_message() - - -and then change your state machine to look like this: - - -.. code-block:: python - - class CoffeeBrewer(object): - _machine = MethodicalMachine() - - # ... - - @_machine.state() - def connected(self): - "connected" - @_machine.state() - def not_connected(self): - "not connected" - @_machine.input() - def send_message(self): - "send a message" - @_machine.output() - def _actually_send_message(self): - self._transport.send(b"message") - @_machine.output() - def _report_sending_failure(self): - print("not connected") - connected.upon(send_message, enter=connected, [_actually_send_message]) - not_connected.upon(send_message, enter=not_connected, [_report_sending_failure]) - - -so that the responsibility for knowing which state the state machine is in -remains within the state machine itself. - -Input for Inputs and Output for Outputs -======================================= - -Quite often you want to be able to pass parameters to your methods, -as well as inspecting their results. -For example, when you brew the coffee, -you might expect a cup of coffee to result, -and you would like to see what kind of coffee it is. -And if you were to put delicious hand-roasted small-batch artisanal -beans into the machine, you would expect a *better* cup of coffee -than if you were to use mass-produced beans. -You would do this in plain old Python by adding a parameter, -so that's how you do it in Automat as well. - - -.. code-block:: python - - class CoffeeBrewer(object): - _machine = MethodicalMachine() - - # ... - - @_machine.input() - def put_in_beans(self, beans): - "The user put in some beans." - - -However, one important difference here is that -*we can't add any implementation code to the input method*. -Inputs are purely a declaration of the interface; -the behavior must all come from outputs. -Therefore, the change in the state of the coffee machine -must be represented as an output. -We can add an output method like this: - - -.. code-block:: python - - class CoffeeBrewer(object): - _machine = MethodicalMachine() - - # ... - - @_machine.output() - def _save_beans(self, beans): - "The beans are now in the machine; save them." - self._beans = beans - - -and then connect it to the `put_in_beans` by changing the transition from -`dont_have_beans` to `have_beans` like so: - - -.. code-block:: python - - class CoffeeBrewer(object): - _machine = MethodicalMachine() - - # ... - - dont_have_beans.upon(put_in_beans, enter=have_beans, - outputs=[_save_beans]) - - -Now, when you call: - - -.. code-block:: python - - coffee_machine.put_in_beans("real good beans") - - -the machine will remember the beans for later. - -So how do we get the beans back out again? -One of our outputs needs to have a return value. -It would make sense if our `brew_button` method -returned the cup of coffee that it made, so we should add an output. -So, in addition to heating the heating element, -let's add a return value that describes the coffee. -First a new output: - - -.. code-block:: python - - class CoffeeBrewer(object): - _machine = MethodicalMachine() - - # ... - - @_machine.output() - def _describe_coffee(self): - return "A cup of coffee made with {}.".format(self._beans) - - -Note that we don't need to check first whether `self._beans` exists or not, -because we can only reach this output method if the state machine says we've -gone through a set of states that sets this attribute. - -Now, we need to hook up `_describe_coffee` to the process of brewing, -so change the brewing transition to: - - -.. code-block:: python - - class CoffeeBrewer(object): - _machine = MethodicalMachine() - - # ... - - have_beans.upon(brew_button, enter=dont_have_beans, - outputs=[_heat_the_heating_element, - _describe_coffee]) - - -Now, we can call it: - - ->>> coffee_machine.brew_button() -[None, 'A cup of coffee made with real good beans.'] - - -Except... wait a second, what's that `None` doing there? - -Since every input can produce multiple outputs, in automat, -the default return value from every input invocation is a `list`. -In this case, we have both `_heat_the_heating_element` -and `_describe_coffee` outputs, so we're seeing both of their return values. -However, this can be customized, with the `collector` argument to :py:meth:`.upon`; -the `collector` is a callable which takes an iterable of all the outputs' -return values and "collects" a single return value -to return to the caller of the state machine. - -In this case, we only care about the last output, -so we can adjust the call to :py:meth:`.upon` like this: - -.. code-block:: python - - class CoffeeBrewer(object): - _machine = MethodicalMachine() - - # ... - - have_beans.upon(brew_button, enter=dont_have_beans, - outputs=[_heat_the_heating_element, - _describe_coffee], - collector=lambda iterable: list(iterable)[-1] - ) - - -And now, we'll get just the return value we want: - - ->>> coffee_machine.brew_button() -'A cup of coffee made with real good beans.' - - -If I can't get the state of the state machine, how can I save it to (a database, an API response, a file on disk...) -==================================================================================================================== -There are APIs for serializing the state machine. - -First, you have to decide on a persistent representation of each state, -via the `serialized=` argument to the `MethodicalMachine.state()` decorator. - -Let's take this very simple "light switch" state machine, -which can be on or off, and flipped to reverse its state: - - -.. code-block:: python - - class LightSwitch(object): - _machine = MethodicalMachine() - - @_machine.state(serialized="on") - def on_state(self): - "the switch is on" - - @_machine.state(serialized="off", initial=True) - def off_state(self): - "the switch is off" - - @_machine.input() - def flip(self): - "flip the switch" - - on_state.upon(flip, enter=off_state, outputs=[]) - off_state.upon(flip, enter=on_state, outputs=[]) - - -In this case, we've chosen a serialized representation for each state -via the `serialized` argument. -The on state is represented by the string `"on"`, -and the off state is represented by the string `"off"`. - -Now, let's just add an input that lets us tell if the switch is on or not. - - -.. code-block:: python - - from operator import itemgetter - - first = itemgetter(0) - - class LightSwitch(object): - _machine = MethodicalMachine() - - # ... - - @_machine.input() - def query_power(self): - "return True if powered, False otherwise" - - @_machine.output() - def _is_powered(self): - return True - - @_machine.output() - def _not_powered(self): - return False - - on_state.upon( - query_power, enter=on_state, outputs=[_is_powered], collector=first - ) - off_state.upon( - query_power, enter=off_state, outputs=[_not_powered], collector=first - ) - - -To save the state, we have the `MethodicalMachine.serializer()` method. -A method decorated with `@serializer()` gets an extra argument injected -at the beginning of its argument list: the serialized identifier for the state. -In this case, either `"on"` or `"off"`. -Since state machine output methods can also affect other state on the object, -a serializer method is expected to return *all* relevant state for serialization. - -For our simple light switch, such a method might look like this: - -.. code-block:: python - - class LightSwitch(object): - _machine = MethodicalMachine() - - # ... - - @_machine.serializer() - def save(self, state): - return {"is-it-on": state} - - -Serializers can be public methods, and they can return whatever you like. -If necessary, you can have different serializers - -just multiple methods decorated with `@_machine.serializer()` - -for different formats; -return one data-structure for JSON, one for XML, one for a database row, and so on. - -When it comes time to unserialize, though, you generally want a private method, -because an unserializer has to take a not-fully-initialized instance -and populate it with state. -It is expected to *return* the serialized machine state token -that was passed to the serializer, but it can take whatever arguments you like. -Of course, in order to return that, -it probably has to take it somewhere in its arguments, -so it will generally take whatever a paired serializer has returned as an argument. - -So our unserializer would look like this: - - -.. code-block:: python - - class LightSwitch(object): - _machine = MethodicalMachine() - - # ... - - @_machine.unserializer() - def _restore(self, blob): - return blob["is-it-on"] - - -Generally you will want a classmethod deserialization constructor -which you write yourself to call this, -so that you know how to create an instance of your own object, like so: - - -.. code-block:: python - - class LightSwitch(object): - _machine = MethodicalMachine() - - # ... - - @classmethod - def from_blob(cls, blob): - self = cls() - self._restore(blob) - return self - - -Saving and loading our `LightSwitch` -along with its state-machine state can now be accomplished as follows: - - ->>> switch1 = LightSwitch() ->>> switch1.query_power() -False ->>> switch1.flip() -[] ->>> switch1.query_power() -True ->>> blob = switch1.save() ->>> switch2 = LightSwitch.from_blob(blob) ->>> switch2.query_power() -True - - -More comprehensive (tested, working) examples are present in `docs/examples`. - -Go forth and machine all the state! diff --git a/docs/api.rst b/docs/api.rst deleted file mode 100644 index 5a6d51e..0000000 --- a/docs/api.rst +++ /dev/null @@ -1,11 +0,0 @@ -======== -API Docs -======== - -.. automodule:: automat - -.. autoclass:: automat.MethodicalMachine - :members: input, output, state - - -.. automethod:: automat._methodical.MethodicalState.upon diff --git a/docs/api/index.rst b/docs/api/index.rst new file mode 100644 index 0000000..d54fb0c --- /dev/null +++ b/docs/api/index.rst @@ -0,0 +1,4 @@ +API Reference +============= + +This will be overwritten by the pydoctor build. diff --git a/docs/compare.rst b/docs/compare.rst new file mode 100644 index 0000000..f9c02a9 --- /dev/null +++ b/docs/compare.rst @@ -0,0 +1,21 @@ +What makes Automat different? +============================= +There are `dozens of libraries on PyPI implementing state machines +`_. +So it behooves me to say why yet another one would be a good idea. + +Automat is designed around the following principle: +while organizing your code around state machines is a good idea, +your callers don't, and shouldn't have to, care that you've done so. + +In Python, the "input" to a stateful system is a method call; +the "output" may be a method call, if you need to invoke a side effect, +or a return value, if you are just performing a computation in memory. +Most other state-machine libraries require you to explicitly create an input object, +provide that object to a generic "input" method, and then receive results, +sometimes in terms of that library's interfaces and sometimes in terms of +classes you define yourself. + +Therefore, from the outside, an Automat state machine looks like a Plain Old +Python Object (POPO). It has methods, and the methods have type annotations, +and you can call them and get their documented return values. diff --git a/docs/conf.py b/docs/conf.py index 953ae4b..b94be95 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,6 +19,7 @@ # import os import sys + docs_dir = os.path.dirname(os.path.abspath(__file__)) automat_dir = os.path.dirname(docs_dir) sys.path.insert(0, automat_dir) @@ -33,60 +34,113 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ['sphinx.ext.autodoc'] +extensions = [ + "sphinx.ext.intersphinx", + "pydoctor.sphinx_ext.build_apidocs", + "sphinx.ext.autosectionlabel", +] +import pathlib +import subprocess +_project_root = pathlib.Path(__file__).parent.parent +_source_root = _project_root + +_git_reference = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + text=True, + encoding="utf8", + capture_output=True, + check=True, +).stdout + + +# Try to find URL fragment for the GitHub source page based on current +# branch or tag. + +if _git_reference == "HEAD": + # It looks like the branch has no name. + # Fallback to commit ID. + _git_reference = subprocess.getoutput("git rev-parse HEAD") + +if os.environ.get("READTHEDOCS", "") == "True": + rtd_version = os.environ.get("READTHEDOCS_VERSION", "") + if "." in rtd_version: + # It looks like we have a tag build. + _git_reference = rtd_version + +pydoctor_args = [ + # pydoctor should not fail the sphinx build, we have another tox environment for that. + "--intersphinx=https://docs.twisted.org/en/twisted-22.1.0/api/objects.inv", + "--intersphinx=https://docs.python.org/3/objects.inv", + "--intersphinx=https://graphviz.readthedocs.io/en/stable/objects.inv", + "--intersphinx=https://zopeinterface.readthedocs.io/en/latest/objects.inv", + # TODO: not sure why I have to specify these all twice. + f"--config={_project_root}/.pydoctor.cfg", + f"--html-viewsource-base=https://github.com/glyph/automat/tree/{_git_reference}/src", + f"--project-base-dir={_source_root}", + "--html-output={outdir}/api", + "--privacy=HIDDEN:automat.test.*", + "--privacy=HIDDEN:automat.test", + "--privacy=HIDDEN:**.__post_init__", + str(_source_root / "automat"), +] +pydoctor_url_path = "/en/{rtd_version}/api/" +intersphinx_mapping = { + "py3": ("https://docs.python.org/3", None), + "zopeinterface": ("https://zopeinterface.readthedocs.io/en/latest", None), + "twisted": ("https://docs.twisted.org/en/twisted-22.1.0/api", None), +} # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = ".rst" # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = 'automat' -copyright = '2017, Glyph' -author = 'Glyph' +project = "automat" +copyright = "2017, Glyph" +author = "Glyph" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. -def _get_release(): - import pkg_resources + +def _get_release() -> str: + import importlib.metadata + try: - distribution = pkg_resources.get_distribution(project) - except pkg_resources.DistributionNotFound: - raise Exception( - "You must install Automat to build the documentation." - ) - else: - return distribution.version + return importlib.metadata.version(project) + except importlib.metadata.PackageNotFoundError: + raise Exception("You must install Automat to build the documentation.") + # The full version, including alpha/beta/rc tags. release = _get_release() # The short X.Y version. -version = '.'.join(release.split('.')[:2]) +version = ".".join(release.split(".")[:2]) # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False @@ -97,7 +151,7 @@ def _get_release(): # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'sphinx_rtd_theme' +html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -108,7 +162,7 @@ def _get_release(): # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # Custom sidebar templates, must be a dictionary that maps document names # to template names. @@ -116,12 +170,12 @@ def _get_release(): # This is required for the alabaster theme # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars html_sidebars = { - '**': [ - 'about.html', - 'navigation.html', - 'relations.html', # needs 'show_related': True theme option to display - 'searchbox.html', - 'donate.html', + "**": [ + "about.html", + "navigation.html", + "relations.html", # needs 'show_related': True theme option to display + "searchbox.html", + "donate.html", ] } @@ -129,24 +183,21 @@ def _get_release(): # -- Options for HTMLHelp output ------------------------------------------ # Output file base name for HTML help builder. -htmlhelp_basename = 'automatdoc' +htmlhelp_basename = "automatdoc" # -- Options for LaTeX output --------------------------------------------- -latex_elements = { +latex_elements: dict[str, str] = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. # # 'preamble': '', - # Latex figure (float) alignment # # 'figure_align': 'htbp', @@ -156,8 +207,7 @@ def _get_release(): # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'automat.tex', 'automat Documentation', - 'Glyph', 'manual'), + (master_doc, "automat.tex", "automat Documentation", "Glyph", "manual"), ] @@ -165,10 +215,7 @@ def _get_release(): # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'automat', 'automat Documentation', - [author], 1) -] +man_pages = [(master_doc, "automat", "automat Documentation", [author], 1)] # -- Options for Texinfo output ------------------------------------------- @@ -177,10 +224,13 @@ def _get_release(): # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'automat', 'automat Documentation', - author, 'automat', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + "automat", + "automat Documentation", + author, + "automat", + "One line description of project.", + "Miscellaneous", + ), ] - - - diff --git a/docs/debugging.rst b/docs/debugging.rst deleted file mode 100644 index 81b8ec3..0000000 --- a/docs/debugging.rst +++ /dev/null @@ -1,173 +0,0 @@ -========= -Debugging -========= - - -Tracing API -=========== - -.. warning:: - - The Tracing API is currently private and unstable. - Use it for local debugging, - but if you think you need to commit code that references it, - you should either pin your dependency on the current version of Automat, - or at least be prepared for your application to break - when this API is changed or removed. - - -The tracing API lets you assign a callback function -that will be invoked each time an input event -causes the state machine to move from one state to another. -This can help you figure out problems caused by events -occurring in the wrong order, or not happening at all. -Your callback function can print a message to stdout, -write something to a logfile, -or deliver the information in any application-specific way you like. -The only restriction is that the function must not touch the state machine at all. - -To prepare the state machine for tracing, -you must assign a name to the "_setTrace" method in your class. -In this example, we use `setTheTracingFunction`, -but the name can be anything you like: - - -.. code-block:: python - - class Sample(object): - mm = MethodicalMachine() - - @mm.state(initial=True) - def begin(self): - "initial state" - - @mm.state() - def end(self): - "end state" - - @mm.input() - def go(self): - "event that moves us from begin to end" - - @mm.output() - def doThing1(self): - "first thing to do" - - @mm.output() - def doThing2(self): - "second thing to do" - - setTheTracingFunction = mm._setTrace - - begin.upon(go, enter=end, outputs=[doThing1, doThing2]) - - -Later, after you instantiate the `Sample` object, -you can set the tracing callback for that particular instance -by calling the `setTheTracingFunction()` method on it: - - -.. code-block:: python - - s = Sample() - def tracer(oldState, input, newState): - pass - s.setTheTracingFunction(tracer) - - -Note that you cannot shortcut the name-assignment step: -`s.mm._setTrace(tracer)` will not work, -because Automat goes to great lengths to hide that `mm` object from external access. -And you cannot set the tracing function at class-definition time -(e.g. a class-level `mm._setTrace(tracer)`) -because the state machine has merely been *defined* at that point, not instantiated -(you might eventually have multiple instances of the Sample class, -each with their own independent state machine), -and each one can be traced separately. - -Since this is a private API, -consider using a tolerant `getattr` when retrieving the `_getTrace` method. -This way, if you do commit code which references it, -but you only *call* that code during debugging, -then at least your application or tests won't crash -when the API is removed entirely: - - -.. code-block:: python - - mm = MethodicalMachine() - setTheTracingFunction = getattr(mm, "_setTrace", lambda self, f: None) - - -The Tracer Callback Function -============================ - -When the input event is received, before any transitions are made, -the tracer function is called with three positional arguments: - -* `oldState`: a string with the name of the current state -* `input`: a string with the name of the input event -* `newState`: a string with the name of the new state - -If your tracer function returns None, -then you will only be notified about the input events. -But, if your tracer function returns a callable, -then just before each output function is executed (if any), -that callable will be executed with a single `output` argument (as a string). - -So if you only care about the transitions, your tracing function can just do: - - ->>> s = Sample() ->>> def tracer(oldState, input, newState): -... print("%s.%s -> %s" % (oldState, input, newState)) ->>> s.setTheTracingFunction(tracer) ->>> s.go() -begin.go -> end - - -But if you want to know when each output is invoked -(perhaps to compare against other log messages -emitted from inside those output functions), -you can do: - - ->>> s = Sample() ->>> def tracer(oldState, input, newState): ->>> def traceOutputs(output): -... print("%s.%s -> %s: %s()" % (oldState, input, newState, output)) -... print("%s.%s -> %s" % (oldState, input, newState)) -... return traceOutputs ->>> s.setTheTracingFunction(tracer) ->>> s.go() -begin.go -> end -begin.go -> end: doThing1() -begin.go -> end: doThing2() - - -Tracing Multiple State Machines -=============================== - -If you have multiple state machines in your application, -you will probably want to pass a different tracing function to each, -so your logs can distinguish between the transitions -of MachineFoo vs those of MachineBar. -This is particularly important if your application involves network communication, -where an instance of MachineFoo (e.g. in a client) -is in communication with a sibling instance of MachineFoo (in a server). -When exercising both sides of this connection in a single process, -perhaps in an automated test, -you will need to clearly mark the first as "foo1" -and the second as "foo2" to avoid confusion. - - -.. code-block:: python - - s1 = Sample() - s2 = Sample() - def tracer1(oldState, input, newState): - print("S1: %s.%s -> %s" % (oldState, input, newState)) - s1.setTheTracingFunction(tracer1) - def tracer2(oldState, input, newState): - print("S2: %s.%s -> %s" % (oldState, input, newState)) - s2.setTheTracingFunction(tracer2) diff --git a/docs/examples/automat_card.py b/docs/examples/automat_card.py new file mode 100644 index 0000000..4fea14c --- /dev/null +++ b/docs/examples/automat_card.py @@ -0,0 +1,116 @@ +from dataclasses import dataclass, field +from typing import Protocol + +from automat import TypeMachineBuilder + + +@dataclass +class PaymentBackend: + accounts: dict[str, int] = field(default_factory=dict) + + def checkBalance(self, accountID: str) -> int: + "how many AutoBux™ do you have" + return self.accounts[accountID] + + def deduct(self, accountID: str, amount: int) -> None: + "deduct some amount of money from the given account" + balance = self.accounts[accountID] + newBalance = balance - amount + if newBalance < 0: + raise ValueError("not enough money") + self.accounts[accountID] = newBalance + + +@dataclass +class Food: + name: str + price: int + doorNumber: int + + +class Doors: + def openDoor(self, number: int) -> None: + print(f"opening door {number}") + + +class Automat(Protocol): + def swipeCard(self, accountID: str) -> None: + "Swipe a payment card with the given account ID." + + def selectFood(self, doorNumber: int) -> None: + "Select a food." + + def _dispenseFood(self, doorNumber: int) -> None: + "Open a door and dispense the food." + + +@dataclass +class AutomatCore: + payments: PaymentBackend + foods: dict[int, Food] # mapping door-number to food + doors: Doors + + +@dataclass +class PaymentDetails: + accountID: str + + +def rememberAccount( + inputs: Automat, core: AutomatCore, accountID: str +) -> PaymentDetails: + print(f"remembering {accountID=}") + return PaymentDetails(accountID) + + +# define machine +builder = TypeMachineBuilder(Automat, AutomatCore) + +idle = builder.state("idle") +choosing = builder.state("choosing", rememberAccount) + +idle.upon(Automat.swipeCard).to(choosing).returns(None) +# end define + + +@choosing.upon(Automat.selectFood).loop() +def selected( + inputs: Automat, core: AutomatCore, details: PaymentDetails, doorNumber: int +) -> None: + food = core.foods[doorNumber] + try: + core.payments.deduct(details.accountID, core.foods[doorNumber].price) + except ValueError as ve: + print(ve) + else: + inputs._dispenseFood(doorNumber) + + +@choosing.upon(Automat._dispenseFood).to(idle) +def doOpen( + inputs: Automat, core: AutomatCore, details: PaymentDetails, doorNumber: int +) -> None: + core.doors.openDoor(doorNumber) + + +machineFactory = builder.build() + +if __name__ == "__main__": + machine = machineFactory( + AutomatCore( + PaymentBackend({"alice": 100}), + { + 1: Food("burger", 5, 1), + 2: Food("fries", 3, 2), + 3: Food("pheasant under glass", 200, 3), + }, + Doors(), + ) + ) + machine.swipeCard("alice") + print("too expensive") + machine.selectFood(3) + print("just right") + machine.selectFood(1) + print("oops") + machine.selectFood(2) diff --git a/docs/examples/coffee_expanded.py b/docs/examples/coffee_expanded.py new file mode 100644 index 0000000..f4570d9 --- /dev/null +++ b/docs/examples/coffee_expanded.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Protocol + +from automat import TypeMachineBuilder + + +@dataclass +class Beans: + description: str + + +@dataclass +class Water: + "It's Water" + + +@dataclass +class Carafe: + "It's a carafe" + full: bool = False + + +@dataclass +class Ready: + beans: Beans + water: Water + carafe: Carafe + + def brew(self) -> Mixture: + print(f"brewing {self.beans} with {self.water} in {self.carafe}") + return Mixture(self.beans, self.water) + + +@dataclass +class Mixture: + beans: Beans + water: Water + + +class Brewer(Protocol): + def brew_button(self) -> None: + "The user pressed the 'brew' button." + + def wait_a_while(self) -> Mixture: + "Allow some time to pass." + + def put_in_beans(self, beans: Beans) -> None: + "The user put in some beans." + + def put_in_water(self, water: Water) -> None: + "The user put in some water." + + def put_in_carafe(self, carafe: Carafe) -> None: + "The user put the mug" + + +class _BrewerInternals(Brewer, Protocol): + def _ready(self, beans: Beans, water: Water, carafe: Carafe) -> None: + "We are ready with all of our inputs." + + +@dataclass +class Light: + on: bool = False + + +@dataclass +class BrewCore: + "state for the brew process" + ready_light: Light + brew_light: Light + beans: Beans | None = None + water: Water | None = None + carafe: Carafe | None = None + brewing: Mixture | None = None + + +def _coffee_machine() -> TypeMachineBuilder[_BrewerInternals, BrewCore]: + """ + Best practice: these functions are all fed in to the builder, they don't + need to call each other, so they don't need to be defined globally. Use a + function scope to avoid littering a module with states and such. + """ + builder = TypeMachineBuilder(_BrewerInternals, BrewCore) + # reveal_type(builder) + + not_ready = builder.state("not_ready") + + def ready_factory( + brewer: _BrewerInternals, + core: BrewCore, + beans: Beans, + water: Water, + carafe: Carafe, + ) -> Ready: + return Ready(beans, water, carafe) + + def mixture_factory(brewer: _BrewerInternals, core: BrewCore) -> Mixture: + # We already do have a 'ready' but it's State-Specific Data which makes + # it really annoying to relay on to the *next* state without passing it + # through the state core. requiring the factory to take SSD inherently + # means that it could only work with transitions away from a single + # state, which would not be helpful, although that *is* what we want + # here. + + assert core.beans is not None + assert core.water is not None + assert core.carafe is not None + + return Mixture(core.beans, core.water) + + ready = builder.state("ready", ready_factory) + brewing = builder.state("brewing", mixture_factory) + + def ready_check(brewer: _BrewerInternals, core: BrewCore) -> None: + if ( + core.beans is not None + and core.water is not None + and core.carafe is not None + and core.carafe.full is not None + ): + brewer._ready(core.beans, core.water, core.carafe) + + @not_ready.upon(Brewer.put_in_beans).loop() + def put_beans(brewer: _BrewerInternals, core: BrewCore, beans: Beans) -> None: + core.beans = beans + ready_check(brewer, core) + + @not_ready.upon(Brewer.put_in_water).loop() + def put_water(brewer: _BrewerInternals, core: BrewCore, water: Water) -> None: + core.water = water + ready_check(brewer, core) + + @not_ready.upon(Brewer.put_in_carafe).loop() + def put_carafe(brewer: _BrewerInternals, core: BrewCore, carafe: Carafe) -> None: + core.carafe = carafe + ready_check(brewer, core) + + @not_ready.upon(_BrewerInternals._ready).to(ready) + def get_ready( + brewer: _BrewerInternals, + core: BrewCore, + beans: Beans, + water: Water, + carafe: Carafe, + ) -> None: + print("ready output") + + @ready.upon(Brewer.brew_button).to(brewing) + def brew(brewer: _BrewerInternals, core: BrewCore, ready: Ready) -> None: + core.brew_light.on = True + print("BREW CALLED") + core.brewing = ready.brew() + + @brewing.upon(_BrewerInternals.wait_a_while).to(not_ready) + def brewed(brewer: _BrewerInternals, core: BrewCore, mixture: Mixture) -> Mixture: + core.brew_light.on = False + return mixture + + return builder + + +CoffeeMachine: Callable[[BrewCore], Brewer] = _coffee_machine().build() + +if __name__ == "__main__": + machine = CoffeeMachine(core := BrewCore(Light(), Light())) + machine.put_in_beans(Beans("light roast")) + machine.put_in_water(Water()) + machine.put_in_carafe(Carafe()) + machine.brew_button() + brewed = machine.wait_a_while() + print(brewed) diff --git a/docs/examples/dont_get_state.py b/docs/examples/dont_get_state.py new file mode 100644 index 0000000..0ff4dfc --- /dev/null +++ b/docs/examples/dont_get_state.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass +from typing import Protocol + +from automat import TypeMachineBuilder + + +class Transport: + def send(self, arg: bytes) -> None: + print(f"sent: {arg!r}") + + +# begin salient +class Connector(Protocol): + def sendMessage(self) -> None: + "send a message" + + +@dataclass +class Core: + _transport: Transport + + +builder = TypeMachineBuilder(Connector, Core) +disconnected = builder.state("disconnected") +connected = builder.state("connector") + + +@connected.upon(Connector.sendMessage).loop() +def actuallySend(connector: Connector, core: Core) -> None: + core._transport.send(b"message") + + +@disconnected.upon(Connector.sendMessage).loop() +def failSend(connector: Connector, core: Core): + print("not connected") + # end salient + + +machineFactory = builder.build() +machine = machineFactory(Core(Transport())) +machine.sendMessage() diff --git a/docs/examples/feedback_debugging.py b/docs/examples/feedback_debugging.py new file mode 100644 index 0000000..35ab174 --- /dev/null +++ b/docs/examples/feedback_debugging.py @@ -0,0 +1,30 @@ +import dataclasses +import typing + +from automat import TypeMachineBuilder + + +class Inputs(typing.Protocol): + def behavior1(self) -> None: ... + def behavior2(self) -> None: ... +class Nothing: ... + + +builder = TypeMachineBuilder(Inputs, Nothing) +start = builder.state("start") + + +@start.upon(Inputs.behavior1).loop() +def one(inputs: Inputs, core: Nothing) -> None: + print("starting behavior 1") + inputs.behavior2() + print("ending behavior 1") + + +@start.upon(Inputs.behavior2).loop() +def two(inputs: Inputs, core: Nothing) -> None: + print("behavior 2") + + +machineFactory = builder.build() +machineFactory(Nothing()).behavior1() diff --git a/docs/examples/feedback_errors.py b/docs/examples/feedback_errors.py new file mode 100644 index 0000000..01c42ef --- /dev/null +++ b/docs/examples/feedback_errors.py @@ -0,0 +1,28 @@ +import dataclasses +import typing + +from automat import TypeMachineBuilder + +#begin +class Inputs(typing.Protocol): + def compute(self) -> int: ... + def behavior(self) -> None: ... +class Nothing: ... + + +builder = TypeMachineBuilder(Inputs, Nothing) +start = builder.state("start") + + +@start.upon(Inputs.compute).loop() +def three(inputs: Inputs, core: Nothing) -> int: + return 3 + + +@start.upon(Inputs.behavior).loop() +def behave(inputs: Inputs, core: Nothing) -> None: + print("computed:", inputs.compute()) +#end + +machineFactory = builder.build() +machineFactory(Nothing()).behavior() diff --git a/docs/examples/feedback_order.py b/docs/examples/feedback_order.py new file mode 100644 index 0000000..f6557f5 --- /dev/null +++ b/docs/examples/feedback_order.py @@ -0,0 +1,34 @@ +import dataclasses +import typing + +from automat import TypeMachineBuilder + + +class Inputs(typing.Protocol): + def compute(self) -> int: ... + def behavior(self) -> None: ... +class Nothing: ... + + +builder = TypeMachineBuilder(Inputs, Nothing) +start = builder.state("start") + + +@start.upon(Inputs.compute).loop() +def three(inputs: Inputs, core: Nothing) -> int: + return 3 + + +# begin computations +computations = [] + + +@start.upon(Inputs.behavior).loop() +def behave(inputs: Inputs, core: Nothing) -> None: + computations.append(inputs.compute) + + +machineFactory = builder.build() +machineFactory(Nothing()).behavior() +print(computations[0]()) +# end computations diff --git a/docs/examples/garage_door.py b/docs/examples/garage_door.py new file mode 100644 index 0000000..1805b57 --- /dev/null +++ b/docs/examples/garage_door.py @@ -0,0 +1,122 @@ +import dataclasses +import typing +from enum import Enum, auto + +from automat import NoTransition, TypeMachineBuilder + + +class Direction(Enum): + up = auto() + stopped = auto() + down = auto() + + +@dataclasses.dataclass +class Motor: + direction: Direction = Direction.stopped + + def up(self) -> None: + assert self.direction is Direction.stopped + self.direction = Direction.up + print("motor running up") + + def stop(self) -> None: + self.direction = Direction.stopped + print("motor stopped") + + def down(self) -> None: + assert self.direction is Direction.stopped + self.direction = Direction.down + print("motor running down") + + +@dataclasses.dataclass +class Alarm: + def beep(self) -> None: + "Sound an alarm so that the user can hear." + print("beep beep beep") + + +# protocol definition +class GarageController(typing.Protocol): + def pushButton(self) -> None: + "Push the button to open or close the door" + + def openSensor(self) -> None: + "The 'open' sensor activated; the door is fully open." + + def closeSensor(self) -> None: + "The 'close' sensor activated; the door is fully closed." + + +# end protocol definition +# core definition +@dataclasses.dataclass +class DoorDevices: + motor: Motor + alarm: Alarm + + +"end core definition" + +# end core definition + +# start building +builder = TypeMachineBuilder(GarageController, DoorDevices) +# build states +closed = builder.state("closed") +opening = builder.state("opening") +opened = builder.state("opened") +closing = builder.state("closing") +# end states + + +# build methods +@closed.upon(GarageController.pushButton).to(opening) +def startOpening(controller: GarageController, devices: DoorDevices) -> None: + devices.motor.up() + + +@opening.upon(GarageController.openSensor).to(opened) +def finishedOpening(controller: GarageController, devices: DoorDevices): + devices.motor.stop() + + +@opened.upon(GarageController.pushButton).to(closing) +def startClosing(controller: GarageController, devices: DoorDevices) -> None: + devices.alarm.beep() + devices.motor.down() + + +@closing.upon(GarageController.closeSensor).to(closed) +def finishedClosing(controller: GarageController, devices: DoorDevices): + devices.motor.stop() + # end methods + + +# do build +machineFactory = builder.build() +# end building +# story +if __name__ == "__main__": + # do instantiate + machine = machineFactory(DoorDevices(Motor(), Alarm())) + # end instantiate + print("pushing button...") + # do open + machine.pushButton() + # end open + print("pushedW") + try: + machine.pushButton() + except NoTransition: + print("this is not implemented yet") + print("triggering open sensor, pushing button again") + # sensor and close + machine.openSensor() + machine.pushButton() + # end close + print("pushed") + machine.closeSensor() + +# end story diff --git a/docs/examples/garage_door_security.py b/docs/examples/garage_door_security.py new file mode 100644 index 0000000..2d679fe --- /dev/null +++ b/docs/examples/garage_door_security.py @@ -0,0 +1,124 @@ +import dataclasses +import typing +from enum import Enum, auto + +from automat import NoTransition, TypeMachineBuilder + + +class Direction(Enum): + up = auto() + stopped = auto() + down = auto() + + +@dataclasses.dataclass +class Motor: + direction: Direction = Direction.stopped + + def up(self) -> None: + assert self.direction is Direction.stopped + self.direction = Direction.up + print("motor running up") + + def stop(self) -> None: + self.direction = Direction.stopped + print("motor stopped") + + def down(self) -> None: + assert self.direction is Direction.stopped + self.direction = Direction.down + print("motor running down") + + +@dataclasses.dataclass +class Alarm: + def beep(self) -> None: + "Sound an alarm so that the user can hear." + print("beep beep beep") + + +# protocol definition +class GarageController(typing.Protocol): + def pushButton(self, remoteID: str) -> None: + "Push the button to open or close the door" + + def openSensor(self) -> None: + "The 'open' sensor activated; the door is fully open." + + def closeSensor(self) -> None: + "The 'close' sensor activated; the door is fully closed." + + +# end protocol definition +# core definition +@dataclasses.dataclass +class DoorDevices: + motor: Motor + alarm: Alarm + + +"end core definition" + +# end core definition + +# start building +builder = TypeMachineBuilder(GarageController, DoorDevices) +# build states +closed = builder.state("closed") +opening = builder.state("opening") +opened = builder.state("opened") +closing = builder.state("closing") +# end states + + +# build methods +@closed.upon(GarageController.pushButton).to(opening) +def startOpening(controller: GarageController, devices: DoorDevices, remoteID: str) -> None: + print(f"opened by {remoteID}") + devices.motor.up() + + +@opening.upon(GarageController.openSensor).to(opened) +def finishedOpening(controller: GarageController, devices: DoorDevices): + devices.motor.stop() + + +@opened.upon(GarageController.pushButton).to(closing) +def startClosing(controller: GarageController, devices: DoorDevices, remoteID: str) -> None: + print(f"closed by {remoteID}") + devices.alarm.beep() + devices.motor.down() + + +@closing.upon(GarageController.closeSensor).to(closed) +def finishedClosing(controller: GarageController, devices: DoorDevices): + devices.motor.stop() + # end methods + + +# do build +machineFactory = builder.build() +# end building +# story +if __name__ == "__main__": + # do instantiate + machine = machineFactory(DoorDevices(Motor(), Alarm())) + # end instantiate + print("pushing button...") + # do open + machine.pushButton("alice") + # end open + print("pushed") + try: + machine.pushButton("bob") + except NoTransition: + print("this is not implemented yet") + print("triggering open sensor, pushing button again") + # sensor and close + machine.openSensor() + machine.pushButton("carol") + # end close + print("pushed") + machine.closeSensor() + +# end story diff --git a/docs/examples/io_coffee_example.py b/docs/examples/io_coffee_example.py index e81e8db..f80d27d 100644 --- a/docs/examples/io_coffee_example.py +++ b/docs/examples/io_coffee_example.py @@ -1,39 +1,48 @@ from automat import MethodicalMachine + class CoffeeBrewer(object): _machine = MethodicalMachine() + @_machine.input() def brew_button(self): "The user pressed the 'brew' button." + @_machine.output() def _heat_the_heating_element(self): "Heat up the heating element, which should cause coffee to happen." # self._heating_element.turn_on() + @_machine.state() def have_beans(self): "In this state, you have some beans." + @_machine.state(initial=True) def dont_have_beans(self): "In this state, you don't have any beans." + @_machine.input() def put_in_beans(self, beans): "The user put in some beans." + @_machine.output() def _save_beans(self, beans): "The beans are now in the machine; save them." self._beans = beans + @_machine.output() def _describe_coffee(self): return "A cup of coffee made with {}.".format(self._beans) - dont_have_beans.upon(put_in_beans, enter=have_beans, - outputs=[_save_beans]) - have_beans.upon(brew_button, enter=dont_have_beans, - outputs=[_heat_the_heating_element, - _describe_coffee], - collector=lambda iterable: list(iterable)[-1] + dont_have_beans.upon(put_in_beans, enter=have_beans, outputs=[_save_beans]) + have_beans.upon( + brew_button, + enter=dont_have_beans, + outputs=[_heat_the_heating_element, _describe_coffee], + collector=lambda iterable: list(iterable)[-1], ) + cb = CoffeeBrewer() cb.put_in_beans("real good beans") print(cb.brew_button()) diff --git a/docs/examples/lightswitch.py b/docs/examples/lightswitch.py index aae1d0d..58c181c 100644 --- a/docs/examples/lightswitch.py +++ b/docs/examples/lightswitch.py @@ -9,18 +9,22 @@ class LightSwitch(object): @machine.state(serialized="on") def on_state(self): "the switch is on" + @machine.state(serialized="off", initial=True) def off_state(self): "the switch is off" + @machine.input() def flip(self): "flip the switch" + on_state.upon(flip, enter=off_state, outputs=[]) off_state.upon(flip, enter=on_state, outputs=[]) @machine.input() def query_power(self): "return True if powered, False otherwise" + @machine.output() def _is_powered(self): return True @@ -28,10 +32,13 @@ def _is_powered(self): @machine.output() def _not_powered(self): return False - on_state.upon(query_power, enter=on_state, outputs=[_is_powered], - collector=itemgetter(0)) - off_state.upon(query_power, enter=off_state, outputs=[_not_powered], - collector=itemgetter(0)) + + on_state.upon( + query_power, enter=on_state, outputs=[_is_powered], collector=itemgetter(0) + ) + off_state.upon( + query_power, enter=off_state, outputs=[_not_powered], collector=itemgetter(0) + ) @machine.serializer() def save(self, state): diff --git a/docs/examples/serialize_machine.py b/docs/examples/serialize_machine.py new file mode 100644 index 0000000..f2c5403 --- /dev/null +++ b/docs/examples/serialize_machine.py @@ -0,0 +1,80 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import Protocol, Self + +from automat import TypeMachineBuilder + + +@dataclass +class Core: + value: int + + +@dataclass +class DataObj: + datum: str + + @classmethod + def create(cls, inputs: Inputs, core: Core, datum: str) -> Self: + return cls(datum) + + +# begin salient +class Inputs(Protocol): + def serialize(self) -> tuple[int, str | None]: ... + def next(self) -> None: ... + def data(self, datum: str) -> None: ... + + +builder = TypeMachineBuilder(Inputs, Core) +start = builder.state("start") +nodata = builder.state("nodata") +data = builder.state("data", DataObj.create) +nodata.upon(Inputs.data).to(data).returns(None) +start.upon(Inputs.next).to(nodata).returns(None) + + +@nodata.upon(Inputs.serialize).loop() +def serialize(inputs: Inputs, core: Core) -> tuple[int, None]: + return (core.value, None) + + +@data.upon(Inputs.serialize).loop() +def serializeData(inputs: Inputs, core: Core, data: DataObj) -> tuple[int, str]: + return (core.value, data.datum) + # end salient + + +# build and serialize +machineFactory = builder.build() +machine = machineFactory(Core(3)) +machine.next() +print(machine.serialize()) +machine.data("hi") +print(machine.serialize()) +# end build + + +def deserializeWithoutData(serialization: tuple[int, DataObj | None]) -> Inputs: + coreValue, dataValue = serialization + assert dataValue is None, "not handling data yet" + return machineFactory(Core(coreValue), nodata) + + +print(deserializeWithoutData((3, None))) + + +def deserialize(serialization: tuple[int, str | None]) -> Inputs: + coreValue, dataValue = serialization + if dataValue is None: + return machineFactory(Core(coreValue), nodata) + else: + return machineFactory( + Core(coreValue), + data, + lambda inputs, core: DataObj(dataValue), + ) + + +print(deserialize((3, None)).serialize()) +print(deserialize((4, "hello")).serialize()) diff --git a/docs/examples/turnstile_example.py b/docs/examples/turnstile_example.py index b57b5c5..76f67ac 100644 --- a/docs/examples/turnstile_example.py +++ b/docs/examples/turnstile_example.py @@ -1,5 +1,16 @@ from automat import MethodicalMachine + +class Lock(object): + "A sample I/O device." + + def engage(self): + print("Locked.") + + def disengage(self): + print("Unlocked.") + + class Turnstile(object): machine = MethodicalMachine() @@ -38,18 +49,16 @@ def _unlocked(self): _unlocked.upon(arm_turned, enter=_locked, outputs=[_engage_lock]) _locked.upon(arm_turned, enter=_locked, outputs=[_nope]) -class Lock(object): - "A sample I/O device." - - def engage(self): - print("Locked.") - - def disengage(self): - print("Unlocked.") turner = Turnstile(Lock()) +print("Paying fare 1.") turner.fare_paid() +print("Walking through.") turner.arm_turned() +print("Jumping.") turner.arm_turned() +print("Paying fare 2.") turner.fare_paid() +print("Walking through 2.") turner.arm_turned() +print("Done.") diff --git a/docs/examples/turnstile_typified.py b/docs/examples/turnstile_typified.py new file mode 100644 index 0000000..a059246 --- /dev/null +++ b/docs/examples/turnstile_typified.py @@ -0,0 +1,55 @@ +from typing import Callable, Protocol +from automat import TypeMachineBuilder + + +class Lock: + "A sample I/O device." + + def engage(self) -> None: + print("Locked.") + + def disengage(self) -> None: + print("Unlocked.") + + +class Turnstile(Protocol): + def arm_turned(self) -> None: + "The arm was turned." + + def fare_paid(self, coin: int) -> None: + "The fare was paid." + + +def buildMachine() -> Callable[[Lock], Turnstile]: + builder = TypeMachineBuilder(Turnstile, Lock) + locked = builder.state("Locked") + unlocked = builder.state("Unlocked") + + @locked.upon(Turnstile.fare_paid).to(unlocked) + def pay(self: Turnstile, lock: Lock, coin: int) -> None: + lock.disengage() + + @locked.upon(Turnstile.arm_turned).loop() + def block(self: Turnstile, lock: Lock) -> None: + print("**Clunk!** The turnstile doesn't move.") + + @unlocked.upon(Turnstile.arm_turned).to(locked) + def turn(self: Turnstile, lock: Lock) -> None: + lock.engage() + + return builder.build() + + +TurnstileImpl = buildMachine() +turner = TurnstileImpl(Lock()) +print("Paying fare 1.") +turner.fare_paid(1) +print("Walking through.") +turner.arm_turned() +print("Jumping.") +turner.arm_turned() +print("Paying fare 2.") +turner.fare_paid(1) +print("Walking through 2.") +turner.arm_turned() +print("Done.") diff --git a/docs/index.rst b/docs/index.rst index 9a5c8bb..80dec1d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,53 +6,55 @@ Automat: Self-service finite-state machines for the programmer on the go. :width: 250 :align: right -Automat is a library for concise, idiomatic Python expression -of finite-state automata -(particularly deterministic finite-state transducers). +Automat is a library for concise, idiomatic Python expression of finite-state +automata (particularly `deterministic finite-state transducers +`_). +.. _Garage-Example: Why use state machines? ======================= + Sometimes you have to create an object whose behavior varies with its state, but still wishes to present a consistent interface to its callers. -For example, let's say you're writing the software for a coffee machine. -It has a lid that can be opened or closed, a chamber for water, -a chamber for coffee beans, and a button for "brew". - -There are a number of possible states for the coffee machine. -It might or might not have water. -It might or might not have beans. -The lid might be open or closed. -The "brew" button should only actually attempt to brew coffee in one of these configurations, -and the "open lid" button should only work if the coffee is not, in fact, brewing. - -With diligence and attention to detail, -you can implement this correctly using a collection of attributes on an object; -has_water, has_beans, is_lid_open and so on. -However, you have to keep all these attributes consistent. -As the coffee maker becomes more complex - -perhaps you add an additional chamber for flavorings so you can make hazelnut coffee, -for example - you have to keep adding more and more checks -and more and more reasoning about which combinations of states are allowed. - -Rather than adding tedious 'if' checks to every single method -to make sure that each of these flags are exactly what you expect, -you can use a state machine to ensure that if your code runs at all, -it will be run with all the required values initialized, -because they have to be called in the order you declare them. +For example, let's say we are writing the software for a garage door +controller. The garage door is composed of 4 components: -You can read more about state machines and their advantages for Python programmers -`in an excellent article by J.P. Calderone. `_ +1. A motor which can be run up or down, to raise or lower the door + respectively. +2. A sensor that activates when the door is fully open. +3. A sensor that activates when the door is fully closed. +4. A button that tells the door to open or close. + +It's very important that the garage door does not get confused about its state, +because we could burn out the motor if we attempt to close an already-closed +door or open an already-open door. +With diligence and attention to detail, you can implement this correctly using +a collection of attributes on an object; ``isOpen``, ``isClosed``, +``motorRunningDirection``, and so on. +However, you have to keep all these attributes consistent. As the software +becomes more complex - perhaps you want to add a safety sensor that prevents +the door from closing when someone is standing under it, for example - they all +potentially need to be updated, and any invariants about their mutual +interdependencies. + +Rather than adding tedious ``if`` checks to every method on your ``GarageDoor`` +to make sure that all internal state is consistent, you can use a state machine +to ensure that if your code runs at all, it will be run with all the required +values initialized, because they have to be called in the order you declare +them. + +You can read more about state machines and their advantages for Python programmers +`in an excellent article by J.P. Calderone. `_ .. toctree:: :maxdepth: 2 :caption: Contents: - about + tutorial + compare visualize - api - debugging - typing + api/index diff --git a/docs/requirements.in b/docs/requirements.in new file mode 100644 index 0000000..7aa5045 --- /dev/null +++ b/docs/requirements.in @@ -0,0 +1,3 @@ +sphinx +pydoctor +sphinx_rtd_theme diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..723c3f7 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,107 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --no-emit-index-url +# +alabaster==0.7.16 + # via sphinx +appdirs==1.4.4 + # via pydoctor +attrs==24.2.0 + # via + # automat + # pydoctor + # twisted +automat==22.10.0 + # via twisted +babel==2.16.0 + # via sphinx +cachecontrol[filecache]==0.14.0 + # via + # cachecontrol + # pydoctor +certifi==2024.7.4 + # via requests +charset-normalizer==3.3.2 + # via requests +configargparse==1.7 + # via pydoctor +constantly==23.10.4 + # via twisted +docutils==0.20.1 + # via + # pydoctor + # sphinx + # sphinx-rtd-theme +filelock==3.15.4 + # via cachecontrol +hyperlink==21.0.0 + # via twisted +idna==3.7 + # via + # hyperlink + # requests +imagesize==1.4.1 + # via sphinx +incremental==24.7.2 + # via twisted +jinja2==3.1.4 + # via sphinx +lunr==0.6.2 + # via pydoctor +markupsafe==2.1.5 + # via jinja2 +msgpack==1.0.8 + # via cachecontrol +packaging==24.1 + # via sphinx +pydoctor==24.3.3 + # via -r requirements.in +pygments==2.18.0 + # via sphinx +requests==2.32.3 + # via + # cachecontrol + # pydoctor + # sphinx +six==1.16.0 + # via automat +snowballstemmer==2.2.0 + # via sphinx +sphinx==7.4.7 + # via + # -r requirements.in + # sphinx-rtd-theme + # sphinxcontrib-jquery +sphinx-rtd-theme==2.0.0 + # via -r requirements.in +sphinxcontrib-applehelp==2.0.0 + # via sphinx +sphinxcontrib-devhelp==2.0.0 + # via sphinx +sphinxcontrib-htmlhelp==2.1.0 + # via sphinx +sphinxcontrib-jquery==4.1 + # via sphinx-rtd-theme +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==2.0.0 + # via sphinx +sphinxcontrib-serializinghtml==2.0.0 + # via sphinx +toml==0.10.2 + # via pydoctor +twisted==24.7.0 + # via pydoctor +typing-extensions==4.12.2 + # via twisted +urllib3==2.2.2 + # via + # pydoctor + # requests +zope-interface==7.0.1 + # via twisted + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/docs/tutorial.rst b/docs/tutorial.rst new file mode 100644 index 0000000..02da46f --- /dev/null +++ b/docs/tutorial.rst @@ -0,0 +1,526 @@ +******** +Tutorial +******** + +.. note:: + + Automat 24.8 is a *major* change to the public API - effectively a whole new + library. For ease of migration, the code and API documentation still + contains ``MethodicalMachine``, effectively the previous version of the + library. However, for readability, the narrative documentation now *only* + documents ``TypeMachineBuilder``. If you need documentation for that + earlier version, you can find it as v22.10.0 on readthedocs. + +The Basics: a Garage Door Opener +================================ + + +Describing the State Machine +---------------------------- + +Let's consider :ref:`the garage door example from the +introduction`. + +Automat takes great care to present a state machine as a collection of regular +methods. So we define what those methods *are* with a +:py:class:`typing.Protocol` that describes them. + +.. literalinclude:: examples/garage_door.py + :pyobject: GarageController + +This protocol tells us that only 3 things can happen to our controller from the +outside world (its inputs): the user can push the button, the "door is all the +way up" sensor can emit a signal, or the "door is all the way down" sensor can +emit a signal. So those are our inputs. + +However, our state machine also needs to be able to *affect* things in the +world (its outputs). As we are writing a program in Python, these come in the +form of a Python object that can be shared between all the states that +implement our controller, and for this purpose we define a simple shared-data +class: + +.. literalinclude:: examples/garage_door.py + :pyobject: DoorDevices + +Here we have a reference to a ``Motor`` that can open and close the door, and +an ``Alarm`` that can beep to alert people that the door is closing. + +Next we need to combine those together, using a +:py:class:`automat.TypeMachineBuilder`. + +.. literalinclude:: examples/garage_door.py + :start-after: start building + :end-before: build states + +Next we have to define our states. Let's start with four simple ones: + +1. closed - the door is closed and idle +2. opening - the door is actively opening +3. opened - the door is open and idle +4. closing - the door is actively closing + +.. literalinclude:: examples/garage_door.py + :start-after: build states + :end-before: end states + +To describe the state machine, we define a series of transitions, using the +method ``.upon()``: + +.. literalinclude:: examples/garage_door.py + :start-after: build methods + :end-before: end methods + +Building and using the state machine +------------------------------------ + +Now that we have described all the inputs, states, and output behaviors, it's +time to actually build the state machine: + +.. literalinclude:: examples/garage_door.py + :start-after: do build + :end-before: end building + +The :py:meth:`automat.TypeMachineBuilder.build` method creates a callable that +takes an instance of its state core (``DoorDevices``) and returns an object +that conforms to its inputs protocol (``GarageController``). We can then take +this ``machineFactory`` and call it, like so: + +.. literalinclude:: examples/garage_door.py + :start-after: do instantiate + :end-before: end instantiate + +Because we defined ``closed`` as our first state above, the machine begins in +that state by default. So the first thing we'll do is to open the door: + +.. literalinclude:: examples/garage_door.py + :start-after: do open + :end-before: end open + +If we run this, we will then see some output, indicating that the motor is +running: + +.. code-block:: + + motor running up + +If we press the button again, rather than silently double-starting the motor, +we will get an error, since we haven't yet defined a state transition for this +state yet. The traceback looks like this: + +.. code-block:: + + Traceback (most recent call last): + File "", line 1, in + machine.pushButton() + File ".../automat/_typed.py", line 419, in implementation + [outputs, tracer] = transitioner.transition(methodInput) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File ".../automat/_core.py", line 196, in transition + outState, outputSymbols = self._automaton.outputForInput( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File ".../automat/_core.py", line 169, in outputForInput + raise NoTransition(state=inState, symbol=inputSymbol) + automat._core.NoTransition: no transition for pushButton in TypedState(name='opening') + +At first, this might seem like it's making more work for you. If you don't +want to crash the code that calls your methods, you need to provide many more +implementations of the same method for each different state. But, in this +case, by causing this exception *before* running any of your code, Automat is +protecting your internal state: although client code will get an exception, the +*internal* state of your garage door controller will remain consistent. + +If you did not explicitly take a specific state into consideration while +implementing some behavior, that behavior will never be invoked. Therefore, it +cannot do something potentially harmful like double-starting the motor. + +If we trigger the open sensor so that the door completes its transition to the +'open' state, then push the button again, the buzzer will sound and the door +will descend: + +.. literalinclude:: examples/garage_door.py + :start-after: sensor and close + :end-before: end close + +.. code-block:: + + motor stopped + beep beep beep + motor running down + +Try these exercises to get to to know Automat a little bit better: + +- When the button is pushed while the door is opening, the motor should stop, + and if it's pressed again, the door should go in the reverse direction; for + exmaple, if it's opening, it should pause and then close again, and if it's + closing, it should pause and then open again. Make it do this rather than + raise an exception. +- Add a 'safety sensor' input, that refuses to close the door while it is + tripped. + +Taking, Storing, and Returning Data +----------------------------------- + +Any method defined by the input protocol can take arguments and return values, +just like any Python method. In order to facilitate this, all transition +behavior methods must be able to accept any signature that their input can. + +To demonstrate this, let's add a feature to our door. Instead of a single +button, let's add the ability to pair multiple remotes to open the door, so we +can note which remote was used in a security log. For starters, we will need +to modify our ``pushButton`` method to accept a ``remoteID`` argument, which we +can print out. + +.. literalinclude:: examples/garage_door_security.py + :pyobject: GarageController.pushButton + +If you're using ``mypy``, you will immediately see a type error when making +this change, as all the calls to ``.upon(GarageController.pushButton)`` +now complain something like this: + +.. code-block:: + + garage_door_security.py:75:2: error: Argument 1 to "__call__" of "TransitionRegistrar" + has incompatible type "Callable[[GarageController, DoorDevices], None]"; + expected "Callable[[GarageController, DoorDevices, str], None]" [arg-type] + +The ``TransitionRegistrar`` object is the result of calling ``.to(...)``, so +what this is saying is that your function that is decorated with, say, +``@closed.upon(GarageController.pushButton).to(opening)``, takes your input +protocol and your shared core object (as all transition behavior functions +must), but does *not* take the ``str`` argument that ``pushButton`` takes. To +fix it, we can add that parameter everywhere, and print it out, like so: + +.. literalinclude:: examples/garage_door_security.py + :pyobject: startOpening + +Obviously, mypy will also complain that our test callers are missing the +``remoteID`` argument as well, so if we change them to pass along some value +like so: + +.. literalinclude:: examples/garage_door.py + :start-after: do open + :end-before: end open + +Then we will see it in our output: + +.. code-block:: + + opened by alice + +Return values are treated in the same way as parameters. If your input +protocol specifies a return type, then all behavior methods must also return +that type. Your type checker will help ensure that these all line up for you +as well. + +You can download the full examples here: + +- :download:`examples/garage_door.py` +- :download:`examples/garage_door_security.py` + +More Advanced Usage: a Membership Card Automat Restaurant +========================================================= + +Setting Up the Example +---------------------- + +We will have to shift to a slightly more complex example to demonstrate +Automat's more sophisticated features. Rather than opening the single door on +our garage, let's implement the payment machine for an Automat - a food vending +machine. + +Our automat operates on a membership system. You buy an AutoBux card, load it +up, and then once you are at the machine, you swipe your card, make a +selection, your account is debited, and your food is dispensed. + +State-specific Data +------------------- + +One of the coolest feature of Automat is not merely enforcing state +transitions, but ensuring that the right data is always available in the right +state. For our membership-card example, will start in an "idle" state, but +when a customer swipes their card and starts to make their food selection, we +have now entered the "choosing" state, it is crucial that *if we are in the +choosing state, then we* **must** *know which customer's card we will charge*. + +We set up the state machine in much the same way as before: a state core: + +.. literalinclude:: examples/automat_card.py + :pyobject: AutomatCore + +And an inputs protocol: + +.. literalinclude:: examples/automat_card.py + :pyobject: Automat + +It may jump out at you that the ``_dispenseFood`` method is private. That's a +bit unusual for a ``Protocol``, which is usually used to describe a +publicly-facing API. Indeed, you might even want a *second* ``Protocol`` to +hide this away from your public documentation. But for Automat, this is +important because it's what lets us implement a *conditional state transition*, +something commonly associated with state-specific data. + +We will get to that in a moment, but first, let's define that data. We'll +begin with a function that, like transition behavior functions, takes our input +protocol and core type. Its job will be to build our state-specific data for +the "choosing" state, i.e. payment details. Entering this state requires an +``accountID`` as supplied by our ``swipeCard`` input, so we will require that +as a parameter as well: + +.. literalinclude:: examples/automat_card.py + :pyobject: rememberAccount + +Next, let's actually build the machine. We will use ``rememberAccount`` as the +second parameter to ``TypeMachineBuilder.state()``, which defines ``choosing`` +as a data state: + +.. literalinclude:: examples/automat_card.py + :start-after: define machine + :end-before: end define + +.. note:: + + Here, because swipeCard doesn't need any behavior and returns a static, + immutable type (None), we define the transition with ``.returns(None)`` + rather than giving it a behavior function. This is the same as using + ``@idle.upon(Automat.swipeCard).to(choosing)`` as a decorator on an empty + function, but a lot faster to type and to read. + +The fact that ``choosing`` is a data state adds two new requirements to its +transitions:x + +1. First, for every transition defined *to* the ``choosing`` state, the data + factory function -- ``rememberAccount`` -- must be callable with whatever + parameters defined in the input. If you want to make a lenient data factory + that supports multiple signatures, you can always add ``*args: object, + **kwargs: object`` to its signature, but any parameters it requires (in this + case, ``accountID``) *must* be present in any input protocol methods that + transition *to* ``choosing`` so that they can be passed along to the + factory. + +2. Second, for every transition defined *from* the ``choosing`` state, behavior + functions will accept an additional parameter, of the same type returned by + their state-specific data factory function. In other words, we will build a + ``PaymentDetails`` object on every transition *to* ``choosing``, and then + remember and pass that object to every behavior function as long as the + machine remains in that state. + +Conditional State Transitions +----------------------------- + +Formally, in a deterministic finite-state automaton, an input in one state must +result in the same transition to the same output state. When you define +transitions statically, Automat adheres to this rule. However, in many +real-world cases, which state you end up in after a particular event depends on +things like the input data or internal state. In this example, if the user's +AutoBux™ account balance is too low, then the food should not be dispensed; it +should prompt the user to make another selection. + +Because it must be static, this means that the transition we will define from +the ``choosing`` state upon ``selectFood`` will actually be a ``.loop()`` -- in +other words, back to ``choosing`` -- rather than ``.to(idle)``. Within the +behavior function of that transition, if we have determined that the user's +card has been charged properly, we will call *back* into the ``Automat`` +protocol via the ``_dispenseFood`` private input, like so: + +.. literalinclude:: examples/automat_card.py + :pyobject: selected + +And since we want *that* input to transition us back to ``idle`` once the food +has been dispensed, once again, we register a static transition, and this one's +behavior is much simpler: + +.. literalinclude:: examples/automat_card.py + :pyobject: doOpen + +You can download the full example here: + +- :download:`examples/garage_door_security.py` + +Reentrancy +---------- + +Observant readers may have noticed a slightly odd detail in the previous +section. + +If our ``selected`` behavior function can cause a transition to another state +before it's completed, but that other state's behaviors may require invariants +that are maintained by previous behavior (i.e. ``selected`` itself) having +completed, doesn't that create a paradox? How can we just invoke +``inputs._dispenseFood`` and have it work? + +In fact, you can't. This is an unresolvable paradox, and automat does a little +trick to allow this convenient illusion, but it only works in some cases. + +Problems that lend themselves to state machines often involve setting up state +to generate inputs back to the state machine in the future. For example, in +the garage door example above, we implicitly registered sensors to call the +``openSensor`` and ``closeSensor`` methods. A more complete implementation in +the behavior might need to set a timeout with an event loop, to automatically +close the door after a certain amount of time. Being able to treat the state +machines inputs as regular bound methods that can be used in callbacks is +extremely convenient for this sort of thing. For those use cases, there are no +particular limits on what can be called; once the behavior itself is finished +and it's no longer on the stack, the object will behave exactly as its +``Protocol`` describes. + +One constraint is that any method you invoke in this way cannot return any +value except None. This very simple machine, for example, that attempts to +invoke a behavior that returns an integer: + +.. literalinclude:: examples/feedback_errors.py + :start-after: #begin + :end-before: #end + +will result in a traceback like so: + +.. code-block:: + + File "feedback_errors.py", line 24, in behave + print("computed:", inputs.compute()) + ^^^^^^^^^^^^^^^^ + File ".../automat/_typed.py", line 406, in implementation + raise RuntimeError( + RuntimeError: attempting to reentrantly run Inputs.compute + but it wants to return not None + +However, if instead of calling the method *immediately*, we save the method +away to invoke later, it works fine once the current behavior function has +completed: + +.. literalinclude:: examples/feedback_order.py + :start-after: begin computations + :end-before: end computations + +This simply prints ``3``, as expected. + +But why is there a constraint on return type? Surely a ``None``-returning +method with side effects depends on its internal state just as much as +something that returns a value? Running it re-entrantly before finishing the +previous behavior would leave things in an invalid state, so how can it run at +all? + +The magic that makes this work is that Automat automatically makes the +invocation *not reentrant*, by re-ordering it for you. It can *re-order a +second behavior that returns None to run at the end of your current behavior*, +but it cannot steal a return value from the future, so it raises an exception +to avoid confusion. + +But there is still the potentially confusing edge-case of re-ordering. A +machine that contains these two behaviors: + +.. literalinclude:: examples/feedback_debugging.py + :pyobject: one +.. literalinclude:: examples/feedback_debugging.py + :pyobject: two + +will, when ``.behavior1()`` is invoked on it, print like so: + +.. code-block:: + + starting behavior 1 + ending behavior 1 + behavior 2 + +In general, this re-ordering *is* what you want idiomatically when working with +a state machine, but it is important to know that it can happen. If you have +code that you do want to invoke side effects in a precise order, put it in a +function or into a method on your shared core. + +How do I get the current state of a state machine? +================================================== + +Don't do that. + +One major reason for having a state machine is that you want the callers of the +state machine to just provide the appropriate input to the machine at the +appropriate time, and *not have to check themselves* what state the machine is +in. + +The *whole point* of Automat is to never, ever write code that looks like this, +and places the burden on the caller: + + +.. code-block:: python + + if connectionMachine.state == "CONNECTED": + connectionMachine.sendMessage() + else: + print("not connected") + +Instead, just make your calling code do this: + +.. code-block:: python + + connectionMachine.sendMessage() + +and then change your state machine to look like this: + +.. literalinclude:: examples/dont_get_state.py + :start-after: begin salient + :end-before: end salient + +so that the responsibility for knowing which state the state machine is in +remains within the state machine itself. + + +If I can't get the state of the state machine, how can I save it to (a database, an API response, a file on disk...) +==================================================================================================================== + +On the serialization side, you can build inputs that return a type that every +state can respond to. For example, here's a machine that maintains an ``int`` +value in its core, and a ``str`` value in a piece of state-specific data. This +really just works like implementing any other return value. + +.. literalinclude:: examples/serialize_machine.py + :start-after: begin salient + :end-before: end salient + +getting the data out then looks like this: + +.. literalinclude:: examples/serialize_machine.py + :start-after: build and serialize + :end-before: end build + +which produces: + +.. code-block:: + + (3, None) + (3, DataObj(datum='hi')) + +Future versions of automat may include some utility functionaity here to reduce +boilerplate, but no additional features are required to address this half of +the problem. + +However, for *de*serialization, we do need the ability to start in a different +initial state. For non-data states, it's simple enough; construct an +appropriate shared core, and just pass the state that you want; in our case, +``nodata``: + +.. literalinclude:: examples/serialize_machine.py + :pyobject: deserializeWithoutData + +Finally, all we need to deserialize a state with state-specific data is to pass +a factory function which takes ``inputs, core`` as arguments, just like +behavior and data-factory functions. Since we are skipping *directly* to the +data state, we will skip the data factory declared on the state itself, and +call this one: + +.. literalinclude:: examples/serialize_machine.py + :pyobject: deserialize + +.. note:: + + In this specific deserialization context, since the object isn't even really + constructed yet, the ``inputs`` argument is in a *totally* invalid state and + cannot be invoked reentrantly at all; any method will raise an exception if + called during the duration of this special deserialization data factory. + You can only use it to save it away on your state-specific data for future + invocations once the state machine instance is built. + +You can download the full example here: + +- :download:`examples/serialize_machine.py` + +And that's pretty much all you need to know in order to build type-safe state +machines with Automat! diff --git a/docs/typing.rst b/docs/typing.rst deleted file mode 100644 index e9722fc..0000000 --- a/docs/typing.rst +++ /dev/null @@ -1,95 +0,0 @@ -Static Typing --------------- - -When writing an output for a given state, -you can assume the finite state machine will be in that state. -This might mean that specific object attributes will have values -of speciifc types. -Those attributes might, -in general, -be of some :code:`Union` type: -frequently, -an :code:`Option` type -(which is a :code:`Union[T, None]`). - -It is an *anti-pattern* to check for these things inside the output. -The reason for a state machine is for the outputs to avoid checking. -However, -if the output is type annotated, -often :code:`mypy` -will complain that it cannot validate the types. -The recommended solution is to -:code:`assert` -the types inside the code. -This aligns -the assumptions -:code:`mypy` -makes -with the assumptions -:code:`automat` -makes. - -For example, -consider the following: - -.. code:: - - import attr - import automat - from typing import Optional - - @attr.s(auto_attribs=True) - class MaybeValue: - _machine = automat.MethodicalMachine() - _value: Optional[float] = attr.ib(default=None) - - @_machine.input() - def set_value(self, value: float) -> None: - "The value has been measured" - - @_machine.input() - def get_value(self) -> float: - "Return the value if it has been measured" - - @_machine.output() - def _set_value_when_unset(self, value: float) -> None: - self._value = value - - @_machine.output() - def _get_value_when_set(self) -> float: - """mypy will complain here: - - Incompatible return value type - (got "Optional[float]", expected "float") - """ - return self._value - - @_machine.state() - def value_is_set(self): - "The value is set" - - @_machine.state(initial=True) - def value_is_unset(self): - "The value is not set" - - value_is_unset.upon( - set_value, - enter=value_is_set, - outputs=[_set_value_when_unset], - collector=lambda x: None, - ) - value_is_set.upon( - get_value, - enter=value_is_set, - outputs=[_get_value_when_set], - collector=lambda x: next(iter(x)), - ) - -In this case -starting -:code:`_get_value_when_set` -with a line -:code:`assert self._value is not None` -will satisfy -:code:`mypy`. - diff --git a/docs/visualize.rst b/docs/visualize.rst index 40b464d..402b7c0 100644 --- a/docs/visualize.rst +++ b/docs/visualize.rst @@ -1,6 +1,6 @@ -============== -Visualizations -============== +================ + Visualizations +================ Installation @@ -13,74 +13,34 @@ To create state machine graphs you must install `automat` with the graphing depe pip install automat[visualize] +To generate images, you will also need to install `Graphviz +`_ for your platform, such as with ``brew install +graphviz`` on macOS or ``apt install graphviz`` on Ubuntu. + Example ======= -Given the following project structure:: - - mystate/ - ├── __init__.py - └── machine.py - - -And the following state machine defined in `machine.py` - - -.. code-block:: python - - from automat import MethodicalMachine - - class MyMachine(object): - _machine = MethodicalMachine() - - @_machine.state(initial=True) - def state_a(self): - """ - State A - """ - - @_machine.state() - def state_b(self): - """ - State B - """ - - @_machine.input() - def change_state(self): - """ - Change state - """ - - @_machine.output() - def output_on_change_state(self): - """ - Change state - """ - return "Changing state" - - state_a.upon(change_state, enter=state_b, outputs=[output_on_change_state]) - +If we put the garage door example from the tutorial into a file called ``garage_door.py``, You can generate a state machine visualization by running: .. code-block:: bash - $ automat-visualize mystate - mystate.machine.MyMachine._machine ...discovered - mystate.machine.MyMachine._machine ...wrote image and dot into .automat_visualize - + $ automat-visualize garage_door + garage_door.machineFactory ...discovered + garage_door.machineFactory ...wrote image and dot into .automat_visualize -The `dot` file and `png` will be saved in the default output directory -of `.automat_visualize/mystatemachine.MyMachine._machine.dot.png` +The `dot` file and `png` will be saved in the default output directory, to the +file ``.automat_visualize/garage_door.machineFactory.dot.png`` . -.. image:: _static/mystate.machine.MyMachine._machine.dot.png - :alt: my state machine +.. image:: _static/garage_door.machineFactory.dot.png + :alt: garage door state machine -`automat-visualize` help -======================== +``automat-visualize`` help +========================== .. code-block:: bash diff --git a/mypy.ini b/mypy.ini index eed9e1a..235657d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,3 +1,9 @@ [mypy] +show_error_codes = True +warn_unused_ignores = true +no_implicit_optional = true +strict_optional = true +disallow_any_generics = true + [mypy-graphviz.*] ignore_missing_imports = True diff --git a/setup.py b/setup.py index 174863c..7cf6058 100644 --- a/setup.py +++ b/setup.py @@ -5,44 +5,41 @@ from setuptools import setup, find_packages setup( - name='Automat', + name="Automat", use_scm_version=True, - url='https://github.com/glyph/Automat', + url="https://github.com/glyph/Automat", description=""" Self-service finite-state machines for the programmer on the go. """.strip(), - readme='README.md', + readme="README.md", packages=find_packages(exclude=[]), - package_dir={'automat': 'automat'}, + package_dir={"automat": "automat"}, setup_requires=[ - 'setuptools-scm', + "setuptools-scm", ], install_requires=[ - "attrs>=19.2.0", + 'typing_extensions; python_version<"3.10"', ], extras_require={ - "visualize": ["graphviz>0.5.1", - "Twisted>=16.1.1"], + "visualize": ["graphviz>0.5.1", "Twisted>=16.1.1"], }, entry_points={ - "console_scripts": [ - "automat-visualize = automat._visualize:tool" - ], + "console_scripts": ["automat-visualize = automat._visualize:tool"], }, - author='Glyph', - author_email='glyph@twistedmatrix.com', + author="Glyph", + author_email="glyph@twistedmatrix.com", include_package_data=True, license="MIT", - keywords='fsm finite state machine automata', + keywords="fsm finite state machine automata", classifiers=[ - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", ], ) diff --git a/tox.ini b/tox.ini index ac75a31..9e1f112 100644 --- a/tox.ini +++ b/tox.ini @@ -1,15 +1,23 @@ [tox] -envlist = lint,mypy,coverage-clean,{pypy3,py38,py310,py311}-{extras,noextras},coverage-report +envlist = lint,{pypy3,py38,py310,py311}-mypy,coverage-clean,{pypy3,py38,py310,py311}-{extras,noextras},coverage-report,docs + [testenv] deps = extras: graphviz>=0.4.9 extras: Twisted>=16.2.0 + + mypy: mypy + mypy: graphviz>=0.4.9 + mypy: Twisted>=16.2.0 + coverage pytest commands = - coverage run --parallel --source automat -m pytest automat/_test + {extras,noextras}: coverage run --parallel --source automat -m pytest -s -rfEsx automat/_test + mypy: mypy {posargs:automat} + depends = coverage-clean @@ -37,14 +45,6 @@ commands = pytest --benchmark-only benchmark/ deps = black commands = black --check automat -[testenv:mypy] -deps = - mypy - graphviz>=0.4.9 - Twisted>=16.2.0 - -commands = mypy automat - [testenv:pypy3-benchmark] deps = {[testenv:benchmark]deps} commands = {[testenv:benchmark]commands} @@ -53,8 +53,7 @@ commands = {[testenv:benchmark]commands} usedevelop = True changedir = docs deps = - sphinx - sphinx_rtd_theme + -r docs/requirements.txt commands = - sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html -basepython = python3.8 + python -m sphinx -M html . _build +basepython = python3.12 diff --git a/typical_example_happy.py b/typical_example_happy.py new file mode 100644 index 0000000..ae1ca6e --- /dev/null +++ b/typical_example_happy.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from itertools import count +from typing import Callable, List, Protocol + +from automat import TypeMachineBuilder + + +# scaffolding; no state machines yet + + +@dataclass +class Request: + id: int = field(default_factory=count(1).__next__) + + +@dataclass +class RequestGetter: + cb: Callable[[Request], None] | None = None + + def startGettingRequests(self, cb: Callable[[Request], None]) -> None: + self.cb = cb + + +@dataclass(repr=False) +class Task: + performer: TaskPerformer + request: Request + done: Callable[[Task, bool], None] + active: bool = True + number: int = field(default_factory=count(1000).__next__) + + def __repr__(self) -> str: + return f"" + + def complete(self, success: bool) -> None: + # Also a state machine, maybe? + print("complete", success) + self.performer.activeTasks.remove(self) + self.active = False + self.done(self, success) + + def stop(self) -> None: + self.complete(False) + + +@dataclass +class TaskPerformer: + activeTasks: List[Task] = field(default_factory=list) + taskLimit: int = 3 + + def performTask(self, r: Request, done: Callable[[Task, bool], None]) -> Task: + self.activeTasks.append(it := Task(self, r, done)) + return it + + +class ConnectionCoordinator(Protocol): + def start(self) -> None: + "kick off the whole process" + + def requestReceived(self, r: Request) -> None: + "a task was received" + + def taskComplete(self, task: Task, success: bool) -> None: + "task complete" + + def atCapacity(self) -> None: + "we're at capacity stop handling requests" + + def headroom(self) -> None: + "one of the tasks completed" + + def cleanup(self) -> None: + "clean everything up" + + +@dataclass +class ConnectionState: + getter: RequestGetter + performer: TaskPerformer + allDone: Callable[[Task], None] + queue: List[Request] = field(default_factory=list) + + +def buildMachine() -> Callable[[ConnectionState], ConnectionCoordinator]: + + builder = TypeMachineBuilder(ConnectionCoordinator, ConnectionState) + Initial = builder.state("Initial") + Requested = builder.state("Requested") + AtCapacity = builder.state("AtCapacity") + CleaningUp = builder.state("CleaningUp") + + Requested.upon(ConnectionCoordinator.atCapacity).to(AtCapacity).returns(None) + Requested.upon(ConnectionCoordinator.headroom).loop().returns(None) + CleaningUp.upon(ConnectionCoordinator.headroom).loop().returns(None) + CleaningUp.upon(ConnectionCoordinator.cleanup).loop().returns(None) + + @Initial.upon(ConnectionCoordinator.start).to(Requested) + def startup(coord: ConnectionCoordinator, core: ConnectionState) -> None: + core.getter.startGettingRequests(coord.requestReceived) + + @AtCapacity.upon(ConnectionCoordinator.requestReceived).loop() + def requestReceived( + coord: ConnectionCoordinator, core: ConnectionState, r: Request + ) -> None: + print("buffering request", r) + core.queue.append(r) + + @AtCapacity.upon(ConnectionCoordinator.headroom).to(Requested) + def headroom(coord: ConnectionCoordinator, core: ConnectionState) -> None: + "nothing to do, just transition to Requested state" + unhandledRequest = core.queue.pop() + print("dequeueing", unhandledRequest) + coord.requestReceived(unhandledRequest) + + @Requested.upon(ConnectionCoordinator.requestReceived).loop() + def requestedRequest( + coord: ConnectionCoordinator, core: ConnectionState, r: Request + ) -> None: + print("immediately handling request", r) + core.performer.performTask(r, coord.taskComplete) + if len(core.performer.activeTasks) >= core.performer.taskLimit: + coord.atCapacity() + + + @Initial.upon(ConnectionCoordinator.taskComplete).loop() + @Requested.upon(ConnectionCoordinator.taskComplete).loop() + @AtCapacity.upon(ConnectionCoordinator.taskComplete).loop() + @CleaningUp.upon(ConnectionCoordinator.taskComplete).loop() + def taskComplete( + c: ConnectionCoordinator, s: ConnectionState, task: Task, success: bool + ) -> None: + if success: + c.cleanup() + s.allDone(task) + else: + c.headroom() + + @Requested.upon(ConnectionCoordinator.cleanup).to(CleaningUp) + @AtCapacity.upon(ConnectionCoordinator.cleanup).to(CleaningUp) + def cleanup(coord: ConnectionCoordinator, core: ConnectionState): + # We *don't* want to recurse in here; stopping tasks will cause + # taskComplete! + while core.performer.activeTasks: + core.performer.activeTasks[-1].stop() + + return builder.build() + + +ConnectionMachine = buildMachine() + + +def begin( + r: RequestGetter, t: TaskPerformer, done: Callable[[Task], None] +) -> ConnectionCoordinator: + machine = ConnectionMachine(ConnectionState(r, t, done)) + machine.start() + return machine + + +def story() -> None: + + rget = RequestGetter() + tper = TaskPerformer() + + def yay(t: Task) -> None: + print("yay") + + m = begin(rget, tper, yay) + cb = rget.cb + assert cb is not None + cb(Request()) + cb(Request()) + cb(Request()) + cb(Request()) + cb(Request()) + cb(Request()) + cb(Request()) + print([each for each in tper.activeTasks]) + sc: ConnectionState = m.__automat_core__ # type:ignore + print(sc.queue) + tper.activeTasks[0].complete(False) + tper.activeTasks[0].complete(False) + print([each for each in tper.activeTasks]) + print(sc.queue) + tper.activeTasks[0].complete(True) + print([each for each in tper.activeTasks]) + + +if __name__ == "__main__": + story()