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",
"BeautifulSoup4",
"typing_extensions",
'importlib-metadata >= 3.6; python_version < "3.10"',
],
)

View file

@ -3,10 +3,13 @@ import importlib.abc
import importlib.util
import os
from pathlib import Path
from types import ModuleType
from typing import Any, Optional, Tuple
current_dir = os.path.dirname(os.path.abspath(__file__))
import importlib_metadata as metadata
def import_module_from_source(path: str, name: str) -> Any:
spec = importlib.util.spec_from_file_location(name, path)
@ -25,6 +28,28 @@ def import_module_by_name(name: str) -> Any:
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]]:
if os.path.isfile(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",
)
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()
@ -109,6 +116,18 @@ def exit_gracefully_if_bot_config_file_does_not_exist(bot_config_file: Optional[
def main() -> None:
args = parse_args()
if args.registry:
try:
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."
)
sys.exit(1)
if lib_module:
bot_name = args.bot
else:
result = finder.resolve_bot_path(args.bot)
if result:
bot_path, bot_name = result

View file

@ -2,6 +2,7 @@
import os
import sys
import unittest
from importlib.metadata import EntryPoint
from pathlib import Path
from typing import Optional
from unittest import TestCase, mock
@ -15,6 +16,7 @@ class TestDefaultArguments(TestCase):
our_dir = os.path.dirname(__file__)
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("zulip_bots.run.run_message_handler_for_bot")
@ -48,6 +50,29 @@ class TestDefaultArguments(TestCase):
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:
bot_name = "helloworld" # existing bot's name
expected_bot_dir_path = Path(