mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 12:11:14 -05:00
okay fine
This commit is contained in:
16
.venv/lib/python3.12/site-packages/automat/__init__.py
Normal file
16
.venv/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",
|
||||
]
|
||||
203
.venv/lib/python3.12/site-packages/automat/_core.py
Normal file
203
.venv/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
.venv/lib/python3.12/site-packages/automat/_discover.py
Normal file
168
.venv/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))
|
||||
57
.venv/lib/python3.12/site-packages/automat/_introspection.py
Normal file
57
.venv/lib/python3.12/site-packages/automat/_introspection.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Python introspection helpers.
|
||||
"""
|
||||
|
||||
from types import CodeType as code, FunctionType as function
|
||||
|
||||
|
||||
def copycode(template, changes):
|
||||
if hasattr(code, "replace"):
|
||||
return template.replace(**{"co_" + k: v for k, v in changes.items()})
|
||||
names = [
|
||||
"argcount",
|
||||
"nlocals",
|
||||
"stacksize",
|
||||
"flags",
|
||||
"code",
|
||||
"consts",
|
||||
"names",
|
||||
"varnames",
|
||||
"filename",
|
||||
"name",
|
||||
"firstlineno",
|
||||
"lnotab",
|
||||
"freevars",
|
||||
"cellvars",
|
||||
]
|
||||
if hasattr(code, "co_kwonlyargcount"):
|
||||
names.insert(1, "kwonlyargcount")
|
||||
if hasattr(code, "co_posonlyargcount"):
|
||||
# PEP 570 added "positional only arguments"
|
||||
names.insert(1, "posonlyargcount")
|
||||
values = [changes.get(name, getattr(template, "co_" + name)) for name in names]
|
||||
return code(*values)
|
||||
|
||||
|
||||
def copyfunction(template, funcchanges, codechanges):
|
||||
names = [
|
||||
"globals",
|
||||
"name",
|
||||
"defaults",
|
||||
"closure",
|
||||
]
|
||||
values = [
|
||||
funcchanges.get(name, getattr(template, "__" + name + "__")) for name in names
|
||||
]
|
||||
return function(copycode(template.__code__, codechanges), *values)
|
||||
|
||||
|
||||
def preserveName(f):
|
||||
"""
|
||||
Preserve the name of the given function on the decorated function.
|
||||
"""
|
||||
|
||||
def decorator(decorated):
|
||||
return copyfunction(decorated, dict(name=f.__name__), dict(name=f.__name__))
|
||||
|
||||
return decorator
|
||||
545
.venv/lib/python3.12/site-packages/automat/_methodical.py
Normal file
545
.venv/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__,
|
||||
)
|
||||
62
.venv/lib/python3.12/site-packages/automat/_runtimeproto.py
Normal file
62
.venv/lib/python3.12/site-packages/automat/_runtimeproto.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Workaround for U{the lack of TypeForm
|
||||
<https://github.com/python/mypy/issues/9773>}.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from typing import TYPE_CHECKING, Callable, Protocol, TypeVar
|
||||
|
||||
from inspect import signature, Signature
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
ProtocolAtRuntime = Callable[[], T]
|
||||
|
||||
|
||||
def runtime_name(x: ProtocolAtRuntime[T]) -> str:
|
||||
return x.__name__
|
||||
|
||||
|
||||
from inspect import getmembers, isfunction
|
||||
|
||||
emptyProtocolMethods: frozenset[str]
|
||||
if not TYPE_CHECKING:
|
||||
emptyProtocolMethods = frozenset(
|
||||
name
|
||||
for name, each in getmembers(type("Example", tuple([Protocol]), {}), isfunction)
|
||||
)
|
||||
|
||||
|
||||
def actuallyDefinedProtocolMethods(protocol: object) -> frozenset[str]:
|
||||
"""
|
||||
Attempt to ignore implementation details, and get all the methods that the
|
||||
protocol actually defines.
|
||||
|
||||
that includes locally defined methods and also those defined in inherited
|
||||
superclasses.
|
||||
"""
|
||||
return (
|
||||
frozenset(name for name, each in getmembers(protocol, isfunction))
|
||||
- emptyProtocolMethods
|
||||
)
|
||||
|
||||
|
||||
def _fixAnnotation(method: Callable[..., object], it: object, ann: str) -> None:
|
||||
annotation = getattr(it, ann)
|
||||
if isinstance(annotation, str):
|
||||
setattr(it, ann, eval(annotation, method.__globals__))
|
||||
|
||||
|
||||
def _liveSignature(method: Callable[..., object]) -> Signature:
|
||||
"""
|
||||
Get a signature with evaluated annotations.
|
||||
"""
|
||||
# TODO: could this be replaced with get_type_hints?
|
||||
result = signature(method)
|
||||
for param in result.parameters.values():
|
||||
_fixAnnotation(method, param, "_annotation")
|
||||
_fixAnnotation(method, result, "_return_annotation")
|
||||
return result
|
||||
@@ -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
.venv/lib/python3.12/site-packages/automat/_test/test_trace.py
Normal file
142
.venv/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
.venv/lib/python3.12/site-packages/automat/_typed.py
Normal file
736
.venv/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
.venv/lib/python3.12/site-packages/automat/_visualize.py
Normal file
230
.venv/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)
|
||||
0
.venv/lib/python3.12/site-packages/automat/py.typed
Normal file
0
.venv/lib/python3.12/site-packages/automat/py.typed
Normal file
Reference in New Issue
Block a user