From 22c8c9e3939d1ebfcf459a98ae8c280b6093723a Mon Sep 17 00:00:00 2001 From: PIG208 <359101898@qq.com> Date: Sat, 31 Jul 2021 01:13:57 +0800 Subject: [PATCH] zulip_bots: Add a script for creating Zulip bots. Following support to running bots from entry points in #708, we implement this `create-zulip-bot` tool to simplify the process of creating new bots. The user will be able to directly install the package with pip and run the bot with `zulip-run-bot`, or use it to quickly set up a git repository. Note that the boilerplate generated by this script does not contain `tests.py` yet. We need to figure out the right pattern for integrating unittests for such packaged bots. --- zulip_bots/README.md | 3 +- zulip_bots/setup.py | 1 + zulip_bots/zulip_bots/create_bot.py | 139 +++++++++++++++++++++ zulip_bots/zulip_bots/tests/test_create.py | 50 ++++++++ 4 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 zulip_bots/zulip_bots/create_bot.py create mode 100644 zulip_bots/zulip_bots/tests/test_create.py diff --git a/zulip_bots/README.md b/zulip_bots/README.md index 28cf5616fd..e4a93d5309 100644 --- a/zulip_bots/README.md +++ b/zulip_bots/README.md @@ -21,6 +21,7 @@ zulip_bots # This directory │ ├───simple_lib.py # Used for terminal testing. │ ├───test_lib.py # Backbone for bot unit tests. │ ├───test_run.py # Unit tests for run.py -│ └───terminal.py # Used to test bots in the command line. +│ ├───terminal.py # Used to test bots in the command line. +│ └───create_bot.py # Used to create new packaged bots. └───setup.py # Script for packaging. ``` diff --git a/zulip_bots/setup.py b/zulip_bots/setup.py index 08acc4331e..5012225036 100644 --- a/zulip_bots/setup.py +++ b/zulip_bots/setup.py @@ -53,6 +53,7 @@ "console_scripts": [ "zulip-run-bot=zulip_bots.run:main", "zulip-terminal=zulip_bots.terminal:main", + "zulip-create-bot=zulip_bots.create_bot:main", ], }, include_package_data=True, diff --git a/zulip_bots/zulip_bots/create_bot.py b/zulip_bots/zulip_bots/create_bot.py new file mode 100644 index 0000000000..69df38409d --- /dev/null +++ b/zulip_bots/zulip_bots/create_bot.py @@ -0,0 +1,139 @@ +import argparse +import os +from pathlib import Path + +DOC_TEMPLATE = """Simple Zulip bot that will respond to any query with a "beep boop". + +This is a boilerplate bot that can be used as a template for more +sophisticated/evolved Zulip bots that can be installed separately. +""" + + +README_TEMPLATE = """This is a boilerplate package for a Zulip bot that can be installed from pip +and launched using the `zulip-run-bots` command. +""" + +SETUP_TEMPLATE = """import {bot_name} +from setuptools import find_packages, setup + +package_info = {{ + "name": "{bot_name}", + "version": {bot_name}.__version__, + "entry_points": {{ + "zulip_bots.registry": ["{bot_name}={bot_name}.{bot_name}"], + }}, + "packages": find_packages(), +}} + +setup(**package_info) +""" + +BOT_MODULE_TEMPLATE = """# See readme.md for instructions on running this code. +from typing import Any, Dict + +import {bot_name} + +from zulip_bots.lib import BotHandler + +__version__ = {bot_name}.__version__ + + +class {handler_name}: + def usage(self) -> str: + return \""" + This is a boilerplate bot that responds to a user query with + "beep boop", which is robot for "Hello World". + + This bot can be used as a template for other, more + sophisticated, bots that can be installed separately. + \""" + + def handle_message(self, message: Dict[str, Any], bot_handler: BotHandler) -> None: + content = "beep boop" # type: str + bot_handler.send_reply(message, content) + + emoji_name = "wave" # type: str + bot_handler.react(message, emoji_name) + + +handler_class = {handler_name} +""" + + +def create_bot_file(path: Path, file_name: str, content: str) -> None: + with open(Path(path, file_name), "w") as file: + file.write(content) + + +def parse_args() -> argparse.Namespace: + usage = """ + zulip-create-bot + zulip-create-bot --help + """ + + parser = argparse.ArgumentParser(usage=usage, description="Create a minimal Zulip bot package.") + + parser.add_argument("bot", help="the name of the bot to be created") + + parser.add_argument("--output", "-o", help="the target directory for the new bot", default=".") + + parser.add_argument( + "--force", + "-f", + action="store_true", + help="forcibly overwrite the existing files in the output directory", + ) + + parser.add_argument("--quiet", "-q", action="store_true", help="turn off logging output") + + args = parser.parse_args() + + if not args.bot.isidentifier(): + parser.error(f'"{args.bot}" is not a valid Python identifier') + + if args.output is not None and not os.path.isdir(args.output): + parser.error(f"{args.output} is not a valid path") + + return parser.parse_args() + + +def main() -> None: + args = parse_args() + + handler_name = f'{args.bot.title().replace("_", "")}Handler' + + bot_path = Path(args.output, args.bot) + bot_module_path = Path(bot_path, args.bot) + + try: + os.mkdir(bot_path) + os.mkdir(bot_module_path) + except FileExistsError as err: + if not args.force: + print( + f'The directory "{err.filename}" already exists\nUse -f or --force to forcibly overwrite the existing files' + ) + exit(1) + + create_bot_file(bot_path, "README.md", README_TEMPLATE) + create_bot_file(bot_path, "setup.py", SETUP_TEMPLATE.format(bot_name=args.bot)) + create_bot_file(bot_module_path, "doc.md", DOC_TEMPLATE.format(bot_name=args.bot)) + create_bot_file(bot_module_path, "__init__.py", '__version__ = "1.0.0"') + create_bot_file( + bot_module_path, + f"{args.bot}.py", + BOT_MODULE_TEMPLATE.format(bot_name=args.bot, handler_name=handler_name), + ) + + output_path = os.path.abspath(bot_path) + if not args.quiet: + print( + f"""Successfully set up {args.bot} at {output_path}\n + You can install it with "pip install -e {output_path}"\n + and then run it with "zulip-run-bot -r {args.bot} -c CONFIG_FILE" + """ + ) + + +if __name__ == "__main__": + main() diff --git a/zulip_bots/zulip_bots/tests/test_create.py b/zulip_bots/zulip_bots/tests/test_create.py new file mode 100644 index 0000000000..6186214a6b --- /dev/null +++ b/zulip_bots/zulip_bots/tests/test_create.py @@ -0,0 +1,50 @@ +import argparse +from pathlib import Path +from unittest import TestCase +from unittest.mock import MagicMock, call, patch + +from zulip_bots.create_bot import main + + +class CreateBotTestCase(TestCase): + @patch("sys.argv", ["zulip-create-bot", "test_bot", "-q"]) + @patch("zulip_bots.create_bot.open") + def test_create_successfully(self, mock_open: MagicMock) -> None: + with patch("os.mkdir"): + main() + + bot_path, bot_module_path = Path(".", "test_bot"), Path(".", "test_bot", "test_bot") + mock_open.assert_has_calls( + [ + call(Path(bot_path, "README.md"), "w"), + call(Path(bot_path, "setup.py"), "w"), + call(Path(bot_module_path, "doc.md"), "w"), + call(Path(bot_module_path, "__init__.py"), "w"), + call(Path(bot_module_path, "test_bot.py"), "w"), + ], + True, + ) + + @patch("sys.argv", ["zulip-create-bot", "test-bot"]) + def test_create_with_invalid_names(self) -> None: + with patch.object( + argparse.ArgumentParser, "error", side_effect=InterruptedError + ) as mock_error: + try: + main() + except InterruptedError: + pass + + mock_error.assert_called_with('"test-bot" is not a valid Python identifier') + + @patch("sys.argv", ["zulip-create-bot", "test_bot", "-o", "invalid_path"]) + def test_create_with_invalid_path(self) -> None: + with patch("os.path.isdir", return_value=False), patch.object( + argparse.ArgumentParser, "error", side_effect=InterruptedError + ) as mock_error: + try: + main() + except InterruptedError: + pass + + mock_error.assert_called_with("invalid_path is not a valid path")