From 4bc0c607c15365a8f507436011f5155e51c3a5eb Mon Sep 17 00:00:00 2001 From: PIG208 <359101898@qq.com> Date: Thu, 22 Jul 2021 12:13:40 +0800 Subject: [PATCH] bots: Find external packaged bots via 'zulip_bots.registry' entry_point. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- zulip_bots/setup.py | 1 + zulip_bots/zulip_bots/finder.py | 25 +++++++++ zulip_bots/zulip_bots/run.py | 67 ++++++++++++++++--------- zulip_bots/zulip_bots/tests/test_run.py | 25 +++++++++ 4 files changed, 94 insertions(+), 24 deletions(-) diff --git a/zulip_bots/setup.py b/zulip_bots/setup.py index 9ab6ca1..08acc43 100644 --- a/zulip_bots/setup.py +++ b/zulip_bots/setup.py @@ -66,6 +66,7 @@ setuptools_info = dict( "lxml", "BeautifulSoup4", "typing_extensions", + 'importlib-metadata >= 3.6; python_version < "3.10"', ], ) diff --git a/zulip_bots/zulip_bots/finder.py b/zulip_bots/zulip_bots/finder.py index 354faf8..41cba98 100644 --- a/zulip_bots/zulip_bots/finder.py +++ b/zulip_bots/zulip_bots/finder.py @@ -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) diff --git a/zulip_bots/zulip_bots/run.py b/zulip_bots/zulip_bots/run.py index 6d8bf76..d0abc0f 100755 --- a/zulip_bots/zulip_bots/run.py +++ b/zulip_bots/zulip_bots/run.py @@ -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,36 +116,48 @@ def exit_gracefully_if_bot_config_file_does_not_exist(bot_config_file: Optional[ def main() -> None: args = parse_args() - 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) - + if args.registry: 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" + 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) - else: - lib_module = finder.import_module_by_name(args.bot) 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: - 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) + 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: print("ERROR: Could not load bot module. Exiting now.") diff --git a/zulip_bots/zulip_bots/tests/test_run.py b/zulip_bots/zulip_bots/tests/test_run.py index 6b233d5..ffcf9e0 100644 --- a/zulip_bots/zulip_bots/tests/test_run.py +++ b/zulip_bots/zulip_bots/tests/test_run.py @@ -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(