Skip to content

Commit 4bc0c60

Browse files
PIG208timabbott
authored andcommitted
bots: Find external packaged bots via 'zulip_bots.registry' entry_point.
Now we will be able to execute `zulip-run-bot` with the `-r` argument to search for and run bots from the `zulip_bots.registry` entry_point. Each entry point should have the name correspond to the bot name, and have the value be the bot module. E.g, an Python package for a bot called "packaged_bot" should have an `entry_points` setup like the following: setup( ... entry_points={ "zulip_bot.registry":[ "packaged_bot=packaged_bot.packaged_bot" ] } ... ) whose file structure may look like this: packaged_bot/ ├───packaged_bot/ | ├───packaged_bot.py # The bot module | ├───test_packaged_bot.py | ├───packaged_bot.conf | └───doc.md └───setup.py # Register the entry points here Add test case.
1 parent 4fd29ba commit 4bc0c60

File tree

4 files changed

+94
-24
lines changed

4 files changed

+94
-24
lines changed

zulip_bots/setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"lxml",
6767
"BeautifulSoup4",
6868
"typing_extensions",
69+
'importlib-metadata >= 3.6; python_version < "3.10"',
6970
],
7071
)
7172

zulip_bots/zulip_bots/finder.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
import importlib.util
44
import os
55
from pathlib import Path
6+
from types import ModuleType
67
from typing import Any, Optional, Tuple
78

89
current_dir = os.path.dirname(os.path.abspath(__file__))
910

11+
import importlib_metadata as metadata
12+
1013

1114
def import_module_from_source(path: str, name: str) -> Any:
1215
spec = importlib.util.spec_from_file_location(name, path)
@@ -25,6 +28,28 @@ def import_module_by_name(name: str) -> Any:
2528
return None
2629

2730

31+
class DuplicateRegisteredBotName(Exception):
32+
pass
33+
34+
35+
def import_module_from_zulip_bot_registry(name: str) -> Optional[ModuleType]:
36+
# Prior to Python 3.10, calling importlib.metadata.entry_points returns a
37+
# SelectableGroups object when no parameters is given. Currently we use
38+
# the importlib_metadata library for compatibility, but we need to migrate
39+
# to the built-in library when we start to adapt Python 3.10.
40+
# https://importlib-metadata.readthedocs.io/en/latest/using.html#entry-points
41+
registered_bots = metadata.entry_points(group="zulip_bots.registry")
42+
matching_bots = [bot for bot in registered_bots if bot.name == name]
43+
44+
if len(matching_bots) == 1: # Unique matching entrypoint
45+
return matching_bots[0].load()
46+
47+
if len(matching_bots) > 1:
48+
raise DuplicateRegisteredBotName(name)
49+
50+
return None # no matches in registry
51+
52+
2853
def resolve_bot_path(name: str) -> Optional[Tuple[Path, str]]:
2954
if os.path.isfile(name):
3055
bot_path = Path(name)

zulip_bots/zulip_bots/run.py

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ def parse_args() -> argparse.Namespace:
4848
help="try running the bot even if dependencies install fails",
4949
)
5050

51+
parser.add_argument(
52+
"--registry",
53+
"-r",
54+
action="store_true",
55+
help="run the bot via zulip_bots registry",
56+
)
57+
5158
parser.add_argument("--provision", action="store_true", help="install dependencies for the bot")
5259

5360
args = parser.parse_args()
@@ -109,36 +116,48 @@ def exit_gracefully_if_bot_config_file_does_not_exist(bot_config_file: Optional[
109116
def main() -> None:
110117
args = parse_args()
111118

112-
result = finder.resolve_bot_path(args.bot)
113-
if result:
114-
bot_path, bot_name = result
115-
sys.path.insert(0, os.path.dirname(bot_path))
116-
117-
if args.provision:
118-
provision_bot(os.path.dirname(bot_path), args.force)
119-
119+
if args.registry:
120120
try:
121-
lib_module = finder.import_module_from_source(bot_path.as_posix(), bot_name)
122-
except ImportError:
123-
req_path = os.path.join(os.path.dirname(bot_path), "requirements.txt")
124-
with open(req_path) as fp:
125-
deps_list = fp.read()
126-
127-
dep_err_msg = (
128-
"ERROR: The following dependencies for the {bot_name} bot are not installed:\n\n"
129-
"{deps_list}\n"
130-
"If you'd like us to install these dependencies, run:\n"
131-
" zulip-run-bot {bot_name} --provision"
121+
lib_module = finder.import_module_from_zulip_bot_registry(args.bot)
122+
except finder.DuplicateRegisteredBotName as error:
123+
print(
124+
f'ERROR: Found duplicate entries for "{error}" in zulip bots registry.\n'
125+
"Make sure that you don't install bots using the same entry point. Exiting now."
132126
)
133-
print(dep_err_msg.format(bot_name=bot_name, deps_list=deps_list))
134127
sys.exit(1)
135-
else:
136-
lib_module = finder.import_module_by_name(args.bot)
137128
if lib_module:
138-
bot_name = lib_module.__name__
129+
bot_name = args.bot
130+
else:
131+
result = finder.resolve_bot_path(args.bot)
132+
if result:
133+
bot_path, bot_name = result
134+
sys.path.insert(0, os.path.dirname(bot_path))
135+
139136
if args.provision:
140-
print("ERROR: Could not load bot's module for '{}'. Exiting now.")
137+
provision_bot(os.path.dirname(bot_path), args.force)
138+
139+
try:
140+
lib_module = finder.import_module_from_source(bot_path.as_posix(), bot_name)
141+
except ImportError:
142+
req_path = os.path.join(os.path.dirname(bot_path), "requirements.txt")
143+
with open(req_path) as fp:
144+
deps_list = fp.read()
145+
146+
dep_err_msg = (
147+
"ERROR: The following dependencies for the {bot_name} bot are not installed:\n\n"
148+
"{deps_list}\n"
149+
"If you'd like us to install these dependencies, run:\n"
150+
" zulip-run-bot {bot_name} --provision"
151+
)
152+
print(dep_err_msg.format(bot_name=bot_name, deps_list=deps_list))
141153
sys.exit(1)
154+
else:
155+
lib_module = finder.import_module_by_name(args.bot)
156+
if lib_module:
157+
bot_name = lib_module.__name__
158+
if args.provision:
159+
print("ERROR: Could not load bot's module for '{}'. Exiting now.")
160+
sys.exit(1)
142161

143162
if lib_module is None:
144163
print("ERROR: Could not load bot module. Exiting now.")

zulip_bots/zulip_bots/tests/test_run.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33
import sys
44
import unittest
5+
from importlib.metadata import EntryPoint
56
from pathlib import Path
67
from typing import Optional
78
from unittest import TestCase, mock
@@ -15,6 +16,7 @@ class TestDefaultArguments(TestCase):
1516

1617
our_dir = os.path.dirname(__file__)
1718
path_to_bot = os.path.abspath(os.path.join(our_dir, "../bots/giphy/giphy.py"))
19+
packaged_bot_entrypoint = EntryPoint("packaged_bot", "module_name", "zulip_bots.registry")
1820

1921
@patch("sys.argv", ["zulip-run-bot", "giphy", "--config-file", "/foo/bar/baz.conf"])
2022
@patch("zulip_bots.run.run_message_handler_for_bot")
@@ -48,6 +50,29 @@ def test_argument_parsing_with_bot_path(
4850
quiet=False,
4951
)
5052

53+
@patch(
54+
"sys.argv", ["zulip-run-bot", "packaged_bot", "--config-file", "/foo/bar/baz.conf", "-r"]
55+
)
56+
@patch("zulip_bots.run.run_message_handler_for_bot")
57+
def test_argument_parsing_with_zulip_bot_registry(
58+
self, mock_run_message_handler_for_bot: mock.Mock
59+
) -> None:
60+
with patch("zulip_bots.run.exit_gracefully_if_zulip_config_is_missing"), patch(
61+
"zulip_bots.finder.metadata.EntryPoint.load"
62+
), patch(
63+
"zulip_bots.finder.metadata.entry_points",
64+
return_value=(self.packaged_bot_entrypoint,),
65+
):
66+
zulip_bots.run.main()
67+
68+
mock_run_message_handler_for_bot.assert_called_with(
69+
bot_name="packaged_bot",
70+
config_file="/foo/bar/baz.conf",
71+
bot_config_file=None,
72+
lib_module=mock.ANY,
73+
quiet=False,
74+
)
75+
5176
def test_adding_bot_parent_dir_to_sys_path_when_bot_name_specified(self) -> None:
5277
bot_name = "helloworld" # existing bot's name
5378
expected_bot_dir_path = Path(

0 commit comments

Comments
 (0)