Skip to content

Commit

Permalink
zulip_bots: Add a script for creating Zulip bots.
Browse files Browse the repository at this point in the history
Following support to running bots from entry points in zulip#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.
  • Loading branch information
PIG208 committed Jul 30, 2021
1 parent 285a946 commit 22c8c9e
Show file tree
Hide file tree
Showing 4 changed files with 192 additions and 1 deletion.
3 changes: 2 additions & 1 deletion zulip_bots/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
```
1 change: 1 addition & 0 deletions zulip_bots/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
139 changes: 139 additions & 0 deletions zulip_bots/zulip_bots/create_bot.py
Original file line number Diff line number Diff line change
@@ -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 <bot_name>
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()
50 changes: 50 additions & 0 deletions zulip_bots/zulip_bots/tests/test_create.py
Original file line number Diff line number Diff line change
@@ -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")

0 comments on commit 22c8c9e

Please sign in to comment.