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",
|
"lxml",
|
||||||
"BeautifulSoup4",
|
"BeautifulSoup4",
|
||||||
"typing_extensions",
|
"typing_extensions",
|
||||||
|
'importlib-metadata >= 3.6; python_version < "3.10"',
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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.")
|
||||||
|
|
|
@ -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(
|
||||||
|
|
Loading…
Reference in a new issue