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 all commits
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()
76 changes: 34 additions & 42 deletions zulip_bots/zulip_bots/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,6 @@ def parse_args() -> argparse.Namespace:
help="try running the bot even if dependencies install fails",
)

parser.add_argument(
"--registry",
"-r",
action="store_true",
help="run the bot via zulip_bots registry",
)

parser.add_argument("--provision", action="store_true", help="install dependencies for the bot")

args = parser.parse_args()
Expand Down Expand Up @@ -116,50 +109,49 @@ def exit_gracefully_if_bot_config_file_does_not_exist(bot_config_file: Optional[
def main() -> None:
args = parse_args()

if args.registry:
result = finder.resolve_bot_path(args.bot)
if result:
bot_path, bot_name = result
sys.path.insert(0, os.path.dirname(bot_path))

if args.provision:
provision_bot(os.path.dirname(bot_path), args.force)

try:
bot_source, lib_module = finder.import_module_from_zulip_bot_registry(args.bot)
except finder.DuplicateRegisteredBotName as error:
print(
f'ERROR: Found duplicate entries for "{error}" in zulip bots registry.\n'
"Make sure that you don't install bots using the same entry point. Exiting now."
lib_module = finder.import_module_from_source(bot_path.as_posix(), bot_name)
except ImportError:
req_path = os.path.join(os.path.dirname(bot_path), "requirements.txt")
with open(req_path) as fp:
deps_list = fp.read()

dep_err_msg = (
"ERROR: The following dependencies for the {bot_name} bot are not installed:\n\n"
"{deps_list}\n"
"If you'd like us to install these dependencies, run:\n"
" zulip-run-bot {bot_name} --provision"
)
print(dep_err_msg.format(bot_name=bot_name, deps_list=deps_list))
sys.exit(1)
if lib_module:
bot_name = args.bot
bot_source = "source"
else:
result = finder.resolve_bot_path(args.bot)
if result:
bot_path, bot_name = result
sys.path.insert(0, os.path.dirname(bot_path))

lib_module = finder.import_module_by_name(args.bot)
if lib_module and hasattr(lib_module, "handler_class"):
bot_name = lib_module.__name__
bot_source = "named module"
if args.provision:
provision_bot(os.path.dirname(bot_path), args.force)

print("ERROR: Could not load bot's module for '{}'. Exiting now.")
sys.exit(1)
else:
try:
lib_module = finder.import_module_from_source(bot_path.as_posix(), bot_name)
except ImportError:
req_path = os.path.join(os.path.dirname(bot_path), "requirements.txt")
with open(req_path) as fp:
deps_list = fp.read()

dep_err_msg = (
"ERROR: The following dependencies for the {bot_name} bot are not installed:\n\n"
"{deps_list}\n"
"If you'd like us to install these dependencies, run:\n"
" zulip-run-bot {bot_name} --provision"
bot_source, lib_module = finder.import_module_from_zulip_bot_registry(args.bot)
except finder.DuplicateRegisteredBotName as error:
print(
f'ERROR: Found duplicate entries for "{error}" in zulip bots registry.\n'
"Make sure that you don't install bots using the same entry point. Exiting now."
)
print(dep_err_msg.format(bot_name=bot_name, deps_list=deps_list))
sys.exit(1)
bot_source = "source"
else:
lib_module = finder.import_module_by_name(args.bot)
if lib_module:
bot_name = lib_module.__name__
bot_source = "named module"
if args.provision:
print("ERROR: Could not load bot's module for '{}'. Exiting now.")
sys.exit(1)
bot_name = args.bot

if lib_module is None:
print("ERROR: Could not load bot module. Exiting now.")
Expand Down
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")
8 changes: 4 additions & 4 deletions zulip_bots/zulip_bots/tests/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,14 @@ def test_argument_parsing_with_bot_path(
quiet=False,
)

@patch(
"sys.argv", ["zulip-run-bot", "packaged_bot", "--config-file", "/foo/bar/baz.conf", "-r"]
)
@patch("sys.argv", ["zulip-run-bot", "packaged_bot", "--config-file", "/foo/bar/baz.conf"])
@patch("zulip_bots.run.run_message_handler_for_bot")
def test_argument_parsing_with_zulip_bot_registry(
self, mock_run_message_handler_for_bot: mock.Mock
) -> None:
with patch("zulip_bots.run.exit_gracefully_if_zulip_config_is_missing"), patch(
with patch("importlib.import_module", return_value={}), patch(
"zulip_bots.run.exit_gracefully_if_zulip_config_is_missing"
), patch(
"zulip_bots.finder.metadata.EntryPoint.load",
return_value=self.packaged_bot_module,
), patch(
Expand Down