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:
parent
4fd29baf2b
commit
4bc0c607c1
|
@ -66,6 +66,7 @@ setuptools_info = dict(
|
|||
"lxml",
|
||||
"BeautifulSoup4",
|
||||
"typing_extensions",
|
||||
'importlib-metadata >= 3.6; python_version < "3.10"',
|
||||
],
|
||||
)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.")
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in a new issue