Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

zulip_bots: Add a script for creating Zulip bots. #709

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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}"],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this working assume the generating directory (default .) is actually equal to the bot name that is supplied? If so, it seems cleaner to make the base folder and the nested one relative to the current location?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name of the base directory doesn't matter. We assume that the nested directory and the bot module have the same name as the bot. This is the same practice we have for bots under the zulip_bots/zulip_bots/bots directory.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right you are, this was from a quick read rather than test.

}},
"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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading this again, is there a reason not to use the pathlib functions instead, if we're already using Path? That is, can we drop import os?

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")