From 80e4ef9f729af0c30d12cf776ac5251d45ca4dc3 Mon Sep 17 00:00:00 2001 From: Steve Howell Date: Tue, 28 Nov 2017 08:44:39 -0800 Subject: [PATCH] Create terminal.py to run bots in the terminal. This program replaces zulip_bot_output.py, which had gotten a little out of date. It should be able to simulate a terminal conversation for all of our bots, including those that use "advanced" features: third party config files: tested with giphy message updates: tested with incrementor storage: tested with virtual_fs and others --- tools/run-mypy | 3 + zulip_bots/README.md | 3 +- zulip_bots/setup.py | 2 +- zulip_bots/zulip_bots/simple_lib.py | 80 ++++++++++++++++ zulip_bots/zulip_bots/terminal.py | 73 +++++++++++++++ zulip_bots/zulip_bots/zulip_bot_output.py | 109 ---------------------- 6 files changed, 159 insertions(+), 111 deletions(-) create mode 100644 zulip_bots/zulip_bots/simple_lib.py create mode 100644 zulip_bots/zulip_bots/terminal.py delete mode 100644 zulip_bots/zulip_bots/zulip_bot_output.py diff --git a/tools/run-mypy b/tools/run-mypy index 60e1f95..692f9c5 100755 --- a/tools/run-mypy +++ b/tools/run-mypy @@ -25,6 +25,9 @@ exclude = [ # fully annotate their bots. "zulip_bots/zulip_bots/bots", "zulip_bots/zulip_bots/bots_unmaintained", + # Excluded out of laziness: + "zulip_bots/zulip_bots/terminal.py", + "zulip_bots/zulip_bots/simple_lib.py", ] parser = argparse.ArgumentParser(description="Run mypy on files tracked by git.") diff --git a/zulip_bots/README.md b/zulip_bots/README.md index c30c9fb..34c0ce8 100644 --- a/zulip_bots/README.md +++ b/zulip_bots/README.md @@ -17,9 +17,10 @@ zulip_bots # This directory │ ├───lib.py # Backbone of run.py │ ├───provision.py # Creates a development environment. │ ├───run.py # Used to run bots. +│ ├───simple_lib.py # Used for terminal testing. │ ├───test_lib.py # Backbone for bot unit tests. │ ├───test_run.py # Unit tests for run.py -│ └───zulip_bot_output.py # Used to test bots in the command line. +│ └───terminal.py # Used to test bots in the command line. ├───generate_manifest.py # Helper-script for packaging. └───setup.py # Script for packaging. ``` diff --git a/zulip_bots/setup.py b/zulip_bots/setup.py index 13b01b7..d432c02 100755 --- a/zulip_bots/setup.py +++ b/zulip_bots/setup.py @@ -36,7 +36,7 @@ package_info = dict( entry_points={ 'console_scripts': [ 'zulip-run-bot=zulip_bots.run:main', - 'zulip-bot-output=zulip_bots.zulip_bot_output:main' + 'zulip-terminal=zulip_bots.terminal:main' ], }, include_package_data=True, diff --git a/zulip_bots/zulip_bots/simple_lib.py b/zulip_bots/zulip_bots/simple_lib.py new file mode 100644 index 0000000..393ce2f --- /dev/null +++ b/zulip_bots/zulip_bots/simple_lib.py @@ -0,0 +1,80 @@ +import configparser + +class SimpleStorage: + def __init__(self): + self.data = dict() + + def contains(self, key): + return (key in self.data) + + def put(self, key, value): + self.data[key] = value + + def get(self, key): + return self.data[key] + +class SimpleMessageServer: + # This class is needed for the incrementor bot, which + # actually updates messages! + def __init__(self): + self.message_id = 0 + self.messages = dict() + + def send(self, message): + self.message_id += 1 + message['id'] = self.message_id + self.messages[self.message_id] = message + return message + + def update(self, message): + self.messages[message['message_id']] = message + +class TerminalBotHandler: + def __init__(self, bot_config_file): + self.bot_config_file = bot_config_file + self.storage = SimpleStorage() + self.message_server = SimpleMessageServer() + + def send_message(self, message): + if message['type'] == 'stream': + print(''' + stream: {} topic: {} + {} + '''.format(message['to'], message['subject'], message['content'])) + else: + print(''' + PM response: + {} + '''.format(message['content'])) + return self.message_server.send(message) + + def send_reply(self, message, response): + print(''' + reply: + {} + '''.format(response)) + response_message = dict( + content=response + ) + return self.message_server.send(response_message) + + def update_message(self, message): + self.message_server.update(message) + print(''' + update to message #{}: + {} + '''.format(message['message_id'], message['content'])) + + def get_config_info(self, bot_name, optional=False): + if self.bot_config_file is None: + if optional: + return dict() + else: + print('Please supply --bot-config-file argument.') + sys.exit(1) + + config = configparser.ConfigParser() + with open(self.bot_config_file) as conf: + config.readfp(conf) # type: ignore # readfp->read_file in python 3, so not in stubs + + return dict(config.items(bot_name)) diff --git a/zulip_bots/zulip_bots/terminal.py b/zulip_bots/zulip_bots/terminal.py new file mode 100644 index 0000000..9e499a9 --- /dev/null +++ b/zulip_bots/zulip_bots/terminal.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python +import os +import sys +import argparse + +from zulip_bots.run import import_module_from_source +from zulip_bots.simple_lib import TerminalBotHandler + +current_dir = os.path.dirname(os.path.abspath(__file__)) + +def parse_args(): + description = ''' + This tool allows you to test a bot using the terminal (and no Zulip server). + + Examples: %(prog)s followup + ''' + + parser = argparse.ArgumentParser(description=description, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('bot', + action='store', + help='the name or path an existing bot to run') + + parser.add_argument('--bot-config-file', '-b', + action='store', + help='optional third party config file (e.g. ~/giphy.conf)') + + args = parser.parse_args() + return args + +def main(): + args = parse_args() + if os.path.isfile(args.bot): + bot_path = os.path.abspath(args.bot) + bot_name = os.path.splitext(os.path.basename(bot_path))[0] + else: + bot_path = os.path.abspath(os.path.join(current_dir, 'bots', args.bot, args.bot+'.py')) + bot_name = args.bot + bot_dir = os.path.dirname(bot_path) + + try: + lib_module = import_module_from_source(bot_path, bot_name) + except IOError: + print("Could not find and import bot '{}'".format(bot_name)) + sys.exit(1) + + try: + message_handler = lib_module.handler_class() + except AttributeError: + print("This module does not appear to have a bot handler_class specified.") + sys.exit(1) + + bot_handler = TerminalBotHandler(args.bot_config_file) + if hasattr(message_handler, 'initialize') and callable(message_handler.initialize): + message_handler.initialize(bot_handler) + + sender_email = 'foo_sender@zulip.com' + + while True: + content = input('Enter your message: ') + + message = dict( + content=content, + sender_email=sender_email, + display_recipient=sender_email, + ) + message_handler.handle_message( + message=message, + bot_handler=bot_handler, + ) + +if __name__ == '__main__': + main() diff --git a/zulip_bots/zulip_bots/zulip_bot_output.py b/zulip_bots/zulip_bots/zulip_bot_output.py deleted file mode 100644 index 0479d36..0000000 --- a/zulip_bots/zulip_bots/zulip_bot_output.py +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env python -from __future__ import print_function -from __future__ import absolute_import - -import os -import sys -import argparse -import zulip_bots - -from six.moves import configparser -from typing import Any - -from mock import MagicMock, patch -from zulip_bots.lib import StateHandler -from zulip_bots.lib import ExternalBotHandler -from zulip_bots.provision import provision_bot -from zulip_bots.run import import_module_from_source - -current_dir = os.path.dirname(os.path.abspath(__file__)) - -def parse_args(): - # type: () -> argparse.Namespace - description = ( - "A tool to test a bot: given a provided message, provides the bot response.\n\n" - 'Examples: %(prog)s xkcd 1\n' - ' %(prog)s ./bot_folder/my_own_bot.py "test message"') - epilog = ( - "The message need only be enclosed in quotes if empty or containing spaces.\n\n" - "(Internally, this program loads bot-related code from the library code and\n" - "then feeds the message provided in the command to the library code to handle.)") - - parser = argparse.ArgumentParser(description=description, - epilog=epilog, - formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument('bot', - action='store', - help='the name or path an existing bot to run') - - parser.add_argument('message', - action='store', - help='the message content to send to the bot') - - parser.add_argument('--force', '-f', - action='store_true', - help='try running bot even if dependencies install fails') - - parser.add_argument('--provision', '-p', - action='store_true', - help='install dependencies for the bot') - - args = parser.parse_args() - return args - -def main(): - # type: () -> None - args = parse_args() - if os.path.isfile(args.bot): - bot_path = os.path.abspath(args.bot) - bot_name = os.path.splitext(os.path.basename(bot_path))[0] - else: - bot_path = os.path.abspath(os.path.join(current_dir, 'bots', args.bot, args.bot+'.py')) - bot_name = args.bot - bot_dir = os.path.dirname(bot_path) - if args.provision: - provision_bot(os.path.dirname(bot_path), args.force) - try: - lib_module = import_module_from_source(bot_path, bot_name) - except IOError: - print("Could not find and import bot '{}'".format(bot_name)) - sys.exit(1) - - message = {'content': args.message, 'sender_email': 'foo_sender@zulip.com'} - try: - message_handler = lib_module.handler_class() - except AttributeError: - print("This module does not appear to have a bot handler_class specified.") - sys.exit(1) - - with patch('zulip.Client') as mock_client: - mock_bot_handler = ExternalBotHandler(mock_client, bot_dir) # type: Any - mock_bot_handler.send_reply = MagicMock() - mock_bot_handler.send_message = MagicMock() - mock_bot_handler.update_message = MagicMock() - if hasattr(message_handler, 'initialize') and callable(message_handler.initialize): - message_handler.initialize(mock_bot_handler) - message_handler.handle_message( - message=message, - bot_handler=mock_bot_handler - ) - print("On sending {} bot the message \"{}\"".format(bot_name, args.message)) - # send_reply and send_message have slightly arguments; the - # following takes that into account. - # send_reply(original_message, response) - # send_message(response_message) - if mock_bot_handler.send_reply.called: - output_message = list(mock_bot_handler.send_reply.call_args)[0][1] - elif mock_bot_handler.send_message.called: - output_message = list(mock_bot_handler.send_message.call_args)[0][0] - elif mock_bot_handler.update_message.called: - output_message = list(mock_bot_handler.update_message.call_args)[0][0]['content'] - print("the bot updates a message with the following text (in quotes):\n\"{}\"".format(output_message)) - sys.exit() - else: - print("the bot sent no reply.") - sys.exit() - print("the bot gives the following output message (in quotes):\n\"{}\"".format(output_message)) - -if __name__ == '__main__': - main()