RAHHH
This commit is contained in:
16
backend/lib/python3.12/site-packages/automat/__init__.py
Normal file
16
backend/lib/python3.12/site-packages/automat/__init__.py
Normal 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",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
203
backend/lib/python3.12/site-packages/automat/_core.py
Normal file
203
backend/lib/python3.12/site-packages/automat/_core.py
Normal 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)
|
||||
168
backend/lib/python3.12/site-packages/automat/_discover.py
Normal file
168
backend/lib/python3.12/site-packages/automat/_discover.py
Normal 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))
|
||||
@@ -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
|
||||
545
backend/lib/python3.12/site-packages/automat/_methodical.py
Normal file
545
backend/lib/python3.12/site-packages/automat/_methodical.py
Normal 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__,
|
||||
)
|
||||
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
142
backend/lib/python3.12/site-packages/automat/_test/test_trace.py
Normal file
142
backend/lib/python3.12/site-packages/automat/_test/test_trace.py
Normal 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"),
|
||||
],
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
736
backend/lib/python3.12/site-packages/automat/_typed.py
Normal file
736
backend/lib/python3.12/site-packages/automat/_typed.py
Normal 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__}"
|
||||
)
|
||||
230
backend/lib/python3.12/site-packages/automat/_visualize.py
Normal file
230
backend/lib/python3.12/site-packages/automat/_visualize.py
Normal 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("<", "<").replace(">", ">")
|
||||
|
||||
return impl
|
||||
|
||||
|
||||
def makeDigraph(
|
||||
automaton: Automaton[State, Input, Output],
|
||||
inputAsString: Callable[[Input], str] = repr,
|
||||
outputAsString: Callable[[Output], str] = repr,
|
||||
stateAsString: Callable[[State], str] = repr,
|
||||
) -> graphviz.Digraph:
|
||||
"""
|
||||
Produce a L{graphviz.Digraph} object from an automaton.
|
||||
"""
|
||||
|
||||
inputAsString = escapify(inputAsString)
|
||||
outputAsString = escapify(outputAsString)
|
||||
stateAsString = escapify(stateAsString)
|
||||
|
||||
digraph = graphviz.Digraph(
|
||||
graph_attr={"pack": "true", "dpi": "100"},
|
||||
node_attr={"fontname": "Menlo"},
|
||||
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)
|
||||
Reference in New Issue
Block a user