okay fine

This commit is contained in:
pacnpal
2024-11-03 17:47:26 +00:00
parent 387c4740e7
commit 27f3326e22
10020 changed files with 1935769 additions and 2364 deletions

View File

@@ -0,0 +1,16 @@
# -*- test-case-name: automat -*-
"""
State-machines.
"""
from ._typed import TypeMachineBuilder, pep614, AlreadyBuiltError, TypeMachine
from ._core import NoTransition
from ._methodical import MethodicalMachine
__all__ = [
"TypeMachineBuilder",
"TypeMachine",
"NoTransition",
"AlreadyBuiltError",
"pep614",
"MethodicalMachine",
]

View File

@@ -0,0 +1,203 @@
# -*- test-case-name: automat._test.test_core -*-
"""
A core state-machine abstraction.
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 = "<no state>"
State = TypeVar("State", bound=Hashable)
Input = TypeVar("Input", bound=Hashable)
Output = TypeVar("Output", bound=Hashable)
class NoTransition(Exception, Generic[State, Input]):
"""
A finite state machine in C{state} has no transition for C{symbol}.
@ivar state: See C{state} init parameter.
@ivar symbol: See C{symbol} init parameter.
"""
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__(
"no transition for {} in {}".format(symbol, state)
)
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, initial: State | None = None) -> None:
"""
Initialize the set of transitions and the initial state.
"""
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) -> State:
"""
Return this automaton's initial state.
"""
return self._initialState
@initialState.setter
def initialState(self, state: State) -> None:
"""
Set this automaton's initial state. Raises a ValueError if
this automaton already has an initial state.
"""
if self._initialState is not _NO_STATE:
raise ValueError(
"initial state already set to {}".format(self._initialState)
)
self._initialState = state
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.
"""
# 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:
if anInState == inState and anInputSymbol == inputSymbol:
raise ValueError(
"already have transition from {} to {} via {}".format(
inState, anOutState, inputSymbol
)
)
self._transitions.add((inState, inputSymbol, outState, tuple(outputSymbols)))
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) -> set[Input]:
"""
The full set of symbols acceptable to this automaton.
"""
return {
inputSymbol
for (inState, inputSymbol, outState, outputSymbol) in self._transitions
}
def outputAlphabet(self) -> set[Output]:
"""
The full set of symbols which can be produced by this automaton.
"""
return set(
chain.from_iterable(
outputSymbols
for (inState, inputSymbol, outState, outputSymbols) in self._transitions
)
)
def states(self) -> frozenset[State]:
"""
All valid states; "Q" in the mathematical description of a state
machine.
"""
return frozenset(
chain.from_iterable(
(inState, outState)
for (inState, inputSymbol, outState, outputSymbol) in self._transitions
)
)
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:
if (inState, inputSymbol) == (anInState, anInputSymbol):
return (outState, list(outputSymbols))
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(Generic[State, Input, Output]):
"""
The combination of a current state and an L{Automaton}.
"""
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: Tracer[State, Input, Output] | None) -> None:
self._tracer = tracer
def transition(
self, inputSymbol: Input
) -> tuple[Sequence[Output], OutputTracer[Output] | None]:
"""
Transition between states, returning any outputs.
"""
outState, outputSymbols = self._automaton.outputForInput(
self._state, inputSymbol
)
outTracer = None
if self._tracer:
outTracer = self._tracer(self._state, inputSymbol, outState)
self._state = outState
return (outputSymbols, outTracer)

View File

@@ -0,0 +1,168 @@
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 ._typed import TypeMachine, InputProtocol, Core
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
defined.
"""
sourceModule = inspect.getmodule(attr.load())
if sourceModule is None:
return False
currentModule = attr
while not isinstance(currentModule, PythonModule):
currentModule = currentModule.onObject
return currentModule.name == sourceModule.__name__
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}
wrapper object.
Note that L{PythonModule}s may refer to packages, as well.
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: L{PythonModule} or L{twisted.python.modules.PythonAttribute}
@param within: Where to start the search.
@return: a generator which yields FQPN, L{MethodicalMachine} pairs.
"""
queue = collections.deque([within])
visited: set[
PythonModule
| PythonAttribute
| MethodicalMachine
| TypeMachine[InputProtocol, Core]
| type[Any]
] = set()
while queue:
attr = queue.pop()
value = attr.load()
if (
isinstance(value, MethodicalMachine) or isinstance(value, TypeMachine)
) and value not in visited:
visited.add(value)
yield attr.name, value
elif (
inspect.isclass(value) and isOriginalLocation(attr) and value not in visited
):
visited.add(value)
queue.extendleft(attr.iterAttributes())
elif isinstance(attr, PythonModule) and value not in visited:
visited.add(value)
queue.extendleft(attr.iterAttributes())
queue.extendleft(attr.iterModules())
class InvalidFQPN(Exception):
"""
The given FQPN was not a dot-separated list of Python objects.
"""
class NoModule(InvalidFQPN):
"""
A prefix of the FQPN was not an importable module or package.
"""
class NoObject(InvalidFQPN):
"""
A suffix of the FQPN was not an accessible object
"""
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
L{twisted.python.modules.PythonAttribute}.
"""
# largely cribbed from t.p.reflect.namedAny
if not fqpn:
raise InvalidFQPN("FQPN was empty")
components = collections.deque(fqpn.split("."))
if "" in components:
raise InvalidFQPN(
"name must be a string giving a '.'-separated list of Python "
"identifiers, not %r" % (fqpn,)
)
component = components.popleft()
try:
module = getModule(component)
except KeyError:
raise NoModule(component)
# find the bottom-most module
while components:
component = components.popleft()
try:
module = module[component]
except KeyError:
components.appendleft(component)
break
else:
module.load()
else:
return module
# find the bottom-most attribute
attribute = module
for component in components:
try:
attribute = next(
child
for child in attribute.iterAttributes()
if child.name.rsplit(".", 1)[-1] == component
)
except StopIteration:
raise NoObject("{}.{}".format(attribute.name, component))
return attribute
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.
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.
@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 (C{FQPN}, L{MethodicalMachine}) pairs.
"""
return findMachinesViaWrapper(wrapFQPN(fqpn))

View File

@@ -0,0 +1,57 @@
"""
Python introspection helpers.
"""
from types import CodeType as code, FunctionType as function
def copycode(template, changes):
if hasattr(code, "replace"):
return template.replace(**{"co_" + k: v for k, v in changes.items()})
names = [
"argcount",
"nlocals",
"stacksize",
"flags",
"code",
"consts",
"names",
"varnames",
"filename",
"name",
"firstlineno",
"lnotab",
"freevars",
"cellvars",
]
if hasattr(code, "co_kwonlyargcount"):
names.insert(1, "kwonlyargcount")
if hasattr(code, "co_posonlyargcount"):
# PEP 570 added "positional only arguments"
names.insert(1, "posonlyargcount")
values = [changes.get(name, getattr(template, "co_" + name)) for name in names]
return code(*values)
def copyfunction(template, funcchanges, codechanges):
names = [
"globals",
"name",
"defaults",
"closure",
]
values = [
funcchanges.get(name, getattr(template, "__" + name + "__")) for name in names
]
return function(copycode(template.__code__, codechanges), *values)
def preserveName(f):
"""
Preserve the name of the given function on the decorated function.
"""
def decorator(decorated):
return copyfunction(decorated, dict(name=f.__name__), dict(name=f.__name__))
return decorator

View File

@@ -0,0 +1,545 @@
# -*- 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 inspect import getfullargspec as getArgsSpec
from itertools import count
from typing import Any, Callable, Hashable, Iterable, TypeVar
if sys.version_info < (3, 10):
from typing_extensions import TypeAlias
else:
from typing import TypeAlias
from ._core import Automaton, OutputTracer, Tracer, Transitioner
from ._introspection import preserveName
ArgSpec = collections.namedtuple(
"ArgSpec",
[
"args",
"varargs",
"varkw",
"defaults",
"kwonlyargs",
"kwonlydefaults",
"annotations",
],
)
def _getArgSpec(func):
"""
Normalize inspect.ArgSpec across python versions
and convert mutable attributes to immutable types.
:param Callable func: A function.
:return: The function's ArgSpec.
:rtype: ArgSpec
"""
spec = getArgsSpec(func)
return ArgSpec(
args=tuple(spec.args),
varargs=spec.varargs,
varkw=spec.varkw,
defaults=spec.defaults if spec.defaults else (),
kwonlyargs=tuple(spec.kwonlyargs),
kwonlydefaults=(
tuple(spec.kwonlydefaults.items()) if spec.kwonlydefaults else ()
),
annotations=tuple(spec.annotations.items()),
)
def _getArgNames(spec):
"""
Get the name of all arguments defined in a function signature.
The name of * and ** arguments is normalized to "*args" and "**kwargs".
:param ArgSpec spec: A function to interrogate for a signature.
:return: The set of all argument names in `func`s signature.
:rtype: Set[str]
"""
return set(
spec.args
+ spec.kwonlyargs
+ (("*args",) if spec.varargs else ())
+ (("**kwargs",) if spec.varkw else ())
+ spec.annotations
)
def _keywords_only(f):
"""
Decorate a function so all its arguments must be passed by keyword.
A useful utility for decorators that take arguments so that they don't
accidentally get passed the thing they're decorating as their first
argument.
Only works for methods right now.
"""
@wraps(f)
def g(self, **kw):
return f(self, **kw)
return g
@dataclass(frozen=True)
class MethodicalState(object):
"""
A state for a L{MethodicalMachine}.
"""
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 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
if outputs is None:
outputs = []
inputArgs = _getArgNames(input.argSpec)
for output in outputs:
outputArgs = _getArgNames(output.argSpec)
if not outputArgs.issubset(inputArgs):
raise TypeError(
"method {input} signature {inputSignature} "
"does not match output {output} "
"signature {outputSignature}".format(
input=input.method.__name__,
output=output.method.__name__,
inputSignature=getArgsSpec(input.method),
outputSignature=getArgsSpec(output.method),
)
)
self.machine._oneTransition(self, input, enter, outputs, collector)
def _name(self) -> str:
return self.method.__name__
def _transitionerFromInstance(
oself: object,
symbol: str,
automaton: Automaton[MethodicalState, MethodicalInput, MethodicalOutput],
) -> Transitioner[MethodicalState, MethodicalInput, MethodicalOutput]:
"""
Get a L{Transitioner}
"""
transitioner = getattr(oself, symbol, None)
if transitioner is None:
transitioner = Transitioner(
automaton,
automaton.initialState,
)
setattr(oself, symbol, transitioner)
return transitioner
def _empty():
pass
def _docstring():
"""docstring"""
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
# bytecode, because the "None" is put in a different constant slot.
# Unfortunately, this does not catch function bodies that return a
# constant value, e.g. "return 1", because their code is identical to a
# "return None". They differ in the contents of their constant table, but
# checking that would require us to parse the bytecode, find the index
# being returned, then making sure the table has a None at that index.
if f.__code__.co_code not in (_empty.__code__.co_code, _docstring.__code__.co_code):
raise ValueError("function body must be empty")
def _filterArgs(args, kwargs, inputSpec, outputSpec):
"""
Filter out arguments that were passed to input that output won't accept.
:param tuple args: The *args that input received.
:param dict kwargs: The **kwargs that input received.
:param ArgSpec inputSpec: The input's arg spec.
:param ArgSpec outputSpec: The output's arg spec.
:return: The args and kwargs that output will accept.
:rtype: Tuple[tuple, dict]
"""
named_args = tuple(zip(inputSpec.args[1:], args))
if outputSpec.varargs:
# Only return all args if the output accepts *args.
return_args = args
else:
# Filter out arguments that don't appear
# in the output's method signature.
return_args = [v for n, v in named_args if n in outputSpec.args]
# Get any of input's default arguments that were not passed.
passed_arg_names = tuple(kwargs)
for name, value in named_args:
passed_arg_names += (name, value)
defaults = zip(inputSpec.args[::-1], inputSpec.defaults[::-1])
full_kwargs = {n: v for n, v in defaults if n not in passed_arg_names}
full_kwargs.update(kwargs)
if outputSpec.varkw:
# Only pass all kwargs if the output method accepts **kwargs.
return_kwargs = full_kwargs
else:
# Filter out names that the output method does not accept.
all_accepted_names = outputSpec.args[1:] + outputSpec.kwonlyargs
return_kwargs = {
n: v for n, v in full_kwargs.items() if n in all_accepted_names
}
return return_args, return_kwargs
T = TypeVar("T")
R = TypeVar("R")
@dataclass(eq=False)
class MethodicalInput(object):
"""
An input for a L{MethodicalMachine}.
"""
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: ArgSpec = field(init=False, repr=False)
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
C{oself}'s current state.
"""
transitioner = _transitionerFromInstance(oself, self.symbol, self.automaton)
@preserveName(self.method)
@wraps(self.method)
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 is not None:
outTracer(output)
a, k = _filterArgs(args, kwargs, self.argSpec, output.argSpec)
value = output(oself, *a, **k)
values.append(value)
return collector(values)
return doInput
def _name(self) -> str:
return self.method.__name__
@dataclass(frozen=True)
class MethodicalOutput(object):
"""
An output for a L{MethodicalMachine}.
"""
machine: MethodicalMachine = field(repr=False)
method: Callable[..., Any]
argSpec: ArgSpec = field(init=False, repr=False, compare=False)
def __post_init__(self) -> None:
self.__dict__["argSpec"] = _getArgSpec(self.method)
def __get__(self, oself, type=None):
"""
Outputs are private, so raise an exception when we attempt to get one.
"""
raise AttributeError(
"{cls}.{method} is a state-machine output method; "
"to produce this output, call an input method instead.".format(
cls=type.__name__, method=self.method.__name__
)
)
def __call__(self, oself, *args, **kwargs):
"""
Call the underlying method.
"""
return self.method(oself, *args, **kwargs)
def _name(self) -> str:
return self.method.__name__
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: Automaton[MethodicalState, MethodicalInput, MethodicalOutput] = field(
repr=False
)
symbol: str = field(repr=False)
def __get__(
self, oself: object, type: object = None
) -> Callable[[StringTracer], None]:
transitioner = _transitionerFromInstance(oself, self.symbol, self.automaton)
def setTrace(tracer: StringTracer | None) -> None:
transitioner.setTrace(wrapTracer(tracer))
return setTrace
counter = count()
def gensym():
"""
Create a unique Python identifier.
"""
return "_symbol_" + str(next(counter))
class MethodicalMachine(object):
"""
A L{MethodicalMachine} is an interface to an L{Automaton} that uses methods
on a class.
"""
def __init__(self):
self._automaton = Automaton()
self._reducers = {}
self._symbol = gensym()
def __get__(self, oself, type=None):
"""
L{MethodicalMachine} is an implementation detail for setting up
class-level state; applications should never need to access it on an
instance.
"""
if oself is not None:
raise AttributeError("MethodicalMachine is an implementation detail.")
return self
@_keywords_only
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 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 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 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):
state = MethodicalState(
machine=self, method=stateMethod, serialized=serialized
)
if initial:
self._automaton.initialState = state
return state
return decorator
@_keywords_only
def input(self):
"""
Declare an input.
This is a decorator for methods.
"""
def decorator(inputMethod):
return MethodicalInput(
automaton=self._automaton, method=inputMethod, symbol=self._symbol
)
return decorator
@_keywords_only
def output(self):
"""
Declare an output.
This is a decorator for methods.
This method will be called when the state machine transitions to this
state as specified in the decorated `output` method.
"""
def decorator(outputMethod):
return MethodicalOutput(machine=self, method=outputMethod)
return decorator
def _oneTransition(self, startState, inputToken, endState, outputTokens, collector):
"""
See L{MethodicalState.upon}.
"""
# FIXME: tests for all of this (some of it is wrong)
# if not isinstance(startState, MethodicalState):
# raise NotImplementedError("start state {} isn't a state"
# .format(startState))
# if not isinstance(inputToken, MethodicalInput):
# raise NotImplementedError("start state {} isn't an input"
# .format(inputToken))
# if not isinstance(endState, MethodicalState):
# raise NotImplementedError("end state {} isn't a state"
# .format(startState))
# for output in outputTokens:
# if not isinstance(endState, MethodicalState):
# raise NotImplementedError("output state {} isn't a state"
# .format(endState))
self._automaton.addTransition(
startState, inputToken, endState, tuple(outputTokens)
)
inputToken.collectors[startState] = collector
@_keywords_only
def serializer(self):
""" """
def decorator(decoratee):
@wraps(decoratee)
def serialize(oself):
transitioner = _transitionerFromInstance(
oself, self._symbol, self._automaton
)
return decoratee(oself, transitioner._state.serialized)
return serialize
return decorator
@_keywords_only
def unserializer(self):
""" """
def decorator(decoratee):
@wraps(decoratee)
def unserialize(oself, *args, **kwargs):
state = decoratee(oself, *args, **kwargs)
mapping = {}
for eachState in self._automaton.states():
mapping[eachState.serialized] = eachState
transitioner = _transitionerFromInstance(
oself, self._symbol, self._automaton
)
transitioner._state = mapping[state]
return None # it's on purpose
return unserialize
return decorator
@property
def _setTrace(self) -> MethodicalTracer:
return MethodicalTracer(self._automaton, self._symbol)
def asDigraph(self):
"""
Generate a L{graphviz.Digraph} that represents this machine's
states and transitions.
@return: L{graphviz.Digraph} object; for more information, please
see the documentation for
U{graphviz<https://graphviz.readthedocs.io/>}
"""
from ._visualize import makeDigraph
return makeDigraph(
self._automaton,
stateAsString=lambda state: state.method.__name__,
inputAsString=lambda input: input.method.__name__,
outputAsString=lambda output: output.method.__name__,
)

View File

@@ -0,0 +1,62 @@
"""
Workaround for U{the lack of TypeForm
<https://github.com/python/mypy/issues/9773>}.
"""
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

View File

@@ -0,0 +1,97 @@
from unittest import TestCase
from .._core import Automaton, NoTransition, Transitioner
class CoreTests(TestCase):
"""
Tests for Automat's (currently private, implementation detail) core.
"""
def test_NoTransition(self):
"""
A L{NoTransition} exception describes the state and input symbol
that caused it.
"""
# NoTransition requires two arguments
with self.assertRaises(TypeError):
NoTransition()
state = "current-state"
symbol = "transitionless-symbol"
noTransitionException = NoTransition(state=state, symbol=symbol)
self.assertIs(noTransitionException.symbol, symbol)
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
transition for that input is defined.
"""
a = Automaton()
self.assertRaises(NoTransition, a.outputForInput, "no-state", "no-symbol")
def test_oneTransition(self):
"""
L{Automaton.addTransition} adds its input symbol to
L{Automaton.inputAlphabet}, all its outputs to
L{Automaton.outputAlphabet}, and causes L{Automaton.outputForInput} to
start returning the new state and output symbols.
"""
a = Automaton()
a.addTransition("beginning", "begin", "ending", ["end"])
self.assertEqual(a.inputAlphabet(), {"begin"})
self.assertEqual(a.outputAlphabet(), {"end"})
self.assertEqual(a.outputForInput("beginning", "begin"), ("ending", ["end"]))
self.assertEqual(a.states(), {"beginning", "ending"})
def test_oneTransition_nonIterableOutputs(self):
"""
L{Automaton.addTransition} raises a TypeError when given outputs
that aren't iterable and doesn't add any transitions.
"""
a = Automaton()
nonIterableOutputs = 1
self.assertRaises(
TypeError,
a.addTransition,
"fromState",
"viaSymbol",
"toState",
nonIterableOutputs,
)
self.assertFalse(a.inputAlphabet())
self.assertFalse(a.outputAlphabet())
self.assertFalse(a.states())
self.assertFalse(a.allTransitions())
def test_initialState(self):
"""
L{Automaton.initialState} is a descriptor that sets the initial
state if it's not yet set, and raises L{ValueError} if it is.
"""
a = Automaton()
a.initialState = "a state"
self.assertEqual(a.initialState, "a state")
with self.assertRaises(ValueError):
a.initialState = "another state"
# FIXME: addTransition for transition that's been added before

View File

@@ -0,0 +1,638 @@
import operator
import os
import shutil
import sys
import textwrap
import tempfile
from unittest import skipIf, TestCase
def isTwistedInstalled():
try:
__import__("twisted")
except ImportError:
return False
else:
return True
class _WritesPythonModules(TestCase):
"""
A helper that enables generating Python module test fixtures.
"""
def setUp(self):
super(_WritesPythonModules, self).setUp()
from twisted.python.modules import getModule, PythonPath
from twisted.python.filepath import FilePath
self.getModule = getModule
self.PythonPath = PythonPath
self.FilePath = FilePath
self.originalSysModules = set(sys.modules.keys())
self.savedSysPath = sys.path[:]
self.pathDir = tempfile.mkdtemp()
self.makeImportable(self.pathDir)
def tearDown(self):
super(_WritesPythonModules, self).tearDown()
sys.path[:] = self.savedSysPath
modulesToDelete = sys.modules.keys() - self.originalSysModules
for module in modulesToDelete:
del sys.modules[module]
shutil.rmtree(self.pathDir)
def makeImportable(self, path):
sys.path.append(path)
def writeSourceInto(self, source, path, moduleName):
directory = self.FilePath(path)
module = directory.child(moduleName)
# FilePath always opens a file in binary mode - but that will
# break on Python 3
with open(module.path, "w") as f:
f.write(textwrap.dedent(source))
return self.PythonPath([directory.path])
def makeModule(self, source, path, moduleName):
pythonModuleName, _ = os.path.splitext(moduleName)
return self.writeSourceInto(source, path, moduleName)[pythonModuleName]
def attributesAsDict(self, hasIterAttributes):
return {attr.name: attr for attr in hasIterAttributes.iterAttributes()}
def loadModuleAsDict(self, module):
module.load()
return self.attributesAsDict(module)
def makeModuleAsDict(self, source, path, name):
return self.loadModuleAsDict(self.makeModule(source, path, name))
@skipIf(not isTwistedInstalled(), "Twisted is not installed.")
class OriginalLocationTests(_WritesPythonModules):
"""
Tests that L{isOriginalLocation} detects when a
L{PythonAttribute}'s FQPN refers to an object inside the module
where it was defined.
For example: A L{twisted.python.modules.PythonAttribute} with a
name of 'foo.bar' that refers to a 'bar' object defined in module
'baz' does *not* refer to bar's original location, while a
L{PythonAttribute} with a name of 'baz.bar' does.
"""
def setUp(self):
super(OriginalLocationTests, self).setUp()
from .._discover import isOriginalLocation
self.isOriginalLocation = isOriginalLocation
def test_failsWithNoModule(self):
"""
L{isOriginalLocation} returns False when the attribute refers to an
object whose source module cannot be determined.
"""
source = """\
class Fake(object):
pass
hasEmptyModule = Fake()
hasEmptyModule.__module__ = None
"""
moduleDict = self.makeModuleAsDict(source, self.pathDir, "empty_module_attr.py")
self.assertFalse(
self.isOriginalLocation(moduleDict["empty_module_attr.hasEmptyModule"])
)
def test_failsWithDifferentModule(self):
"""
L{isOriginalLocation} returns False when the attribute refers to
an object outside of the module where that object was defined.
"""
originalSource = """\
class ImportThisClass(object):
pass
importThisObject = ImportThisClass()
importThisNestingObject = ImportThisClass()
importThisNestingObject.nestedObject = ImportThisClass()
"""
importingSource = """\
from original import (ImportThisClass,
importThisObject,
importThisNestingObject)
"""
self.makeModule(originalSource, self.pathDir, "original.py")
importingDict = self.makeModuleAsDict(
importingSource, self.pathDir, "importing.py"
)
self.assertFalse(
self.isOriginalLocation(importingDict["importing.ImportThisClass"])
)
self.assertFalse(
self.isOriginalLocation(importingDict["importing.importThisObject"])
)
nestingObject = importingDict["importing.importThisNestingObject"]
nestingObjectDict = self.attributesAsDict(nestingObject)
nestedObject = nestingObjectDict[
"importing.importThisNestingObject.nestedObject"
]
self.assertFalse(self.isOriginalLocation(nestedObject))
def test_succeedsWithSameModule(self):
"""
L{isOriginalLocation} returns True when the attribute refers to an
object inside the module where that object was defined.
"""
mSource = textwrap.dedent(
"""
class ThisClassWasDefinedHere(object):
pass
anObject = ThisClassWasDefinedHere()
aNestingObject = ThisClassWasDefinedHere()
aNestingObject.nestedObject = ThisClassWasDefinedHere()
"""
)
mDict = self.makeModuleAsDict(mSource, self.pathDir, "m.py")
self.assertTrue(self.isOriginalLocation(mDict["m.ThisClassWasDefinedHere"]))
self.assertTrue(self.isOriginalLocation(mDict["m.aNestingObject"]))
nestingObject = mDict["m.aNestingObject"]
nestingObjectDict = self.attributesAsDict(nestingObject)
nestedObject = nestingObjectDict["m.aNestingObject.nestedObject"]
self.assertTrue(self.isOriginalLocation(nestedObject))
@skipIf(not isTwistedInstalled(), "Twisted is not installed.")
class FindMachinesViaWrapperTests(_WritesPythonModules):
"""
L{findMachinesViaWrapper} recursively yields FQPN,
L{MethodicalMachine} pairs in and under a given
L{twisted.python.modules.PythonModule} or
L{twisted.python.modules.PythonAttribute}.
"""
def setUp(self):
super(FindMachinesViaWrapperTests, self).setUp()
from .._discover import findMachinesViaWrapper
self.findMachinesViaWrapper = findMachinesViaWrapper
def test_yieldsMachine(self):
"""
When given a L{twisted.python.modules.PythonAttribute} that refers
directly to a L{MethodicalMachine}, L{findMachinesViaWrapper}
yields that machine and its FQPN.
"""
source = """\
from automat import MethodicalMachine
rootMachine = MethodicalMachine()
"""
moduleDict = self.makeModuleAsDict(source, self.pathDir, "root.py")
rootMachine = moduleDict["root.rootMachine"]
self.assertIn(
("root.rootMachine", rootMachine.load()),
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
to a class that contains a L{MethodicalMachine} as a class
variable, L{findMachinesViaWrapper} yields that machine and
its FQPN.
"""
source = """\
from automat import MethodicalMachine
class PythonClass(object):
_classMachine = MethodicalMachine()
"""
moduleDict = self.makeModuleAsDict(source, self.pathDir, "clsmod.py")
PythonClass = moduleDict["clsmod.PythonClass"]
self.assertIn(
("clsmod.PythonClass._classMachine", PythonClass.load()._classMachine),
list(self.findMachinesViaWrapper(PythonClass)),
)
def test_yieldsMachineInNestedClass(self):
"""
When given a L{twisted.python.modules.PythonAttribute} that refers
to a nested class that contains a L{MethodicalMachine} as a
class variable, L{findMachinesViaWrapper} yields that machine
and its FQPN.
"""
source = """\
from automat import MethodicalMachine
class PythonClass(object):
class NestedClass(object):
_classMachine = MethodicalMachine()
"""
moduleDict = self.makeModuleAsDict(source, self.pathDir, "nestedcls.py")
PythonClass = moduleDict["nestedcls.PythonClass"]
self.assertIn(
(
"nestedcls.PythonClass.NestedClass._classMachine",
PythonClass.load().NestedClass._classMachine,
),
list(self.findMachinesViaWrapper(PythonClass)),
)
def test_yieldsMachineInModule(self):
"""
When given a L{twisted.python.modules.PythonModule} that refers to
a module that contains a L{MethodicalMachine},
L{findMachinesViaWrapper} yields that machine and its FQPN.
"""
source = """\
from automat import MethodicalMachine
rootMachine = MethodicalMachine()
"""
module = self.makeModule(source, self.pathDir, "root.py")
rootMachine = self.loadModuleAsDict(module)["root.rootMachine"].load()
self.assertIn(
("root.rootMachine", rootMachine), list(self.findMachinesViaWrapper(module))
)
def test_yieldsMachineInClassInModule(self):
"""
When given a L{twisted.python.modules.PythonModule} that refers to
the original module of a class containing a
L{MethodicalMachine}, L{findMachinesViaWrapper} yields that
machine and its FQPN.
"""
source = """\
from automat import MethodicalMachine
class PythonClass(object):
_classMachine = MethodicalMachine()
"""
module = self.makeModule(source, self.pathDir, "clsmod.py")
PythonClass = self.loadModuleAsDict(module)["clsmod.PythonClass"].load()
self.assertIn(
("clsmod.PythonClass._classMachine", PythonClass._classMachine),
list(self.findMachinesViaWrapper(module)),
)
def test_yieldsMachineInNestedClassInModule(self):
"""
When given a L{twisted.python.modules.PythonModule} that refers to
the original module of a nested class containing a
L{MethodicalMachine}, L{findMachinesViaWrapper} yields that
machine and its FQPN.
"""
source = """\
from automat import MethodicalMachine
class PythonClass(object):
class NestedClass(object):
_classMachine = MethodicalMachine()
"""
module = self.makeModule(source, self.pathDir, "nestedcls.py")
PythonClass = self.loadModuleAsDict(module)["nestedcls.PythonClass"].load()
self.assertIn(
(
"nestedcls.PythonClass.NestedClass._classMachine",
PythonClass.NestedClass._classMachine,
),
list(self.findMachinesViaWrapper(module)),
)
def test_ignoresImportedClass(self):
"""
When given a L{twisted.python.modules.PythonAttribute} that refers
to a class imported from another module, any
L{MethodicalMachine}s on that class are ignored.
This behavior ensures that a machine is only discovered on a
class when visiting the module where that class was defined.
"""
originalSource = """
from automat import MethodicalMachine
class PythonClass(object):
_classMachine = MethodicalMachine()
"""
importingSource = """
from original import PythonClass
"""
self.makeModule(originalSource, self.pathDir, "original.py")
importingModule = self.makeModule(importingSource, self.pathDir, "importing.py")
self.assertFalse(list(self.findMachinesViaWrapper(importingModule)))
def test_descendsIntoPackages(self):
"""
L{findMachinesViaWrapper} descends into packages to discover
machines.
"""
pythonPath = self.PythonPath([self.pathDir])
package = self.FilePath(self.pathDir).child("test_package")
package.makedirs()
package.child("__init__.py").touch()
source = """
from automat import MethodicalMachine
class PythonClass(object):
_classMachine = MethodicalMachine()
rootMachine = MethodicalMachine()
"""
self.makeModule(source, package.path, "module.py")
test_package = pythonPath["test_package"]
machines = sorted(
self.findMachinesViaWrapper(test_package), key=operator.itemgetter(0)
)
moduleDict = self.loadModuleAsDict(test_package["module"])
rootMachine = moduleDict["test_package.module.rootMachine"].load()
PythonClass = moduleDict["test_package.module.PythonClass"].load()
expectedMachines = sorted(
[
("test_package.module.rootMachine", rootMachine),
(
"test_package.module.PythonClass._classMachine",
PythonClass._classMachine,
),
],
key=operator.itemgetter(0),
)
self.assertEqual(expectedMachines, machines)
def test_infiniteLoop(self):
"""
L{findMachinesViaWrapper} ignores infinite loops.
Note this test can't fail - it can only run forever!
"""
source = """
class InfiniteLoop(object):
pass
InfiniteLoop.loop = InfiniteLoop
"""
module = self.makeModule(source, self.pathDir, "loop.py")
self.assertFalse(list(self.findMachinesViaWrapper(module)))
@skipIf(not isTwistedInstalled(), "Twisted is not installed.")
class WrapFQPNTests(TestCase):
"""
Tests that ensure L{wrapFQPN} loads the
L{twisted.python.modules.PythonModule} or
L{twisted.python.modules.PythonAttribute} for a given FQPN.
"""
def setUp(self):
from twisted.python.modules import PythonModule, PythonAttribute
from .._discover import wrapFQPN, InvalidFQPN, NoModule, NoObject
self.PythonModule = PythonModule
self.PythonAttribute = PythonAttribute
self.wrapFQPN = wrapFQPN
self.InvalidFQPN = InvalidFQPN
self.NoModule = NoModule
self.NoObject = NoObject
def assertModuleWrapperRefersTo(self, moduleWrapper, module):
"""
Assert that a L{twisted.python.modules.PythonModule} refers to a
particular Python module.
"""
self.assertIsInstance(moduleWrapper, self.PythonModule)
self.assertEqual(moduleWrapper.name, module.__name__)
self.assertIs(moduleWrapper.load(), module)
def assertAttributeWrapperRefersTo(self, attributeWrapper, fqpn, obj):
"""
Assert that a L{twisted.python.modules.PythonAttribute} refers to a
particular Python object.
"""
self.assertIsInstance(attributeWrapper, self.PythonAttribute)
self.assertEqual(attributeWrapper.name, fqpn)
self.assertIs(attributeWrapper.load(), obj)
def test_failsWithEmptyFQPN(self):
"""
L{wrapFQPN} raises L{InvalidFQPN} when given an empty string.
"""
with self.assertRaises(self.InvalidFQPN):
self.wrapFQPN("")
def test_failsWithBadDotting(self):
""" "
L{wrapFQPN} raises L{InvalidFQPN} when given a badly-dotted
FQPN. (e.g., x..y).
"""
for bad in (".fails", "fails.", "this..fails"):
with self.assertRaises(self.InvalidFQPN):
self.wrapFQPN(bad)
def test_singleModule(self):
"""
L{wrapFQPN} returns a L{twisted.python.modules.PythonModule}
referring to the single module a dotless FQPN describes.
"""
import os
moduleWrapper = self.wrapFQPN("os")
self.assertIsInstance(moduleWrapper, self.PythonModule)
self.assertIs(moduleWrapper.load(), os)
def test_failsWithMissingSingleModuleOrPackage(self):
"""
L{wrapFQPN} raises L{NoModule} when given a dotless FQPN that does
not refer to a module or package.
"""
with self.assertRaises(self.NoModule):
self.wrapFQPN("this is not an acceptable name!")
def test_singlePackage(self):
"""
L{wrapFQPN} returns a L{twisted.python.modules.PythonModule}
referring to the single package a dotless FQPN describes.
"""
import xml
self.assertModuleWrapperRefersTo(self.wrapFQPN("xml"), xml)
def test_multiplePackages(self):
"""
L{wrapFQPN} returns a L{twisted.python.modules.PythonModule}
referring to the deepest package described by dotted FQPN.
"""
import xml.etree
self.assertModuleWrapperRefersTo(self.wrapFQPN("xml.etree"), xml.etree)
def test_multiplePackagesFinalModule(self):
"""
L{wrapFQPN} returns a L{twisted.python.modules.PythonModule}
referring to the deepest module described by dotted FQPN.
"""
import xml.etree.ElementTree
self.assertModuleWrapperRefersTo(
self.wrapFQPN("xml.etree.ElementTree"), xml.etree.ElementTree
)
def test_singleModuleObject(self):
"""
L{wrapFQPN} returns a L{twisted.python.modules.PythonAttribute}
referring to the deepest object an FQPN names, traversing one module.
"""
import os
self.assertAttributeWrapperRefersTo(
self.wrapFQPN("os.path"), "os.path", os.path
)
def test_multiplePackagesObject(self):
"""
L{wrapFQPN} returns a L{twisted.python.modules.PythonAttribute}
referring to the deepest object described by an FQPN,
descending through several packages.
"""
import xml.etree.ElementTree
import automat
for fqpn, obj in [
("xml.etree.ElementTree.fromstring", xml.etree.ElementTree.fromstring),
("automat.MethodicalMachine.__doc__", automat.MethodicalMachine.__doc__),
]:
self.assertAttributeWrapperRefersTo(self.wrapFQPN(fqpn), fqpn, obj)
def test_failsWithMultiplePackagesMissingModuleOrPackage(self):
"""
L{wrapFQPN} raises L{NoObject} when given an FQPN that contains a
missing attribute, module, or package.
"""
for bad in ("xml.etree.nope!", "xml.etree.nope!.but.the.rest.is.believable"):
with self.assertRaises(self.NoObject):
self.wrapFQPN(bad)
@skipIf(not isTwistedInstalled(), "Twisted is not installed.")
class FindMachinesIntegrationTests(_WritesPythonModules):
"""
Integration tests to check that L{findMachines} yields all
machines discoverable at or below an FQPN.
"""
SOURCE = """
from automat import MethodicalMachine
class PythonClass(object):
_machine = MethodicalMachine()
ignored = "i am ignored"
rootLevel = MethodicalMachine()
ignored = "i am ignored"
"""
def setUp(self):
super(FindMachinesIntegrationTests, self).setUp()
from .._discover import findMachines
self.findMachines = findMachines
packageDir = self.FilePath(self.pathDir).child("test_package")
packageDir.makedirs()
self.pythonPath = self.PythonPath([self.pathDir])
self.writeSourceInto(self.SOURCE, packageDir.path, "__init__.py")
subPackageDir = packageDir.child("subpackage")
subPackageDir.makedirs()
subPackageDir.child("__init__.py").touch()
self.makeModule(self.SOURCE, subPackageDir.path, "module.py")
self.packageDict = self.loadModuleAsDict(self.pythonPath["test_package"])
self.moduleDict = self.loadModuleAsDict(
self.pythonPath["test_package"]["subpackage"]["module"]
)
def test_discoverAll(self):
"""
Given a top-level package FQPN, L{findMachines} discovers all
L{MethodicalMachine} instances in and below it.
"""
machines = sorted(self.findMachines("test_package"), key=operator.itemgetter(0))
tpRootLevel = self.packageDict["test_package.rootLevel"].load()
tpPythonClass = self.packageDict["test_package.PythonClass"].load()
mRLAttr = self.moduleDict["test_package.subpackage.module.rootLevel"]
mRootLevel = mRLAttr.load()
mPCAttr = self.moduleDict["test_package.subpackage.module.PythonClass"]
mPythonClass = mPCAttr.load()
expectedMachines = sorted(
[
("test_package.rootLevel", tpRootLevel),
("test_package.PythonClass._machine", tpPythonClass._machine),
("test_package.subpackage.module.rootLevel", mRootLevel),
(
"test_package.subpackage.module.PythonClass._machine",
mPythonClass._machine,
),
],
key=operator.itemgetter(0),
)
self.assertEqual(expectedMachines, machines)

View File

@@ -0,0 +1,717 @@
"""
Tests for the public interface of Automat.
"""
from functools import reduce
from unittest import TestCase
from automat._methodical import ArgSpec, _getArgNames, _getArgSpec, _filterArgs
from .. import MethodicalMachine, NoTransition
from .. import _methodical
class MethodicalTests(TestCase):
"""
Tests for L{MethodicalMachine}.
"""
def test_oneTransition(self):
"""
L{MethodicalMachine} provides a way for you to declare a state machine
with inputs, outputs, and states as methods. When you have declared an
input, an output, and a state, calling the input method in that state
will produce the specified output.
"""
class Machination(object):
machine = MethodicalMachine()
@machine.input()
def anInput(self):
"an input"
@machine.output()
def anOutput(self):
"an output"
return "an-output-value"
@machine.output()
def anotherOutput(self):
"another output"
return "another-output-value"
@machine.state(initial=True)
def anState(self):
"a state"
@machine.state()
def anotherState(self):
"another state"
anState.upon(anInput, enter=anotherState, outputs=[anOutput])
anotherState.upon(anInput, enter=anotherState, outputs=[anotherOutput])
m = Machination()
self.assertEqual(m.anInput(), ["an-output-value"])
self.assertEqual(m.anInput(), ["another-output-value"])
def test_machineItselfIsPrivate(self):
"""
L{MethodicalMachine} is an implementation detail. If you attempt to
access it on an instance of your class, you will get an exception.
However, since tools may need to access it for the purposes of, for
example, visualization, you may access it on the class itself.
"""
expectedMachine = MethodicalMachine()
class Machination(object):
machine = expectedMachine
machination = Machination()
with self.assertRaises(AttributeError) as cm:
machination.machine
self.assertIn(
"MethodicalMachine is an implementation detail", str(cm.exception)
)
self.assertIs(Machination.machine, expectedMachine)
def test_outputsArePrivate(self):
"""
One of the benefits of using a state machine is that your output method
implementations don't need to take invalid state transitions into
account - the methods simply won't be called. This property would be
broken if client code called output methods directly, so output methods
are not directly visible under their names.
"""
class Machination(object):
machine = MethodicalMachine()
counter = 0
@machine.input()
def anInput(self):
"an input"
@machine.output()
def anOutput(self):
self.counter += 1
@machine.state(initial=True)
def state(self):
"a machine state"
state.upon(anInput, enter=state, outputs=[anOutput])
mach1 = Machination()
mach1.anInput()
self.assertEqual(mach1.counter, 1)
mach2 = Machination()
with self.assertRaises(AttributeError) as cm:
mach2.anOutput
self.assertEqual(mach2.counter, 0)
self.assertIn(
"Machination.anOutput is a state-machine output method; to "
"produce this output, call an input method instead.",
str(cm.exception),
)
def test_multipleMachines(self):
"""
Two machines may co-exist happily on the same instance; they don't
interfere with each other.
"""
class MultiMach(object):
a = MethodicalMachine()
b = MethodicalMachine()
@a.input()
def inputA(self):
"input A"
@b.input()
def inputB(self):
"input B"
@a.state(initial=True)
def initialA(self):
"initial A"
@b.state(initial=True)
def initialB(self):
"initial B"
@a.output()
def outputA(self):
return "A"
@b.output()
def outputB(self):
return "B"
initialA.upon(inputA, initialA, [outputA])
initialB.upon(inputB, initialB, [outputB])
mm = MultiMach()
self.assertEqual(mm.inputA(), ["A"])
self.assertEqual(mm.inputB(), ["B"])
def test_collectOutputs(self):
"""
Outputs can be combined with the "collector" argument to "upon".
"""
import operator
class Machine(object):
m = MethodicalMachine()
@m.input()
def input(self):
"an input"
@m.output()
def outputA(self):
return "A"
@m.output()
def outputB(self):
return "B"
@m.state(initial=True)
def state(self):
"a state"
state.upon(
input,
state,
[outputA, outputB],
collector=lambda x: reduce(operator.add, x),
)
m = Machine()
self.assertEqual(m.input(), "AB")
def test_methodName(self):
"""
Input methods preserve their declared names.
"""
class Mech(object):
m = MethodicalMachine()
@m.input()
def declaredInputName(self):
"an input"
@m.state(initial=True)
def aState(self):
"state"
m = Mech()
with self.assertRaises(TypeError) as cm:
m.declaredInputName("too", "many", "arguments")
self.assertIn("declaredInputName", str(cm.exception))
def test_inputWithArguments(self):
"""
If an input takes an argument, it will pass that along to its output.
"""
class Mechanism(object):
m = MethodicalMachine()
@m.input()
def input(self, x, y=1):
"an input"
@m.state(initial=True)
def state(self):
"a state"
@m.output()
def output(self, x, y=1):
self._x = x
return x + y
state.upon(input, state, [output])
m = Mechanism()
self.assertEqual(m.input(3), [4])
self.assertEqual(m._x, 3)
def test_outputWithSubsetOfArguments(self):
"""
Inputs pass arguments that output will accept.
"""
class Mechanism(object):
m = MethodicalMachine()
@m.input()
def input(self, x, y=1):
"an input"
@m.state(initial=True)
def state(self):
"a state"
@m.output()
def outputX(self, x):
self._x = x
return x
@m.output()
def outputY(self, y):
self._y = y
return y
@m.output()
def outputNoArgs(self):
return None
state.upon(input, state, [outputX, outputY, outputNoArgs])
m = Mechanism()
# Pass x as positional argument.
self.assertEqual(m.input(3), [3, 1, None])
self.assertEqual(m._x, 3)
self.assertEqual(m._y, 1)
# Pass x as key word argument.
self.assertEqual(m.input(x=4), [4, 1, None])
self.assertEqual(m._x, 4)
self.assertEqual(m._y, 1)
# Pass y as positional argument.
self.assertEqual(m.input(6, 3), [6, 3, None])
self.assertEqual(m._x, 6)
self.assertEqual(m._y, 3)
# Pass y as key word argument.
self.assertEqual(m.input(5, y=2), [5, 2, None])
self.assertEqual(m._x, 5)
self.assertEqual(m._y, 2)
def test_inputFunctionsMustBeEmpty(self):
"""
The wrapped input function must have an empty body.
"""
# input functions are executed to assert that the signature matches,
# but their body must be empty
_methodical._empty() # chase coverage
_methodical._docstring()
class Mechanism(object):
m = MethodicalMachine()
with self.assertRaises(ValueError) as cm:
@m.input()
def input(self):
"an input"
list() # pragma: no cover
self.assertEqual(str(cm.exception), "function body must be empty")
# all three of these cases should be valid. Functions/methods with
# docstrings produce slightly different bytecode than ones without.
class MechanismWithDocstring(object):
m = MethodicalMachine()
@m.input()
def input(self):
"an input"
@m.state(initial=True)
def start(self):
"starting state"
start.upon(input, enter=start, outputs=[])
MechanismWithDocstring().input()
class MechanismWithPass(object):
m = MethodicalMachine()
@m.input()
def input(self):
pass
@m.state(initial=True)
def start(self):
"starting state"
start.upon(input, enter=start, outputs=[])
MechanismWithPass().input()
class MechanismWithDocstringAndPass(object):
m = MethodicalMachine()
@m.input()
def input(self):
"an input"
pass
@m.state(initial=True)
def start(self):
"starting state"
start.upon(input, enter=start, outputs=[])
MechanismWithDocstringAndPass().input()
class MechanismReturnsNone(object):
m = MethodicalMachine()
@m.input()
def input(self):
return None
@m.state(initial=True)
def start(self):
"starting state"
start.upon(input, enter=start, outputs=[])
MechanismReturnsNone().input()
class MechanismWithDocstringAndReturnsNone(object):
m = MethodicalMachine()
@m.input()
def input(self):
"an input"
return None
@m.state(initial=True)
def start(self):
"starting state"
start.upon(input, enter=start, outputs=[])
MechanismWithDocstringAndReturnsNone().input()
def test_inputOutputMismatch(self):
"""
All the argument lists of the outputs for a given input must match; if
one does not the call to C{upon} will raise a C{TypeError}.
"""
class Mechanism(object):
m = MethodicalMachine()
@m.input()
def nameOfInput(self, a):
"an input"
@m.output()
def outputThatMatches(self, a):
"an output that matches"
@m.output()
def outputThatDoesntMatch(self, b):
"an output that doesn't match"
@m.state()
def state(self):
"a state"
with self.assertRaises(TypeError) as cm:
state.upon(
nameOfInput, state, [outputThatMatches, outputThatDoesntMatch]
)
self.assertIn("nameOfInput", str(cm.exception))
self.assertIn("outputThatDoesntMatch", str(cm.exception))
def test_stateLoop(self):
"""
It is possible to write a self-loop by omitting "enter"
"""
class Mechanism(object):
m = MethodicalMachine()
@m.input()
def input(self):
"an input"
@m.input()
def say_hi(self):
"an input"
@m.output()
def _start_say_hi(self):
return "hi"
@m.state(initial=True)
def start(self):
"a state"
def said_hi(self):
"a state with no inputs"
start.upon(input, outputs=[])
start.upon(say_hi, outputs=[_start_say_hi])
a_mechanism = Mechanism()
[a_greeting] = a_mechanism.say_hi()
self.assertEqual(a_greeting, "hi")
def test_defaultOutputs(self):
"""
It is possible to write a transition with no outputs
"""
class Mechanism(object):
m = MethodicalMachine()
@m.input()
def finish(self):
"final transition"
@m.state(initial=True)
def start(self):
"a start state"
@m.state()
def finished(self):
"a final state"
start.upon(finish, enter=finished)
Mechanism().finish()
def test_getArgNames(self):
"""
Type annotations should be included in the set of
"""
spec = ArgSpec(
args=("a", "b"),
varargs=None,
varkw=None,
defaults=None,
kwonlyargs=(),
kwonlydefaults=None,
annotations=(("a", int), ("b", str)),
)
self.assertEqual(
_getArgNames(spec),
{"a", "b", ("a", int), ("b", str)},
)
def test_filterArgs(self):
"""
filterArgs() should not filter the `args` parameter
if outputSpec accepts `*args`.
"""
inputSpec = _getArgSpec(lambda *args, **kwargs: None)
outputSpec = _getArgSpec(lambda *args, **kwargs: None)
argsIn = ()
argsOut, _ = _filterArgs(argsIn, {}, inputSpec, outputSpec)
self.assertIs(argsIn, argsOut)
def test_multipleInitialStatesFailure(self):
"""
A L{MethodicalMachine} can only have one initial state.
"""
class WillFail(object):
m = MethodicalMachine()
@m.state(initial=True)
def firstInitialState(self):
"The first initial state -- this is OK."
with self.assertRaises(ValueError):
@m.state(initial=True)
def secondInitialState(self):
"The second initial state -- results in a ValueError."
def test_multipleTransitionsFailure(self):
"""
A L{MethodicalMachine} can only have one transition per start/event
pair.
"""
class WillFail(object):
m = MethodicalMachine()
@m.state(initial=True)
def start(self):
"We start here."
@m.state()
def end(self):
"Rainbows end."
@m.input()
def event(self):
"An event."
start.upon(event, enter=end, outputs=[])
with self.assertRaises(ValueError):
start.upon(event, enter=end, outputs=[])
def test_badTransitionForCurrentState(self):
"""
Calling any input method that lacks a transition for the machine's
current state raises an informative L{NoTransition}.
"""
class OnlyOnePath(object):
m = MethodicalMachine()
@m.state(initial=True)
def start(self):
"Start state."
@m.state()
def end(self):
"End state."
@m.input()
def advance(self):
"Move from start to end."
@m.input()
def deadEnd(self):
"A transition from nowhere to nowhere."
start.upon(advance, end, [])
machine = OnlyOnePath()
with self.assertRaises(NoTransition) as cm:
machine.deadEnd()
self.assertIn("deadEnd", str(cm.exception))
self.assertIn("start", str(cm.exception))
machine.advance()
with self.assertRaises(NoTransition) as cm:
machine.deadEnd()
self.assertIn("deadEnd", str(cm.exception))
self.assertIn("end", str(cm.exception))
def test_saveState(self):
"""
L{MethodicalMachine.serializer} is a decorator that modifies its
decoratee's signature to take a "state" object as its first argument,
which is the "serialized" argument to the L{MethodicalMachine.state}
decorator.
"""
class Mechanism(object):
m = MethodicalMachine()
def __init__(self):
self.value = 1
@m.state(serialized="first-state", initial=True)
def first(self):
"First state."
@m.state(serialized="second-state")
def second(self):
"Second state."
@m.serializer()
def save(self, state):
return {
"machine-state": state,
"some-value": self.value,
}
self.assertEqual(
Mechanism().save(),
{
"machine-state": "first-state",
"some-value": 1,
},
)
def test_restoreState(self):
"""
L{MethodicalMachine.unserializer} decorates a function that becomes a
machine-state unserializer; its return value is mapped to the
C{serialized} parameter to C{state}, and the L{MethodicalMachine}
associated with that instance's state is updated to that state.
"""
class Mechanism(object):
m = MethodicalMachine()
def __init__(self):
self.value = 1
self.ranOutput = False
@m.state(serialized="first-state", initial=True)
def first(self):
"First state."
@m.state(serialized="second-state")
def second(self):
"Second state."
@m.input()
def input(self):
"an input"
@m.output()
def output(self):
self.value = 2
self.ranOutput = True
return 1
@m.output()
def output2(self):
return 2
first.upon(input, second, [output], collector=lambda x: list(x)[0])
second.upon(input, second, [output2], collector=lambda x: list(x)[0])
@m.serializer()
def save(self, state):
return {
"machine-state": state,
"some-value": self.value,
}
@m.unserializer()
def _restore(self, blob):
self.value = blob["some-value"]
return blob["machine-state"]
@classmethod
def fromBlob(cls, blob):
self = cls()
self._restore(blob)
return self
m1 = Mechanism()
m1.input()
blob = m1.save()
m2 = Mechanism.fromBlob(blob)
self.assertEqual(m2.ranOutput, False)
self.assertEqual(m2.input(), 2)
self.assertEqual(
m2.save(),
{
"machine-state": "second-state",
"some-value": 2,
},
)
# FIXME: error for wrong types on any call to _oneTransition
# FIXME: better public API for .upon; maybe a context manager?
# FIXME: when transitions are defined, validate that we can always get to
# terminal? do we care about this?
# FIXME: implementation (and use-case/example) for passing args from in to out
# FIXME: possibly these need some kind of support from core
# FIXME: wildcard state (in all states, when input X, emit Y and go to Z)
# FIXME: wildcard input (in state X, when any input, emit Y and go to Z)
# FIXME: combined wildcards (in any state for any input, emit Y go to Z)

View File

@@ -0,0 +1,142 @@
from unittest import TestCase
from .._methodical import MethodicalMachine
class SampleObject(object):
mm = MethodicalMachine()
@mm.state(initial=True)
def begin(self):
"initial state"
@mm.state()
def middle(self):
"middle state"
@mm.state()
def end(self):
"end state"
@mm.input()
def go1(self):
"sample input"
@mm.input()
def go2(self):
"sample input"
@mm.input()
def back(self):
"sample input"
@mm.output()
def out(self):
"sample output"
setTrace = mm._setTrace
begin.upon(go1, middle, [out])
middle.upon(go2, end, [out])
end.upon(back, middle, [])
middle.upon(back, begin, [])
class TraceTests(TestCase):
def test_only_inputs(self):
traces = []
def tracer(old_state, input, new_state):
traces.append((old_state, input, new_state))
return None # "I only care about inputs, not outputs"
s = SampleObject()
s.setTrace(tracer)
s.go1()
self.assertEqual(
traces,
[
("begin", "go1", "middle"),
],
)
s.go2()
self.assertEqual(
traces,
[
("begin", "go1", "middle"),
("middle", "go2", "end"),
],
)
s.setTrace(None)
s.back()
self.assertEqual(
traces,
[
("begin", "go1", "middle"),
("middle", "go2", "end"),
],
)
s.go2()
self.assertEqual(
traces,
[
("begin", "go1", "middle"),
("middle", "go2", "end"),
],
)
def test_inputs_and_outputs(self):
traces = []
def tracer(old_state, input, new_state):
traces.append((old_state, input, new_state, None))
def trace_outputs(output):
traces.append((old_state, input, new_state, output))
return trace_outputs # "I care about outputs too"
s = SampleObject()
s.setTrace(tracer)
s.go1()
self.assertEqual(
traces,
[
("begin", "go1", "middle", None),
("begin", "go1", "middle", "out"),
],
)
s.go2()
self.assertEqual(
traces,
[
("begin", "go1", "middle", None),
("begin", "go1", "middle", "out"),
("middle", "go2", "end", None),
("middle", "go2", "end", "out"),
],
)
s.setTrace(None)
s.back()
self.assertEqual(
traces,
[
("begin", "go1", "middle", None),
("begin", "go1", "middle", "out"),
("middle", "go2", "end", None),
("middle", "go2", "end", "out"),
],
)
s.go2()
self.assertEqual(
traces,
[
("begin", "go1", "middle", None),
("begin", "go1", "middle", "out"),
("middle", "go2", "end", None),
("middle", "go2", "end", "out"),
],
)

View File

@@ -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)

View File

@@ -0,0 +1,478 @@
from __future__ import annotations
import functools
import os
import subprocess
from dataclasses import dataclass
from typing import Protocol
from unittest import TestCase, skipIf
from automat import TypeMachineBuilder, pep614
from .._methodical import MethodicalMachine
from .._typed import TypeMachine
from .test_discover import isTwistedInstalled
def isGraphvizModuleInstalled():
"""
Is the graphviz Python module installed?
"""
try:
__import__("graphviz")
except ImportError:
return False
else:
return True
def isGraphvizInstalled():
"""
Are the graphviz tools installed?
"""
r, w = os.pipe()
os.close(w)
try:
return not subprocess.call("dot", stdin=r, shell=True)
finally:
os.close(r)
def sampleMachine():
"""
Create a sample L{MethodicalMachine} with some sample states.
"""
mm = MethodicalMachine()
class SampleObject(object):
@mm.state(initial=True)
def begin(self):
"initial state"
@mm.state()
def end(self):
"end state"
@mm.input()
def go(self):
"sample input"
@mm.output()
def out(self):
"sample output"
begin.upon(go, end, [out])
so = SampleObject()
so.go()
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):
"""
L{elementMaker} generates HTML representing the specified element.
"""
def setUp(self):
from .._visualize import elementMaker
self.elementMaker = elementMaker
def test_sortsAttrs(self):
"""
L{elementMaker} orders HTML attributes lexicographically.
"""
expected = r'<div a="1" b="2" c="3"></div>'
self.assertEqual(expected, self.elementMaker("div", b="2", a="1", c="3"))
def test_quotesAttrs(self):
"""
L{elementMaker} quotes HTML attributes according to DOT's quoting rule.
See U{http://www.graphviz.org/doc/info/lang.html}, footnote 1.
"""
expected = r'<div a="1" b="a \" quote" c="a string"></div>'
self.assertEqual(
expected, self.elementMaker("div", b='a " quote', a=1, c="a string")
)
def test_noAttrs(self):
"""
L{elementMaker} should render an element with no attributes.
"""
expected = r"<div ></div>"
self.assertEqual(expected, self.elementMaker("div"))
@dataclass
class HTMLElement(object):
"""Holds an HTML element, as created by elementMaker."""
name: str
children: list[HTMLElement]
attributes: dict[str, str]
def findElements(element, predicate):
"""
Recursively collect all elements in an L{HTMLElement} tree that
match the optional predicate.
"""
if predicate(element):
return [element]
elif isLeaf(element):
return []
return [
result
for child in element.children
for result in findElements(child, predicate)
]
def isLeaf(element):
"""
This HTML element is actually leaf node.
"""
return not isinstance(element, HTMLElement)
@skipIf(not isGraphvizModuleInstalled(), "Graphviz module is not installed.")
@skipIf(not isTwistedInstalled(), "Twisted is not installed.")
class TableMakerTests(TestCase):
"""
Tests that ensure L{tableMaker} generates HTML tables usable as
labels in DOT graphs.
For more information, read the "HTML-Like Labels" section of
U{http://www.graphviz.org/doc/info/shapes.html}.
"""
def fakeElementMaker(self, name, *children, **attributes):
return HTMLElement(name=name, children=children, attributes=attributes)
def setUp(self):
from .._visualize import tableMaker
self.inputLabel = "input label"
self.port = "the port"
self.tableMaker = functools.partial(tableMaker, _E=self.fakeElementMaker)
def test_inputLabelRow(self):
"""
The table returned by L{tableMaker} always contains the input
symbol label in its first row, and that row contains one cell
with a port attribute set to the provided port.
"""
def hasPort(element):
return not isLeaf(element) and element.attributes.get("port") == self.port
for outputLabels in ([], ["an output label"]):
table = self.tableMaker(self.inputLabel, outputLabels, port=self.port)
self.assertGreater(len(table.children), 0)
inputLabelRow = table.children[0]
portCandidates = findElements(table, hasPort)
self.assertEqual(len(portCandidates), 1)
self.assertEqual(portCandidates[0].name, "td")
self.assertEqual(findElements(inputLabelRow, isLeaf), [self.inputLabel])
def test_noOutputLabels(self):
"""
L{tableMaker} does not add a colspan attribute to the input
label's cell or a second row if there no output labels.
"""
table = self.tableMaker("input label", (), port=self.port)
self.assertEqual(len(table.children), 1)
(inputLabelRow,) = table.children
self.assertNotIn("colspan", inputLabelRow.attributes)
def test_withOutputLabels(self):
"""
L{tableMaker} adds a colspan attribute to the input label's cell
equal to the number of output labels and a second row that
contains the output labels.
"""
table = self.tableMaker(
self.inputLabel, ("output label 1", "output label 2"), port=self.port
)
self.assertEqual(len(table.children), 2)
inputRow, outputRow = table.children
def hasCorrectColspan(element):
return (
not isLeaf(element)
and element.name == "td"
and element.attributes.get("colspan") == "2"
)
self.assertEqual(len(findElements(inputRow, hasCorrectColspan)), 1)
self.assertEqual(
findElements(outputRow, isLeaf), ["output label 1", "output label 2"]
)
@skipIf(not isGraphvizModuleInstalled(), "Graphviz module is not installed.")
@skipIf(not isGraphvizInstalled(), "Graphviz tools are not installed.")
@skipIf(not isTwistedInstalled(), "Twisted is not installed.")
class IntegrationTests(TestCase):
"""
Tests which make sure Graphviz can understand the output produced by
Automat.
"""
def test_validGraphviz(self) -> None:
"""
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(text)
self.assertEqual(p.returncode, 0)
@skipIf(not isGraphvizModuleInstalled(), "Graphviz module is not installed.")
@skipIf(not isTwistedInstalled(), "Twisted is not installed.")
class SpotChecks(TestCase):
"""
Tests to make sure that the output contains salient features of the machine
being generated.
"""
def test_containsMachineFeatures(self):
"""
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)
self.assertIn("end", gvout)
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):
"""
Records calls made to L{FakeDigraph}.
"""
def __init__(self):
self.reset()
def reset(self):
self.renderCalls = []
self.saveCalls = []
class FakeDigraph(object):
"""
A fake L{graphviz.Digraph}. Instantiate it with a
L{RecordsDigraphActions}.
"""
def __init__(self, recorder):
self._recorder = recorder
def render(self, **kwargs):
self._recorder.renderCalls.append(kwargs)
def save(self, **kwargs):
self._recorder.saveCalls.append(kwargs)
class FakeMethodicalMachine(object):
"""
A fake L{MethodicalMachine}. Instantiate it with a L{FakeDigraph}
"""
def __init__(self, digraph):
self._digraph = digraph
def asDigraph(self):
return self._digraph
@skipIf(not isGraphvizModuleInstalled(), "Graphviz module is not installed.")
@skipIf(not isGraphvizInstalled(), "Graphviz tools are not installed.")
@skipIf(not isTwistedInstalled(), "Twisted is not installed.")
class VisualizeToolTests(TestCase):
def setUp(self):
self.digraphRecorder = RecordsDigraphActions()
self.fakeDigraph = FakeDigraph(self.digraphRecorder)
self.fakeProgname = "tool-test"
self.fakeSysPath = ["ignored"]
self.collectedOutput = []
self.fakeFQPN = "fake.fqpn"
def collectPrints(self, *args):
self.collectedOutput.append(" ".join(args))
def fakeFindMachines(self, fqpn):
yield fqpn, FakeMethodicalMachine(self.fakeDigraph)
def tool(
self, progname=None, argv=None, syspath=None, findMachines=None, print=None
):
from .._visualize import tool
return tool(
_progname=progname or self.fakeProgname,
_argv=argv or [self.fakeFQPN],
_syspath=syspath or self.fakeSysPath,
_findMachines=findMachines or self.fakeFindMachines,
_print=print or self.collectPrints,
)
def test_checksCurrentDirectory(self):
"""
L{tool} adds '' to sys.path to ensure
L{automat._discover.findMachines} searches the current
directory.
"""
self.tool(argv=[self.fakeFQPN])
self.assertEqual(self.fakeSysPath[0], "")
def test_quietHidesOutput(self):
"""
Passing -q/--quiet hides all output.
"""
self.tool(argv=[self.fakeFQPN, "--quiet"])
self.assertFalse(self.collectedOutput)
self.tool(argv=[self.fakeFQPN, "-q"])
self.assertFalse(self.collectedOutput)
def test_onlySaveDot(self):
"""
Passing an empty string for --image-directory/-i disables
rendering images.
"""
for arg in ("--image-directory", "-i"):
self.digraphRecorder.reset()
self.collectedOutput = []
self.tool(argv=[self.fakeFQPN, arg, ""])
self.assertFalse(any("image" in line for line in self.collectedOutput))
self.assertEqual(len(self.digraphRecorder.saveCalls), 1)
(call,) = self.digraphRecorder.saveCalls
self.assertEqual("{}.dot".format(self.fakeFQPN), call["filename"])
self.assertFalse(self.digraphRecorder.renderCalls)
def test_saveOnlyImage(self):
"""
Passing an empty string for --dot-directory/-d disables saving dot
files.
"""
for arg in ("--dot-directory", "-d"):
self.digraphRecorder.reset()
self.collectedOutput = []
self.tool(argv=[self.fakeFQPN, arg, ""])
self.assertFalse(any("dot" in line for line in self.collectedOutput))
self.assertEqual(len(self.digraphRecorder.renderCalls), 1)
(call,) = self.digraphRecorder.renderCalls
self.assertEqual("{}.dot".format(self.fakeFQPN), call["filename"])
self.assertTrue(call["cleanup"])
self.assertFalse(self.digraphRecorder.saveCalls)
def test_saveDotAndImagesInDifferentDirectories(self):
"""
Passing different directories to --image-directory and --dot-directory
writes images and dot files to those directories.
"""
imageDirectory = "image"
dotDirectory = "dot"
self.tool(
argv=[
self.fakeFQPN,
"--image-directory",
imageDirectory,
"--dot-directory",
dotDirectory,
]
)
self.assertTrue(any("image" in line for line in self.collectedOutput))
self.assertTrue(any("dot" in line for line in self.collectedOutput))
self.assertEqual(len(self.digraphRecorder.renderCalls), 1)
(renderCall,) = self.digraphRecorder.renderCalls
self.assertEqual(renderCall["directory"], imageDirectory)
self.assertTrue(renderCall["cleanup"])
self.assertEqual(len(self.digraphRecorder.saveCalls), 1)
(saveCall,) = self.digraphRecorder.saveCalls
self.assertEqual(saveCall["directory"], dotDirectory)
def test_saveDotAndImagesInSameDirectory(self):
"""
Passing the same directory to --image-directory and --dot-directory
writes images and dot files to that one directory.
"""
directory = "imagesAndDot"
self.tool(
argv=[
self.fakeFQPN,
"--image-directory",
directory,
"--dot-directory",
directory,
]
)
self.assertTrue(any("image and dot" in line for line in self.collectedOutput))
self.assertEqual(len(self.digraphRecorder.renderCalls), 1)
(renderCall,) = self.digraphRecorder.renderCalls
self.assertEqual(renderCall["directory"], directory)
self.assertFalse(renderCall["cleanup"])
self.assertFalse(len(self.digraphRecorder.saveCalls))

View File

@@ -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 <https://peps.python.org/pep-0614/>}, 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() <automat.TypeMachineBuilder.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"<implementation for {method}>"
)
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
<automat._typed.TypedState.upon>} L{.to
<automat._typed.UponFromNo.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__}"
)

View File

@@ -0,0 +1,230 @@
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: str) -> str:
return '"{}"'.format(s.replace('"', r"\""))
def _gvhtml(s: str) -> str:
return "<{}>".format(s)
def elementMaker(name: str, *children: str, **attrs: str) -> str:
"""
Construct a string from the HTML element description.
"""
formattedAttrs = " ".join(
"{}={}".format(key, _gvquote(str(value)))
for key, value in sorted(attrs.items())
)
formattedChildren = "".join(children)
return "<{name} {attrs}>{children}</{name}>".format(
name=name, attrs=formattedAttrs, children=formattedChildren
)
def tableMaker(
inputLabel: str,
outputLabels: list[str],
port: str,
_E: Callable[..., str] = elementMaker,
) -> str:
"""
Construct an HTML table to label a state transition.
"""
colspan = {}
if outputLabels:
colspan["colspan"] = str(len(outputLabels))
inputLabelCell = _E(
"td",
_E("font", inputLabel, face="menlo-italic"),
color="purple",
port=port,
**colspan,
)
pointSize = {"point-size": "9"}
outputLabelCells = [
_E("td", _E("font", outputLabel, **pointSize), color="pink")
for outputLabel in outputLabels
]
rows = [_E("tr", inputLabelCell)]
if outputLabels:
rows.append(_E("tr", *outputLabelCells))
return _E("table", *rows)
def escapify(x: Callable[[State], str]) -> Callable[[State], str]:
@wraps(x)
def impl(t: State) -> str:
return x(t).replace("<", "&lt;").replace(">", "&gt;")
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"},
edge_attr={"fontname": "Menlo"},
)
for state in automaton.states():
if state is automaton.initialState:
stateShape = "bold"
fontName = "Menlo-Bold"
else:
stateShape = ""
fontName = "Menlo"
digraph.node(
stateAsString(state),
fontame=fontName,
shape="ellipse",
style=stateShape,
color="blue",
)
for n, eachTransition in enumerate(automaton.allTransitions()):
inState, inputSymbol, outState, outputSymbols = eachTransition
thisTransition = "t{}".format(n)
inputLabel = inputAsString(inputSymbol)
port = "tableport"
table = tableMaker(
inputLabel,
[outputAsString(outputSymbol) for outputSymbol in outputSymbols],
port=port,
)
digraph.node(thisTransition, label=_gvhtml(table), margin="0.2", shape="none")
digraph.edge(
stateAsString(inState),
"{}:{}:w".format(thisTransition, port),
arrowhead="none",
)
digraph.edge("{}:{}:e".format(thisTransition, port), stateAsString(outState))
return digraph
def tool(
_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.
"""
DESCRIPTION = """
Visualize automat.MethodicalMachines as graphviz graphs.
"""
EPILOG = """
You must have the graphviz tool suite installed. Please visit
http://www.graphviz.org for more information.
"""
if _syspath[0]:
_syspath.insert(0, "")
argumentParser = argparse.ArgumentParser(
prog=_progname, description=DESCRIPTION, epilog=EPILOG
)
argumentParser.add_argument(
"fqpn",
help="A Fully Qualified Path name" " representing where to find machines.",
)
argumentParser.add_argument(
"--quiet", "-q", help="suppress output", default=False, action="store_true"
)
argumentParser.add_argument(
"--dot-directory",
"-d",
help="Where to write out .dot files.",
default=".automat_visualize",
)
argumentParser.add_argument(
"--image-directory",
"-i",
help="Where to write out image files.",
default=".automat_visualize",
)
argumentParser.add_argument(
"--image-type",
"-t",
help="The image format.",
choices=graphviz.FORMATS,
default="png",
)
argumentParser.add_argument(
"--view",
"-v",
help="View rendered graphs with" " default image viewer",
default=False,
action="store_true",
)
args = argumentParser.parse_args(_argv)
explicitlySaveDot = args.dot_directory and (
not args.image_directory or args.image_directory != args.dot_directory
)
if args.quiet:
def _print(*args):
pass
for fqpn, machine in _findMachines(args.fqpn):
_print(fqpn, "...discovered")
digraph = machine.asDigraph()
if explicitlySaveDot:
digraph.save(filename="{}.dot".format(fqpn), directory=args.dot_directory)
_print(fqpn, "...wrote dot into", args.dot_directory)
if args.image_directory:
deleteDot = not args.dot_directory or explicitlySaveDot
digraph.format = args.image_type
digraph.render(
filename="{}.dot".format(fqpn),
directory=args.image_directory,
view=args.view,
cleanup=deleteDot,
)
if deleteDot:
msg = "...wrote image into"
else:
msg = "...wrote image and dot into"
_print(fqpn, msg, args.image_directory)