Commit 4bfcbd07 authored by Guillaume Binet's avatar Guillaume Binet

Flow/Conversation implementation

This defines a new type of plugin called "BotFlow" which represent a
graph of commands to be executed in sequence either manually,
automatically or a mix of both.

This enables not only a way to converse with the bot towards a goal but
also to skip part of the conversation (automate the flow) if the trigger
of the conversation or an intermediate step provides extra context.

The graph can include botcmd, arg_botcmd and re_botcmd and can mix them.

Squashed commit of the following:

commit 8096ed6103ac33b3e4dfd4c6af939672a1e7de79
Author: Guillaume Binet <gbin@google.com>
Date:   Wed Mar 30 14:32:59 2016 -0700

    annotations/remarks.

commit c0ce872262105d207c53302b522c64cb1bdc1be2
Author: Guillaume Binet <gbin@google.com>
Date:   Wed Mar 30 10:50:42 2016 -0700

    typos.

commit 242c102822b8542520ae996b00d33b96d8f30e21
Author: Guillaume Binet <gbin@google.com>
Date:   Wed Mar 30 09:52:39 2016 -0700

    comment on CommandError.

commit 58ebee104ed4847f9935bb62bee55c0515dabaa6
Author: Guillaume Binet <gbin@google.com>
Date:   Tue Mar 29 16:09:06 2016 -0700

    unit tests and e2e tests for flows.

commit d0faf5769441c489fa3a85b3ce501a6f06b9fa81
Author: Guillaume Binet <gbin@google.com>
Date:   Tue Mar 29 10:35:42 2016 -0700

    makes py2.7 encoding happy.

commit f6f505387df37cc62a6a0e18ac68dadfa6683438
Author: Guillaume Binet <gbin@google.com>
Date:   Tue Mar 29 10:29:29 2016 -0700

    Added doc to the flows.

commit aeb31a0fc71c996cdefbc32040b5a1b04f6cb50d
Author: Guillaume Binet <gbin@google.com>
Date:   Mon Mar 28 16:21:32 2016 -0700

    added better formatting with syntax.

commit cba01d1332a5632b0c010c6c407ad2e6f937642c
Author: Guillaume Binet <gbin@google.com>
Date:   Mon Mar 28 15:01:17 2016 -0700

    triggered flows work, resume etc..

commit 13bb06fbf35e8e389696299412a602607cf3e4f3
Author: Guillaume Binet <gbin@google.com>
Date:   Fri Mar 25 14:53:22 2016 -0700

    possible next steps to resume a flow.

commit 40c13d6801d18edd96852946bff3c2123be71e09
Author: Guillaume Binet <gbin@google.com>
Date:   Fri Mar 25 14:38:18 2016 -0700

    implemented the auto-triggers.

commit adc042a42e82ab73e879ad52c7555c98aecb0977
Author: Guillaume Binet <gbin@google.com>
Date:   Fri Mar 25 11:07:37 2016 -0700

    merge the flow & context with Message.

    It was complicating too much the API to have it externally.

commit d3e296fd17f24d644fb54c38158cb5e3a2de4f9a
Author: Guillaume Binet <gbin@google.com>
Date:   Fri Mar 25 10:08:25 2016 -0700

    renames to make the API cleaner.

commit bbf28f65d088bad44c6b4e9e2cc2471ecc1a6f12
Author: Guillaume Binet <gbin@google.com>
Date:   Thu Mar 24 10:40:51 2016 -0700

    make a flow run more than one step

commit c85afb52e1456f8c9a52945bb2e6faba313c7288
Author: Guillaume Binet <gbin@google.com>
Date:   Thu Mar 24 08:47:13 2016 -0700

    Exposes CommandError

commit c4a22d4bc60d1e36b8a9ae3dd2ee927861c13b5d
Author: Guillaume Binet <gbin@google.com>
Date:   Tue Mar 22 16:33:29 2016 -0700

    Introduced CommandError exception.

commit 2e0d036369e1b763bc99b62c9898db381327ad9f
Author: Guillaume Binet <gbin@google.com>
Date:   Tue Mar 22 15:56:58 2016 -0700

    autoexecution starts to work.

commit 1019f06a104c21797daf72e2e40f7124223658e6
Author: Guillaume Binet <gbin@google.com>
Date:   Tue Mar 22 11:29:59 2016 -0700

    basic flow query.

commit febafe8ee9d7b23da475aa363b576f1966a95c2d
Author: Guillaume Binet <gbin@google.com>
Date:   Tue Mar 22 09:25:42 2016 -0700

    Now flows are registered.

commit 6bb7bf93e0b18d904eafa8c95931259877b5c899
Author: Guillaume Binet <gbin@google.com>
Date:   Mon Mar 21 16:49:42 2016 -0700

    Fixed activation of the Flow plugin type.

commit a7aefdee9568c44da5cc6dc2b3fe6f84ef63e894
Author: Guillaume Binet <gbin@google.com>
Date:   Mon Mar 21 15:51:03 2016 -0700

    Refinements to uniformize the API with the plugis one.

commit c05e528f76bc094223b78d0ea1d38e10c70204c6
Author: Guillaume Binet <gbin@google.com>
Date:   Fri Mar 18 16:10:36 2016 -0700

    more definitions + loading.

commit b39ecd8c366f14732755d41f7232e916eb4878f2
Author: Guillaume Binet <gbin@gootz.net>
Date:   Fri Mar 18 16:09:34 2016 -0400

    WIP flow interfaces
parent fcee3082
......@@ -12,10 +12,12 @@ from .core_plugins.wsview import bottle_app, WebView
from errbot.backends.base import Message, ONLINE, OFFLINE, AWAY, DND # noqa
from .utils import compat_str
from .utils import PY2, PY3 # noqa gbin: this is now used by plugins
from .botplugin import BotPlugin, SeparatorArgParser, ShlexArgParser # noqa
from .botplugin import BotPlugin, SeparatorArgParser, ShlexArgParser, CommandError # noqa
from .flow import FlowRoot, BotFlow, Flow
from .core_plugins.wsview import route, view # noqa
__all__ = ['BotPlugin', 'webhook', 'webroute', 'webview', 'botcmd', 're_botcmd', 'arg_botcmd']
__all__ = ['BotPlugin', 'CommandError', 'webhook', 'webroute', 'webview',
'botcmd', 're_botcmd', 'arg_botcmd', 'botflow', 'BotFlow', 'FlowRoot', 'Flow']
log = logging.getLogger(__name__)
......@@ -406,4 +408,20 @@ def cmdfilter(*args, **kwargs):
if len(args):
return decorate(args[0], **kwargs)
return lambda func: decorate(func, **kwargs)
return lambda func: decorate(func)
def botflow(*args, **kwargs):
"""
Decorator for flow of commands.
TODO(gbin): example / docs
"""
def decorate(func):
if not hasattr(func, '_err_flow'): # don't override generated functions
func._err_flow = True
return func
if len(args):
return decorate(args[0], **kwargs)
return lambda func: decorate(func)
......@@ -235,18 +235,28 @@ class Message(object):
frm: Identifier=None,
to: Identifier=None,
delayed: bool=False,
extras: Mapping=None):
extras: Mapping=None,
flow=None):
"""
:param body:
The plaintext body of the message.
:param extras:
Extra data attached by a backend
:param flow:
The flow in which this message has been triggered.
"""
self._body = compat_str(body)
self._from = frm
self._to = to
self._delayed = delayed
self._extras = extras or dict()
self._flow = flow
# Convenience shortcut to the flow context
if flow:
self.ctx = flow.ctx
else:
self.ctx = {}
def clone(self):
return Message(self._body, self._from, self._to, self._delayed, self.extras)
......@@ -318,6 +328,16 @@ class Message(object):
def extras(self) -> Mapping:
return self._extras
@property
def flow(self):
"""
Get the conversation flow for this message.
:returns:
A :class:`~errbot.Flow`
"""
return self._from
def __str__(self):
return self._body
......
......@@ -430,7 +430,9 @@ class TestBot(object):
def assertCommand(self, command, response, timeout=5):
"""Assert the given command returns the given response"""
self.bot.push_message(command)
assert response in self.bot.pop_message(timeout)
msg = self.bot.pop_message(timeout)
if response not in msg:
raise Exception('"%s" not in "%s"' % (response, msg))
def assertCommandFound(self, command, timeout=5):
"""Assert the given command does not exist"""
......
......@@ -12,6 +12,23 @@ from errbot.backends.base import Message, Presence, Stream, Room, Identifier, ON
log = logging.getLogger(__name__)
class CommandError(Exception):
"""
Use this class to report an error condition from your commands, the command
did not proceed for a known "business" reason.
"""
def __init__(self, reason: str, template: str = None):
"""
:param reason: the reason for the error in the command.
:param template: apply this specific template to report the error.
"""
self.reason = reason
self.template = template
def __str__(self):
return str(self.reason)
# noinspection PyAbstractClass
class BotPluginBase(StoreMixin):
"""
......
[Core]
Name = Flows
Module = flows
Core = True
[Documentation]
Description = Core Errbot commands to query and manage conversation flows.
[Python]
Version = 2+
# -*- coding: utf-8 -*-
import io
import json
from errbot import BotPlugin, botcmd, arg_botcmd
from errbot.flow import FlowNode, FlowRoot
class Flows(BotPlugin):
""" Management commands related to flows / conversations.
"""
def recurse_node(self, response: io.StringIO, stack, f: FlowNode):
if f in stack:
response.write("%s⥀\n" % ("\t" * len(stack)))
return
if isinstance(f, FlowRoot):
response.write("Flow " + f.name + ": " + f.description + "\n")
else:
cmd = self._bot.commands[f.command]
response.write("%s⤷%s: %s\n" % ("\t" * len(stack), f, cmd.__doc__))
for _, sf in f.children:
self.recurse_node(response, stack + [f], sf)
# noinspection PyUnusedLocal
@botcmd(admin_only=True)
def flows(self, mess, args):
""" Displays the list of setup flows.
"""
with io.StringIO() as response:
if args:
flow = self._bot.flow_executor.flows.get(args, None)
if flow is None:
return "Flow %s doesn't exist." % args
self.recurse_node(response, [], flow)
else:
for name, flow in self._bot.flow_executor.flow_roots.items():
response.write(name + ": " + flow.description + "\n")
return response.getvalue()
@arg_botcmd('json_payload', type=str, nargs='?', default=None)
@arg_botcmd('flow_name', type=str)
def flows_start(self, mess, flow_name=None, json_payload=None):
""" Manually start a flow within the context of the calling user.
You can prefeed the flow data with a json payload.
Example:
!flows start poll_setup {\"title\":\"yeah!\",\"options\":[\"foo\",\"bar\",\"baz\"]}
"""
if not flow_name:
return "You need to specify a flow to manually start"
context = {}
if json_payload:
try:
context = json.loads(json_payload)
except Exception as e:
return "Cannot parse json %s: %s" % (json_payload, e)
self._bot.flow_executor.start_flow(flow_name, mess.frm, context)
return "Flow %s started ..." % flow_name
......@@ -19,6 +19,8 @@ import inspect
import logging
import traceback
from errbot import CommandError
from errbot.flow import FlowExecutor, FlowRoot
from .backends.base import Backend, Room, Identifier, Person, Message
from threadpool import ThreadPool, WorkRequest
from .streaming import Tee
......@@ -89,6 +91,7 @@ class ErrBot(Backend, StoreMixin):
self.plugin_manager = None
self.storage_plugin = None
self._plugin_errors_during_startup = None
self.flow_executor = FlowExecutor(self)
def attach_repo_manager(self, repo_manager):
self.repo_manager = repo_manager
......@@ -444,6 +447,12 @@ class ErrBot(Backend, StoreMixin):
private = cmd in self.bot_config.DIVERT_TO_PRIVATE
commands = self.re_commands if match else self.commands
try:
# first check if we need to reattach a flow context
flow, _ = self.flow_executor.check_inflight_flow_triggered(cmd, mess.frm)
if flow:
log.debug("Reattach context from flow %s to the message", flow._root.name)
mess.ctx = flow.ctx
if inspect.isgeneratorfunction(commands[cmd]):
replies = commands[cmd](mess, match) if match else commands[cmd](mess, args)
for reply in replies:
......@@ -453,6 +462,16 @@ class ErrBot(Backend, StoreMixin):
reply = commands[cmd](mess, match) if match else commands[cmd](mess, args)
if reply:
self.send_simple_reply(mess, self.process_template(template_name, reply), private)
# The command is a success, check if this has not made a flow progressed
self.flow_executor.trigger(cmd, mess.frm, mess.ctx)
except CommandError as command_error:
reason = command_error.reason
if command_error.template:
reason = self.process_template(command_error.template, reason)
self.send_simple_reply(mess, reason, private)
except Exception as e:
tb = traceback.format_exc()
log.exception('An error happened while processing '
......@@ -501,12 +520,31 @@ class ErrBot(Backend, StoreMixin):
log.debug('Adding command : %s -> %s' % (name, value.__name__))
self.commands = commands
def inject_flows_from(self, instance_to_inject):
classname = instance_to_inject.__class__.__name__
for name, method in inspect.getmembers(instance_to_inject, inspect.ismethod):
if getattr(method, '_err_flow', False):
log.debug('Found new flow %s: %s', classname, name)
flow = FlowRoot(name, method.__doc__)
try:
method(flow)
except Exception:
log.exception("Exception initializing a flow")
self.flow_executor.add_flow(flow)
def inject_command_filters_from(self, instance_to_inject):
for name, method in inspect.getmembers(instance_to_inject, inspect.ismethod):
if getattr(method, '_err_command_filter', False):
log.debug('Adding command filter: %s' % name)
self.command_filters.append(method)
def remove_flows_from(self, instance_to_inject):
for name, value in inspect.getmembers(instance_to_inject, inspect.ismethod):
if getattr(value, '_err_flow', False):
log.debug('Remove flow %s', name)
# TODO(gbin)
def remove_commands_from(self, instance_to_inject):
for name, value in inspect.getmembers(instance_to_inject, inspect.ismethod):
if getattr(value, '_err_command', False):
......
This diff is collapsed.
......@@ -9,6 +9,8 @@ import logging
import sys
import os
import pip
from errbot.flow import BotFlow
from .botplugin import BotPlugin
from .utils import (version2array, PY3, PY2, collect_roots, ensure_sys_path_contains)
from .templating import remove_plugin_templates_path, add_plugin_templates_path
......@@ -23,6 +25,9 @@ log = logging.getLogger(__name__)
CORE_PLUGINS = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'core_plugins')
BOTPLUGIN_TAG = 'botplugin'
BOTFLOW_TAG = 'botflow'
try:
from importlib import reload # new in python 3.4
except ImportError:
......@@ -230,9 +235,10 @@ class BotPluginManager(PluginManager, StoreMixin):
if self.CONFIGS not in self:
self[self.CONFIGS] = {}
locator = PluginFileLocator([PluginFileAnalyzerWithInfoFile("info_ext", 'plug')])
locator = PluginFileLocator([PluginFileAnalyzerWithInfoFile("info_ext", 'plug'),
PluginFileAnalyzerWithInfoFile("info_ext", 'flow')])
locator.disableRecursiveScan() # We do that ourselves
super().__init__(categories_filter={"bots": BotPlugin}, plugin_locator=locator)
super().__init__(categories_filter={BOTPLUGIN_TAG: BotPlugin, BOTFLOW_TAG: BotFlow}, plugin_locator=locator)
def attach_bot(self, bot):
self.bot = bot
......@@ -241,14 +247,14 @@ class BotPluginManager(PluginManager, StoreMixin):
return element(self.bot)
def get_plugin_by_name(self, name):
return self.getPluginByName(name, 'bots')
return self.getPluginByName(name, BOTPLUGIN_TAG)
def get_plugin_obj_by_name(self, name):
plugin = self.get_plugin_by_name(name)
return None if plugin is None else plugin.plugin_object
def activate_plugin_with_version_check(self, name, config):
pta_item = self.getPluginByName(name, 'bots')
pta_item = self.getPluginByName(name, BOTPLUGIN_TAG)
if pta_item is None:
log.warning('Could not activate %s', name)
return None
......@@ -281,23 +287,23 @@ class BotPluginManager(PluginManager, StoreMixin):
add_plugin_templates_path(pta_item.path)
populate_doc(pta_item)
try:
obj = self.activatePluginByName(name, "bots")
obj = self.activatePluginByName(name, BOTPLUGIN_TAG)
route(obj)
return obj
except Exception:
pta_item.activated = False # Yapsy doesn't revert this in case of error
remove_plugin_templates_path(pta_item.path)
log.error("Plugin %s failed at activation stage, deactivating it...", name)
self.deactivatePluginByName(name, "bots")
self.deactivatePluginByName(name, BOTPLUGIN_TAG)
raise
def deactivate_plugin_by_name(self, name):
# TODO handle the "un"routing.
pta_item = self.getPluginByName(name, 'bots')
pta_item = self.getPluginByName(name, BOTPLUGIN_TAG)
remove_plugin_templates_path(pta_item.path)
try:
return self.deactivatePluginByName(name, "bots")
return self.deactivatePluginByName(name, BOTPLUGIN_TAG)
except Exception:
add_plugin_templates_path(pta_item.path)
raise
......@@ -361,24 +367,26 @@ class BotPluginManager(PluginManager, StoreMixin):
self.all_candidates = [candidate[2] for candidate in self.getPluginCandidates()]
loaded_plugins = self.loadPlugins()
errors.update({pluginfo.path: ''.join(traceback.format_tb(pluginfo.error[2]))
for pluginfo in self.loadPlugins() if pluginfo.error is not None})
for pluginfo in loaded_plugins if pluginfo.error is not None})
return errors
def get_all_active_plugin_objects(self):
return [plug.plugin_object
for plug in self.getAllPlugins()
for plug in self.getPluginsOfCategory(BOTPLUGIN_TAG)
if hasattr(plug, 'is_activated') and plug.is_activated]
def get_all_active_plugin_names(self):
return [p.name for p in self.getAllPlugins() if hasattr(p, 'is_activated') and p.is_activated]
def get_all_plugin_names(self):
return [p.name for p in self.getAllPlugins()]
return [p.name for p in self.getPluginsOfCategory(BOTPLUGIN_TAG)]
def deactivate_all_plugins(self):
for name in self.get_all_active_plugin_names():
self.deactivatePluginByName(name, "bots")
self.deactivatePluginByName(name, BOTPLUGIN_TAG)
# plugin blacklisting management
def get_blacklisted_plugin(self):
......@@ -423,10 +431,10 @@ class BotPluginManager(PluginManager, StoreMixin):
return self.update_plugin_places(self.repo_manager.get_all_repos_paths(), self.extra, self.autoinstall_deps)
def activate_non_started_plugins(self):
log.info('Activating all the plugins...')
log.info('Activate bot plugins...')
configs = self[self.CONFIGS]
errors = ''
for pluginInfo in self.getAllPlugins():
for pluginInfo in self.getPluginsOfCategory(BOTPLUGIN_TAG):
try:
if self.is_plugin_blacklisted(pluginInfo.name):
errors += 'Notice: %s is blacklisted, use %s plugin unblacklist %s to unblacklist it\n' % (
......@@ -438,6 +446,28 @@ class BotPluginManager(PluginManager, StoreMixin):
except Exception as e:
log.exception("Error loading %s" % pluginInfo.name)
errors += 'Error: %s failed to start: %s\n' % (pluginInfo.name, e)
log.debug('Activate flow plugins ...')
for pluginInfo in self.getPluginsOfCategory(BOTFLOW_TAG):
try:
if hasattr(pluginInfo, 'is_activated') and not pluginInfo.is_activated:
name = pluginInfo.name
log.info('Activate flow: %s' % name)
pta_item = self.getPluginByName(name, BOTFLOW_TAG)
if pta_item is None:
log.warning('Could not activate %s', name)
continue
try:
self.activatePluginByName(name, BOTFLOW_TAG)
except Exception as e:
pta_item.activated = False # Yapsy doesn't revert this in case of error
log.error("Plugin %s failed at activation stage with e, deactivating it...", e, name)
self.deactivatePluginByName(name, BOTFLOW_TAG)
except Exception as e:
log.exception("Error loading flow %s" % pluginInfo.name)
errors += 'Error: flow %s failed to start: %s\n' % (pluginInfo.name, e)
return errors
def activate_plugin(self, name):
......
from os import path
from errbot.backends.test import FullStackTest
class TestCommands(FullStackTest):
def setUp(self, *args, **kwargs):
kwargs['extra_plugin_dir'] = path.join(path.dirname(path.realpath(__file__)), 'flow_plugin')
super().setUp(*args, **kwargs)
def test_list_flows(self):
self.assertEqual(len(self.bot.flow_executor.flow_roots), 2)
self.bot.push_message('!flows')
result = self.bot.pop_message()
self.assertIn('documentation of W1', result)
self.assertIn('documentation of W2', result)
self.assertIn('w1', result)
self.assertIn('w2', result)
def test_no_autotrigger(self):
self.assertCommand('!a', 'a')
self.assertEqual(len(self.bot.flow_executor.in_flight), 0)
def test_autotrigger(self):
self.assertCommand('!c', 'c')
flow_message = self.bot.pop_message()
self.assertIn('You are in the flow w2, you can continue with', flow_message)
self.assertIn('!b', flow_message)
self.assertEqual(len(self.bot.flow_executor.in_flight), 1)
self.assertEqual(self.bot.flow_executor.in_flight[0].name, 'w2')
def test_secondary_autotrigger(self):
self.assertCommand('!e', 'e')
second_message = self.bot.pop_message()
self.assertIn('You are in the flow w2, you can continue with', second_message)
self.assertIn('!d', second_message)
self.assertEqual(len(self.bot.flow_executor.in_flight), 1)
self.assertEqual(self.bot.flow_executor.in_flight[0].name, 'w2')
def test_manual_flow(self):
self.assertCommand('!flows start w1', 'Flow w1 started')
flow_message = self.bot.pop_message()
self.assertIn('You are in the flow w1, you can continue with', flow_message)
self.assertIn('!a', flow_message)
self.assertCommand('!a', 'a')
flow_message = self.bot.pop_message()
self.assertIn('You are in the flow w1, you can continue with', flow_message)
self.assertIn('!b', flow_message)
self.assertIn('!c', flow_message)
def test_no_flyby_trigger_flow(self):
self.assertCommand('!flows start w1', 'Flow w1 started')
flow_message = self.bot.pop_message()
self.assertIn('You are in the flow w1', flow_message)
self.assertCommand('!a', 'a')
flow_message = self.bot.pop_message()
self.assertIn('You are in the flow w1', flow_message)
self.assertCommand('!c', 'c') # c is a trigger for w2 but it should not trigger now.
flow_message = self.bot.pop_message()
self.assertIn('You are in the flow w1', flow_message)
self.assertEqual(len(self.bot.flow_executor.in_flight), 1)
[Core]
Name = FlowTest
Module = flowtest_flows
[Python]
Version = 2+
[Core]
Name = FlowTest
Module = flowtest
[Python]
Version = 2+
from __future__ import absolute_import
from errbot import BotPlugin, botcmd
class FlowTest(BotPlugin):
"""A plugin to test the flows
see flowtest.png for the structure.
"""
@botcmd
def a(self, msg, args):
return 'a'
@botcmd
def b(self, msg, args):
return 'b'
@botcmd
def c(self, msg, args):
return 'c'
@botcmd
def d(self, msg, args):
return 'd'
@botcmd
def e(self, msg, args):
return 'e'
from __future__ import absolute_import
from errbot import botflow, FlowRoot, BotFlow
class FlowDefinitions(BotFlow):
"""A plugin to test the flows
see flowtest.png for the structure.
"""
@botflow
def w1(self, flow: FlowRoot):
"documentation of W1"
a_node = flow.connect('a') # no autotrigger
b_node = a_node.connect('b')
c_node = a_node.connect('c') # crosses the autotrigger of w2
d_node = c_node.connect('d')
@botflow
def w2(self, flow: FlowRoot):
"documentation of W2"
c_node = flow.connect('c', auto_trigger=True)
b_node = c_node.connect('b')
e_node = flow.connect('e', auto_trigger=True) # 2 autotriggers for the same workflow
d_node = e_node.connect('d')
import logging
import unittest
from errbot.backends.test import TestPerson
from errbot.flow import Flow, FlowRoot, InvalidState
log = logging.getLogger(__name__)
class FlowTest(unittest.TestCase):
def test_node(self):
root = FlowRoot("test", "This is my flowroot")
node = root.connect("a", lambda ctx: ctx['toto'] == 'titui')
self.assertTrue(root.predicate_for_node(node)({'toto': 'titui'}))
self.assertFalse(root.predicate_for_node(node)({'toto': 'blah'}))
def test_flow_predicate(self):
root = FlowRoot("test", "This is my flowroot")
node = root.connect("a", lambda ctx: 'toto' in ctx and ctx['toto'] == 'titui')
somebody = TestPerson('me')
# Non-matching predicate
flow = Flow(root, somebody, {})
self.assertIn(node, flow.next_steps())
self.assertNotIn(node, flow.next_autosteps())
self.assertRaises(InvalidState, flow.advance, node)
flow.advance(node, enforce_predicate=False) # This will bypass the restriction
self.assertEqual(flow._current_step, node)
# Matching predicate
flow = Flow(root, somebody, {'toto': 'titui'})
self.assertIn(node, flow.next_steps())
self.assertIn(node, flow.next_autosteps())
flow.advance(node)
self.assertEqual(flow._current_step, node)
def test_autotrigger(self):
root = FlowRoot("test", "This is my flowroot")
node = root.connect("a", lambda ctx: 'toto' in ctx and ctx['toto'] == 'titui', auto_trigger=True)
self.assertIn(node.command, root.auto_triggers)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment