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.
This commit is contained in:
PIG208 2021-07-22 12:13:40 +08:00 committed by Tim Abbott
parent 4fd29baf2b
commit 4bc0c607c1
4 changed files with 94 additions and 24 deletions

View file

@ -66,6 +66,7 @@ setuptools_info = dict(
"lxml", "lxml",
"BeautifulSoup4", "BeautifulSoup4",
"typing_extensions", "typing_extensions",
'importlib-metadata >= 3.6; python_version < "3.10"',
], ],
) )

View file

@ -3,10 +3,13 @@ import importlib.abc
import importlib.util import importlib.util
import os import os
from pathlib import Path from pathlib import Path
from types import ModuleType
from typing import Any, Optional, Tuple from typing import Any, Optional, Tuple
current_dir = os.path.dirname(os.path.abspath(__file__)) current_dir = os.path.dirname(os.path.abspath(__file__))
import importlib_metadata as metadata
def import_module_from_source(path: str, name: str) -> Any: def import_module_from_source(path: str, name: str) -> Any:
spec = importlib.util.spec_from_file_location(name, path) spec = importlib.util.spec_from_file_location(name, path)
@ -25,6 +28,28 @@ def import_module_by_name(name: str) -> Any:
return None return None
class DuplicateRegisteredBotName(Exception):
pass
def import_module_from_zulip_bot_registry(name: str) -> Optional[ModuleType]:
# Prior to Python 3.10, calling importlib.metadata.entry_points returns a
# SelectableGroups object when no parameters is given. Currently we use
# the importlib_metadata library for compatibility, but we need to migrate
# to the built-in library when we start to adapt Python 3.10.
# https://importlib-metadata.readthedocs.io/en/latest/using.html#entry-points
registered_bots = metadata.entry_points(group="zulip_bots.registry")
matching_bots = [bot for bot in registered_bots if bot.name == name]
if len(matching_bots) == 1: # Unique matching entrypoint
return matching_bots[0].load()
if len(matching_bots) > 1:
raise DuplicateRegisteredBotName(name)
return None # no matches in registry
def resolve_bot_path(name: str) -> Optional[Tuple[Path, str]]: def resolve_bot_path(name: str) -> Optional[Tuple[Path, str]]:
if os.path.isfile(name): if os.path.isfile(name):
bot_path = Path(name) bot_path = Path(name)

View file

@ -48,6 +48,13 @@ def parse_args() -> argparse.Namespace:
help="try running the bot even if dependencies install fails", 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") parser.add_argument("--provision", action="store_true", help="install dependencies for the bot")
args = parser.parse_args() args = parser.parse_args()
@ -109,36 +116,48 @@ def exit_gracefully_if_bot_config_file_does_not_exist(bot_config_file: Optional[
def main() -> None: def main() -> None:
args = parse_args() args = parse_args()
result = finder.resolve_bot_path(args.bot) if args.registry:
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: try:
lib_module = finder.import_module_from_source(bot_path.as_posix(), bot_name) lib_module = finder.import_module_from_zulip_bot_registry(args.bot)
except ImportError: except finder.DuplicateRegisteredBotName as error:
req_path = os.path.join(os.path.dirname(bot_path), "requirements.txt") print(
with open(req_path) as fp: f'ERROR: Found duplicate entries for "{error}" in zulip bots registry.\n'
deps_list = fp.read() "Make sure that you don't install bots using the same entry point. Exiting now."
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) sys.exit(1)
else:
lib_module = finder.import_module_by_name(args.bot)
if lib_module: if lib_module:
bot_name = lib_module.__name__ bot_name = args.bot
else:
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: if args.provision:
print("ERROR: Could not load bot's module for '{}'. Exiting now.") provision_bot(os.path.dirname(bot_path), args.force)
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"
)
print(dep_err_msg.format(bot_name=bot_name, deps_list=deps_list))
sys.exit(1) sys.exit(1)
else:
lib_module = finder.import_module_by_name(args.bot)
if lib_module:
bot_name = lib_module.__name__
if args.provision:
print("ERROR: Could not load bot's module for '{}'. Exiting now.")
sys.exit(1)
if lib_module is None: if lib_module is None:
print("ERROR: Could not load bot module. Exiting now.") print("ERROR: Could not load bot module. Exiting now.")

View file

@ -2,6 +2,7 @@
import os import os
import sys import sys
import unittest import unittest
from importlib.metadata import EntryPoint
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from unittest import TestCase, mock from unittest import TestCase, mock
@ -15,6 +16,7 @@ class TestDefaultArguments(TestCase):
our_dir = os.path.dirname(__file__) our_dir = os.path.dirname(__file__)
path_to_bot = os.path.abspath(os.path.join(our_dir, "../bots/giphy/giphy.py")) path_to_bot = os.path.abspath(os.path.join(our_dir, "../bots/giphy/giphy.py"))
packaged_bot_entrypoint = EntryPoint("packaged_bot", "module_name", "zulip_bots.registry")
@patch("sys.argv", ["zulip-run-bot", "giphy", "--config-file", "/foo/bar/baz.conf"]) @patch("sys.argv", ["zulip-run-bot", "giphy", "--config-file", "/foo/bar/baz.conf"])
@patch("zulip_bots.run.run_message_handler_for_bot") @patch("zulip_bots.run.run_message_handler_for_bot")
@ -48,6 +50,29 @@ class TestDefaultArguments(TestCase):
quiet=False, quiet=False,
) )
@patch(
"sys.argv", ["zulip-run-bot", "packaged_bot", "--config-file", "/foo/bar/baz.conf", "-r"]
)
@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(
"zulip_bots.finder.metadata.EntryPoint.load"
), patch(
"zulip_bots.finder.metadata.entry_points",
return_value=(self.packaged_bot_entrypoint,),
):
zulip_bots.run.main()
mock_run_message_handler_for_bot.assert_called_with(
bot_name="packaged_bot",
config_file="/foo/bar/baz.conf",
bot_config_file=None,
lib_module=mock.ANY,
quiet=False,
)
def test_adding_bot_parent_dir_to_sys_path_when_bot_name_specified(self) -> None: def test_adding_bot_parent_dir_to_sys_path_when_bot_name_specified(self) -> None:
bot_name = "helloworld" # existing bot's name bot_name = "helloworld" # existing bot's name
expected_bot_dir_path = Path( expected_bot_dir_path = Path(