Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 22c8c9e

Browse files
committedJul 30, 2021
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.
1 parent 285a946 commit 22c8c9e

File tree

4 files changed

+192
-1
lines changed

4 files changed

+192
-1
lines changed
 

‎zulip_bots/README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ zulip_bots # This directory
2121
│ ├───simple_lib.py # Used for terminal testing.
2222
│ ├───test_lib.py # Backbone for bot unit tests.
2323
│ ├───test_run.py # Unit tests for run.py
24-
│ └───terminal.py # Used to test bots in the command line.
24+
│ ├───terminal.py # Used to test bots in the command line.
25+
│ └───create_bot.py # Used to create new packaged bots.
2526
└───setup.py # Script for packaging.
2627
```

‎zulip_bots/setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"console_scripts": [
5454
"zulip-run-bot=zulip_bots.run:main",
5555
"zulip-terminal=zulip_bots.terminal:main",
56+
"zulip-create-bot=zulip_bots.create_bot:main",
5657
],
5758
},
5859
include_package_data=True,

‎zulip_bots/zulip_bots/create_bot.py

+139
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import argparse
2+
import os
3+
from pathlib import Path
4+
5+
DOC_TEMPLATE = """Simple Zulip bot that will respond to any query with a "beep boop".
6+
7+
This is a boilerplate bot that can be used as a template for more
8+
sophisticated/evolved Zulip bots that can be installed separately.
9+
"""
10+
11+
12+
README_TEMPLATE = """This is a boilerplate package for a Zulip bot that can be installed from pip
13+
and launched using the `zulip-run-bots` command.
14+
"""
15+
16+
SETUP_TEMPLATE = """import {bot_name}
17+
from setuptools import find_packages, setup
18+
19+
package_info = {{
20+
"name": "{bot_name}",
21+
"version": {bot_name}.__version__,
22+
"entry_points": {{
23+
"zulip_bots.registry": ["{bot_name}={bot_name}.{bot_name}"],
24+
}},
25+
"packages": find_packages(),
26+
}}
27+
28+
setup(**package_info)
29+
"""
30+
31+
BOT_MODULE_TEMPLATE = """# See readme.md for instructions on running this code.
32+
from typing import Any, Dict
33+
34+
import {bot_name}
35+
36+
from zulip_bots.lib import BotHandler
37+
38+
__version__ = {bot_name}.__version__
39+
40+
41+
class {handler_name}:
42+
def usage(self) -> str:
43+
return \"""
44+
This is a boilerplate bot that responds to a user query with
45+
"beep boop", which is robot for "Hello World".
46+
47+
This bot can be used as a template for other, more
48+
sophisticated, bots that can be installed separately.
49+
\"""
50+
51+
def handle_message(self, message: Dict[str, Any], bot_handler: BotHandler) -> None:
52+
content = "beep boop" # type: str
53+
bot_handler.send_reply(message, content)
54+
55+
emoji_name = "wave" # type: str
56+
bot_handler.react(message, emoji_name)
57+
58+
59+
handler_class = {handler_name}
60+
"""
61+
62+
63+
def create_bot_file(path: Path, file_name: str, content: str) -> None:
64+
with open(Path(path, file_name), "w") as file:
65+
file.write(content)
66+
67+
68+
def parse_args() -> argparse.Namespace:
69+
usage = """
70+
zulip-create-bot <bot_name>
71+
zulip-create-bot --help
72+
"""
73+
74+
parser = argparse.ArgumentParser(usage=usage, description="Create a minimal Zulip bot package.")
75+
76+
parser.add_argument("bot", help="the name of the bot to be created")
77+
78+
parser.add_argument("--output", "-o", help="the target directory for the new bot", default=".")
79+
80+
parser.add_argument(
81+
"--force",
82+
"-f",
83+
action="store_true",
84+
help="forcibly overwrite the existing files in the output directory",
85+
)
86+
87+
parser.add_argument("--quiet", "-q", action="store_true", help="turn off logging output")
88+
89+
args = parser.parse_args()
90+
91+
if not args.bot.isidentifier():
92+
parser.error(f'"{args.bot}" is not a valid Python identifier')
93+
94+
if args.output is not None and not os.path.isdir(args.output):
95+
parser.error(f"{args.output} is not a valid path")
96+
97+
return parser.parse_args()
98+
99+
100+
def main() -> None:
101+
args = parse_args()
102+
103+
handler_name = f'{args.bot.title().replace("_", "")}Handler'
104+
105+
bot_path = Path(args.output, args.bot)
106+
bot_module_path = Path(bot_path, args.bot)
107+
108+
try:
109+
os.mkdir(bot_path)
110+
os.mkdir(bot_module_path)
111+
except FileExistsError as err:
112+
if not args.force:
113+
print(
114+
f'The directory "{err.filename}" already exists\nUse -f or --force to forcibly overwrite the existing files'
115+
)
116+
exit(1)
117+
118+
create_bot_file(bot_path, "README.md", README_TEMPLATE)
119+
create_bot_file(bot_path, "setup.py", SETUP_TEMPLATE.format(bot_name=args.bot))
120+
create_bot_file(bot_module_path, "doc.md", DOC_TEMPLATE.format(bot_name=args.bot))
121+
create_bot_file(bot_module_path, "__init__.py", '__version__ = "1.0.0"')
122+
create_bot_file(
123+
bot_module_path,
124+
f"{args.bot}.py",
125+
BOT_MODULE_TEMPLATE.format(bot_name=args.bot, handler_name=handler_name),
126+
)
127+
128+
output_path = os.path.abspath(bot_path)
129+
if not args.quiet:
130+
print(
131+
f"""Successfully set up {args.bot} at {output_path}\n
132+
You can install it with "pip install -e {output_path}"\n
133+
and then run it with "zulip-run-bot -r {args.bot} -c CONFIG_FILE"
134+
"""
135+
)
136+
137+
138+
if __name__ == "__main__":
139+
main()
+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import argparse
2+
from pathlib import Path
3+
from unittest import TestCase
4+
from unittest.mock import MagicMock, call, patch
5+
6+
from zulip_bots.create_bot import main
7+
8+
9+
class CreateBotTestCase(TestCase):
10+
@patch("sys.argv", ["zulip-create-bot", "test_bot", "-q"])
11+
@patch("zulip_bots.create_bot.open")
12+
def test_create_successfully(self, mock_open: MagicMock) -> None:
13+
with patch("os.mkdir"):
14+
main()
15+
16+
bot_path, bot_module_path = Path(".", "test_bot"), Path(".", "test_bot", "test_bot")
17+
mock_open.assert_has_calls(
18+
[
19+
call(Path(bot_path, "README.md"), "w"),
20+
call(Path(bot_path, "setup.py"), "w"),
21+
call(Path(bot_module_path, "doc.md"), "w"),
22+
call(Path(bot_module_path, "__init__.py"), "w"),
23+
call(Path(bot_module_path, "test_bot.py"), "w"),
24+
],
25+
True,
26+
)
27+
28+
@patch("sys.argv", ["zulip-create-bot", "test-bot"])
29+
def test_create_with_invalid_names(self) -> None:
30+
with patch.object(
31+
argparse.ArgumentParser, "error", side_effect=InterruptedError
32+
) as mock_error:
33+
try:
34+
main()
35+
except InterruptedError:
36+
pass
37+
38+
mock_error.assert_called_with('"test-bot" is not a valid Python identifier')
39+
40+
@patch("sys.argv", ["zulip-create-bot", "test_bot", "-o", "invalid_path"])
41+
def test_create_with_invalid_path(self) -> None:
42+
with patch("os.path.isdir", return_value=False), patch.object(
43+
argparse.ArgumentParser, "error", side_effect=InterruptedError
44+
) as mock_error:
45+
try:
46+
main()
47+
except InterruptedError:
48+
pass
49+
50+
mock_error.assert_called_with("invalid_path is not a valid path")

0 commit comments

Comments
 (0)
Please sign in to comment.