This commit is contained in:
2024-12-17 14:36:15 -08:00
parent b2dbf46d28
commit 06d106de53
17731 changed files with 3037186 additions and 144 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,534 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Generic, List, Protocol, TypeVar
from unittest import TestCase, skipIf
from .. import AlreadyBuiltError, NoTransition, TypeMachineBuilder, pep614
try:
from zope.interface import Interface, implementer # type:ignore[import-untyped]
except ImportError:
hasInterface = False
else:
hasInterface = True
class ISomething(Interface):
def something() -> int: ... # type:ignore[misc,empty-body]
T = TypeVar("T")
class ProtocolForTesting(Protocol):
def change(self) -> None:
"Switch to the other state."
def value(self) -> int:
"Give a value specific to the given state."
class ArgTaker(Protocol):
def takeSomeArgs(self, arg1: int = 0, arg2: str = "") -> None: ...
def value(self) -> int: ...
class NoOpCore:
"Just an object, you know?"
@dataclass
class Gen(Generic[T]):
t: T
def buildTestBuilder() -> tuple[
TypeMachineBuilder[ProtocolForTesting, NoOpCore],
Callable[[NoOpCore], ProtocolForTesting],
]:
builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore)
first = builder.state("first")
second = builder.state("second")
first.upon(ProtocolForTesting.change).to(second).returns(None)
second.upon(ProtocolForTesting.change).to(first).returns(None)
@pep614(first.upon(ProtocolForTesting.value).loop())
def firstValue(machine: ProtocolForTesting, core: NoOpCore) -> int:
return 3
@pep614(second.upon(ProtocolForTesting.value).loop())
def secondValue(machine: ProtocolForTesting, core: NoOpCore) -> int:
return 4
return builder, builder.build()
builder, machineFactory = buildTestBuilder()
def needsSomething(proto: ProtocolForTesting, core: NoOpCore, value: str) -> int:
"we need data to build this state"
return 3 # pragma: no cover
def needsNothing(proto: ArgTaker, core: NoOpCore) -> str:
return "state-specific data" # pragma: no cover
class SimpleProtocol(Protocol):
def method(self) -> None:
"A method"
class Counter(Protocol):
def start(self) -> None:
"enter the counting state"
def increment(self) -> None:
"increment the counter"
def stop(self) -> int:
"stop"
@dataclass
class Count:
value: int = 0
class TypeMachineTests(TestCase):
def test_oneTransition(self) -> None:
machine = machineFactory(NoOpCore())
self.assertEqual(machine.value(), 3)
machine.change()
self.assertEqual(machine.value(), 4)
self.assertEqual(machine.value(), 4)
machine.change()
self.assertEqual(machine.value(), 3)
def test_stateSpecificData(self) -> None:
builder = TypeMachineBuilder(Counter, NoOpCore)
initial = builder.state("initial")
counting = builder.state("counting", lambda machine, core: Count())
initial.upon(Counter.start).to(counting).returns(None)
@pep614(counting.upon(Counter.increment).loop())
def incf(counter: Counter, core: NoOpCore, count: Count) -> None:
count.value += 1
@pep614(counting.upon(Counter.stop).to(initial))
def finish(counter: Counter, core: NoOpCore, count: Count) -> int:
return count.value
machineFactory = builder.build()
machine = machineFactory(NoOpCore())
machine.start()
machine.increment()
machine.increment()
self.assertEqual(machine.stop(), 2)
machine.start()
machine.increment()
self.assertEqual(machine.stop(), 1)
def test_stateSpecificDataWithoutData(self) -> None:
"""
To facilitate common implementations of transition behavior methods,
sometimes you want to implement a transition within a data state
without taking a data parameter. To do this, pass the 'nodata=True'
parameter to 'upon'.
"""
builder = TypeMachineBuilder(Counter, NoOpCore)
initial = builder.state("initial")
counting = builder.state("counting", lambda machine, core: Count())
startCalls = []
@pep614(initial.upon(Counter.start).to(counting))
@pep614(counting.upon(Counter.start, nodata=True).loop())
def start(counter: Counter, core: NoOpCore) -> None:
startCalls.append("started!")
@pep614(counting.upon(Counter.increment).loop())
def incf(counter: Counter, core: NoOpCore, count: Count) -> None:
count.value += 1
@pep614(counting.upon(Counter.stop).to(initial))
def finish(counter: Counter, core: NoOpCore, count: Count) -> int:
return count.value
machineFactory = builder.build()
machine = machineFactory(NoOpCore())
machine.start()
self.assertEqual(len(startCalls), 1)
machine.start()
self.assertEqual(len(startCalls), 2)
machine.increment()
self.assertEqual(machine.stop(), 1)
def test_incompleteTransitionDefinition(self) -> None:
builder = TypeMachineBuilder(SimpleProtocol, NoOpCore)
sample = builder.state("sample")
sample.upon(SimpleProtocol.method).loop() # oops, no '.returns(None)'
with self.assertRaises(ValueError) as raised:
builder.build()
self.assertIn(
"incomplete transition from sample to sample upon SimpleProtocol.method",
str(raised.exception),
)
def test_dataToData(self) -> None:
builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore)
@dataclass
class Data1:
value: int
@dataclass
class Data2:
stuff: List[str]
initial = builder.state("initial")
counting = builder.state("counting", lambda proto, core: Data1(1))
appending = builder.state("appending", lambda proto, core: Data2([]))
initial.upon(ProtocolForTesting.change).to(counting).returns(None)
@pep614(counting.upon(ProtocolForTesting.value).loop())
def countup(p: ProtocolForTesting, c: NoOpCore, d: Data1) -> int:
d.value *= 2
return d.value
counting.upon(ProtocolForTesting.change).to(appending).returns(None)
@pep614(appending.upon(ProtocolForTesting.value).loop())
def appendup(p: ProtocolForTesting, c: NoOpCore, d: Data2) -> int:
d.stuff.extend("abc")
return len(d.stuff)
machineFactory = builder.build()
machine = machineFactory(NoOpCore())
machine.change()
self.assertEqual(machine.value(), 2)
self.assertEqual(machine.value(), 4)
machine.change()
self.assertEqual(machine.value(), 3)
self.assertEqual(machine.value(), 6)
def test_dataFactoryArgs(self) -> None:
"""
Any data factory that takes arguments will constrain the allowed
signature of all protocol methods that transition into that state.
"""
builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore)
initial = builder.state("initial")
data = builder.state("data", needsSomething)
data2 = builder.state("data2", needsSomething)
# toState = initial.to(data)
# 'assertions' in the form of expected type errors:
# (no data -> data)
uponNoData = initial.upon(ProtocolForTesting.change)
uponNoData.to(data) # type:ignore[arg-type]
# (data -> data)
uponData = data.upon(ProtocolForTesting.change)
uponData.to(data2) # type:ignore[arg-type]
def test_dataFactoryNoArgs(self) -> None:
"""
Inverse of C{test_dataFactoryArgs} where the data factory specifically
does I{not} take arguments, but the input specified does.
"""
builder = TypeMachineBuilder(ArgTaker, NoOpCore)
initial = builder.state("initial")
data = builder.state("data", needsNothing)
(
initial.upon(ArgTaker.takeSomeArgs)
.to(data) # type:ignore[arg-type]
.returns(None)
)
def test_invalidTransition(self) -> None:
"""
Invalid transitions raise a NoTransition exception.
"""
builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore)
builder.state("initial")
factory = builder.build()
machine = factory(NoOpCore())
with self.assertRaises(NoTransition):
machine.change()
def test_reentrancy(self) -> None:
"""
During the execution of a transition behavior implementation function,
you may invoke other methods on your state machine. However, the
execution of the behavior of those methods will be deferred until the
current behavior method is done executing. In order to implement that
deferral, we restrict the set of methods that can be invoked to those
that return None.
@note: it may be possible to implement deferral via Awaitables or
Deferreds later, but we are starting simple.
"""
class SomeMethods(Protocol):
def start(self) -> None:
"Start the machine."
def later(self) -> None:
"Do some deferrable work."
builder = TypeMachineBuilder(SomeMethods, NoOpCore)
initial = builder.state("initial")
second = builder.state("second")
order = []
@pep614(initial.upon(SomeMethods.start).to(second))
def startup(methods: SomeMethods, core: NoOpCore) -> None:
order.append("startup")
methods.later()
order.append("startup done")
@pep614(second.upon(SomeMethods.later).loop())
def later(methods: SomeMethods, core: NoOpCore) -> None:
order.append("later")
machineFactory = builder.build()
machine = machineFactory(NoOpCore())
machine.start()
self.assertEqual(order, ["startup", "startup done", "later"])
def test_reentrancyNotNoneError(self) -> None:
class SomeMethods(Protocol):
def start(self) -> None:
"Start the machine."
def later(self) -> int:
"Do some deferrable work."
builder = TypeMachineBuilder(SomeMethods, NoOpCore)
initial = builder.state("initial")
second = builder.state("second")
order = []
@pep614(initial.upon(SomeMethods.start).to(second))
def startup(methods: SomeMethods, core: NoOpCore) -> None:
order.append("startup")
methods.later()
order.append("startup done") # pragma: no cover
@pep614(second.upon(SomeMethods.later).loop())
def later(methods: SomeMethods, core: NoOpCore) -> int:
order.append("later")
return 3
machineFactory = builder.build()
machine = machineFactory(NoOpCore())
with self.assertRaises(RuntimeError):
machine.start()
self.assertEqual(order, ["startup"])
# We do actually do the state transition, which happens *before* the
# output is generated; TODO: maybe we should have exception handling
# that transitions into an error state that requires explicit recovery?
self.assertEqual(machine.later(), 3)
self.assertEqual(order, ["startup", "later"])
def test_buildLock(self) -> None:
"""
``.build()`` locks the builder so it can no longer be modified.
"""
builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore)
state = builder.state("test-state")
state2 = builder.state("state2")
state3 = builder.state("state3")
upon = state.upon(ProtocolForTesting.change)
to = upon.to(state2)
to2 = upon.to(state3)
to.returns(None)
with self.assertRaises(ValueError) as ve:
to2.returns(None)
with self.assertRaises(AlreadyBuiltError):
to.returns(None)
builder.build()
with self.assertRaises(AlreadyBuiltError):
builder.state("hello")
with self.assertRaises(AlreadyBuiltError):
builder.build()
def test_methodMembership(self) -> None:
"""
Input methods must be members of their protocol.
"""
builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore)
state = builder.state("test-state")
def stateful(proto: ProtocolForTesting, core: NoOpCore) -> int:
return 4 # pragma: no cover
state2 = builder.state("state2", stateful)
def change(self: ProtocolForTesting) -> None: ...
def rogue(self: ProtocolForTesting) -> int:
return 3 # pragma: no cover
with self.assertRaises(ValueError):
state.upon(change)
with self.assertRaises(ValueError) as ve:
state2.upon(change)
with self.assertRaises(ValueError):
state.upon(rogue)
def test_startInAlternateState(self) -> None:
"""
The state machine can be started in an alternate state.
"""
builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore)
one = builder.state("one")
two = builder.state("two")
@dataclass
class Three:
proto: ProtocolForTesting
core: NoOpCore
value: int = 0
three = builder.state("three", Three)
one.upon(ProtocolForTesting.change).to(two).returns(None)
one.upon(ProtocolForTesting.value).loop().returns(1)
two.upon(ProtocolForTesting.change).to(three).returns(None)
two.upon(ProtocolForTesting.value).loop().returns(2)
@pep614(three.upon(ProtocolForTesting.value).loop())
def threevalue(proto: ProtocolForTesting, core: NoOpCore, three: Three) -> int:
return 3 + three.value
onetwothree = builder.build()
# confirm positive behavior first, particularly the value of the three
# state's change
normal = onetwothree(NoOpCore())
self.assertEqual(normal.value(), 1)
normal.change()
self.assertEqual(normal.value(), 2)
normal.change()
self.assertEqual(normal.value(), 3)
# now try deserializing it in each state
self.assertEqual(onetwothree(NoOpCore()).value(), 1)
self.assertEqual(onetwothree(NoOpCore(), two).value(), 2)
self.assertEqual(
onetwothree(
NoOpCore(), three, lambda proto, core: Three(proto, core, 4)
).value(),
7,
)
def test_genericData(self) -> None:
"""
Test to cover get_origin in generic assertion.
"""
builder = TypeMachineBuilder(ArgTaker, NoOpCore)
one = builder.state("one")
def dat(
proto: ArgTaker, core: NoOpCore, arg1: int = 0, arg2: str = ""
) -> Gen[int]:
return Gen(arg1)
two = builder.state("two", dat)
one.upon(ArgTaker.takeSomeArgs).to(two).returns(None)
@pep614(two.upon(ArgTaker.value).loop())
def val(proto: ArgTaker, core: NoOpCore, data: Gen[int]) -> int:
return data.t
b = builder.build()
m = b(NoOpCore())
m.takeSomeArgs(3)
self.assertEqual(m.value(), 3)
@skipIf(not hasInterface, "zope.interface not installed")
def test_interfaceData(self) -> None:
"""
Test to cover providedBy assertion.
"""
builder = TypeMachineBuilder(ArgTaker, NoOpCore)
one = builder.state("one")
@implementer(ISomething)
@dataclass
class Something:
val: int
def something(self) -> int:
return self.val
def dat(
proto: ArgTaker, core: NoOpCore, arg1: int = 0, arg2: str = ""
) -> ISomething:
return Something(arg1) # type:ignore[return-value]
two = builder.state("two", dat)
one.upon(ArgTaker.takeSomeArgs).to(two).returns(None)
@pep614(two.upon(ArgTaker.value).loop())
def val(proto: ArgTaker, core: NoOpCore, data: ISomething) -> int:
return data.something() # type:ignore[misc]
b = builder.build()
m = b(NoOpCore())
m.takeSomeArgs(3)
self.assertEqual(m.value(), 3)
def test_noMethodsInAltStateDataFactory(self) -> None:
"""
When the state machine is received by a data factory during
construction, it is in an invalid state. It may be invoked after
construction is complete.
"""
builder = TypeMachineBuilder(ProtocolForTesting, NoOpCore)
@dataclass
class Data:
value: int
proto: ProtocolForTesting
start = builder.state("start")
data = builder.state("data", lambda proto, core: Data(3, proto))
@pep614(data.upon(ProtocolForTesting.value).loop())
def getval(proto: ProtocolForTesting, core: NoOpCore, data: Data) -> int:
return data.value
@pep614(start.upon(ProtocolForTesting.value).loop())
def minusone(proto: ProtocolForTesting, core: NoOpCore) -> int:
return -1
factory = builder.build()
self.assertEqual(factory(NoOpCore()).value(), -1)
def touchproto(proto: ProtocolForTesting, core: NoOpCore) -> Data:
return Data(proto.value(), proto)
catchdata = []
def notouchproto(proto: ProtocolForTesting, core: NoOpCore) -> Data:
catchdata.append(new := Data(4, proto))
return new
with self.assertRaises(NoTransition):
factory(NoOpCore(), data, touchproto)
machine = factory(NoOpCore(), data, notouchproto)
self.assertIs(machine, catchdata[0].proto)
self.assertEqual(machine.value(), 4)

View File

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