diff --git a/nonebot/plugin.py b/nonebot/plugin/__init__.py similarity index 97% rename from nonebot/plugin.py rename to nonebot/plugin/__init__.py index e66672cd..540eac89 100644 --- a/nonebot/plugin.py +++ b/nonebot/plugin/__init__.py @@ -4,7 +4,6 @@ 为 NoneBot 插件开发提供便携的定义函数。 """ - import re import sys import pkgutil @@ -21,14 +20,17 @@ from nonebot.permission import Permission from nonebot.typing import T_State, T_StateFactory, T_Handler, T_RuleChecker from nonebot.rule import Rule, startswith, endswith, keyword, command, shell_command, ArgumentParser, regex +from .manager import PluginManager + if TYPE_CHECKING: - from nonebot.adapters import Bot, Event, MessageSegment + from nonebot.adapters import Bot, Event plugins: Dict[str, "Plugin"] = {} """ :类型: ``Dict[str, Plugin]`` :说明: 已加载的插件 """ +PLUGIN_NAMESPACE = "nonebot.loaded_plugins" _export: ContextVar["Export"] = ContextVar("_export") _tmp_matchers: ContextVar[Set[Type[Matcher]]] = ContextVar("_tmp_matchers") @@ -950,7 +952,7 @@ def load_plugin(module_path: str) -> Optional[Plugin]: """ :说明: - 使用 ``importlib`` 加载单个插件,可以是本地插件或是通过 ``pip`` 安装的插件。 + 使用 ``PluginManager`` 加载单个插件,可以是本地插件或是通过 ``pip`` 安装的插件。 :参数: @@ -1006,42 +1008,38 @@ def load_plugins(*plugin_dir: str) -> Set[Plugin]: - ``Set[Plugin]`` """ - def _load_plugin(module_info) -> Optional[Plugin]: - _tmp_matchers.set(set()) - _export.set(Export()) - name = module_info.name - if name.startswith("_"): + def _load_plugin(plugin_name: str) -> Optional[Plugin]: + if plugin_name.startswith("_"): return None - spec = module_info.module_finder.find_spec(name, None) - if not spec: - logger.warning( - f"Module {name} cannot be loaded! Check module name first.") - elif spec.name in plugins: - return None - elif spec.name in sys.modules: - logger.warning( - f"Module {spec.name} has been loaded by other plugin! Ignored") + _tmp_matchers.set(set()) + _export.set(Export()) + + if plugin_name in plugins: return None try: - module = _load(spec) + module = manager.load_plugin(plugin_name) for m in _tmp_matchers.get(): - m.module = name - plugin = Plugin(name, module, _tmp_matchers.get(), _export.get()) - plugins[name] = plugin - logger.opt(colors=True).info(f'Succeeded to import "{name}"') + m.module = plugin_name + plugin = Plugin(plugin_name, module, _tmp_matchers.get(), + _export.get()) + plugins[plugin_name] = plugin + logger.opt( + colors=True).info(f'Succeeded to import "{plugin_name}"') return plugin except Exception as e: logger.opt(colors=True, exception=e).error( - f'Failed to import "{name}"') + f'Failed to import "{plugin_name}"' + ) return None loaded_plugins = set() - for module_info in pkgutil.iter_modules(plugin_dir): + manager = PluginManager(PLUGIN_NAMESPACE, search_path=plugin_dir) + for plugin_name in manager.list_plugins(): context: Context = copy_context() - result = context.run(_load_plugin, module_info) + result = context.run(_load_plugin, plugin_name) if result: loaded_plugins.add(result) return loaded_plugins diff --git a/nonebot/plugin.pyi b/nonebot/plugin/__init__.pyi similarity index 100% rename from nonebot/plugin.pyi rename to nonebot/plugin/__init__.pyi diff --git a/nonebot/plugin/manager.py b/nonebot/plugin/manager.py new file mode 100644 index 00000000..7bdf0910 --- /dev/null +++ b/nonebot/plugin/manager.py @@ -0,0 +1,177 @@ +import sys +import uuid +import pkgutil +import importlib +from hashlib import md5 +from types import ModuleType +from collections import Counter +from importlib.abc import MetaPathFinder +from importlib.machinery import PathFinder +from typing import Set, List, Optional, Iterable + +_internal_space = ModuleType(__name__ + "._internal") +_internal_space.__path__ = [] # type: ignore +sys.modules[_internal_space.__name__] = _internal_space + +_manager_stack: List["PluginManager"] = [] + + +class _NamespaceModule(ModuleType): + """Simple namespace module to store plugins.""" + + @property + def __path__(self): + return [] + + def __getattr__(self, name: str): + try: + return super().__getattr__(name) # type: ignore + except AttributeError: + if name.startswith("__"): + raise + raise RuntimeError("Plugin manager not activated!") + + +class _InternalModule(ModuleType): + """Internal module for each plugin manager.""" + + def __init__(self, plugin_manager: "PluginManager"): + super().__init__( + f"{_internal_space.__name__}.{plugin_manager.internal_id}") + self.__plugin_manager__ = plugin_manager + + @property + def __path__(self) -> List[str]: + return list(self.__plugin_manager__.search_path) + + +class PluginManager: + + def __init__(self, + namespace: Optional[str] = None, + plugins: Optional[Iterable[str]] = None, + search_path: Optional[Iterable[str]] = None, + *, + id: Optional[str] = None): + self.namespace: Optional[str] = namespace + self.namespace_module: Optional[ModuleType] = self._setup_namespace( + namespace) + + self.id: str = id or str(uuid.uuid4()) + self.internal_id: str = md5( + ((self.namespace or "") + self.id).encode()).hexdigest() + self.internal_module = self._setup_internal_module(self.internal_id) + + # simple plugin not in search path + self.plugins: Set[str] = set(plugins or []) + self.search_path: Set[str] = set(search_path or []) + # ensure can be loaded + self.list_plugins() + + def _setup_namespace(self, + namespace: Optional[str] = None + ) -> Optional[ModuleType]: + if not namespace: + return None + + try: + module = importlib.import_module(namespace) + except ImportError: + module = _NamespaceModule(namespace) + if "." in namespace: + parent = importlib.import_module(namespace.rsplit(".", 1)[0]) + setattr(parent, namespace.rsplit(".", 1)[1], module) + + sys.modules[namespace] = module + return module + + def _setup_internal_module(self, internal_id: str) -> ModuleType: + if hasattr(_internal_space, internal_id): + raise RuntimeError("Plugin manager already exists!") + module = _InternalModule(self) + sys.modules[module.__name__] = module + setattr(_internal_space, internal_id, module) + return module + + def __enter__(self): + if self in _manager_stack: + raise RuntimeError("Plugin manager already activated!") + _manager_stack.append(self) + return self + + def __exit__(self, exc_type, exc_value, traceback): + try: + _manager_stack.pop() + except IndexError: + pass + + def search_plugins(self) -> List[str]: + return [ + module_info.name + for module_info in pkgutil.iter_modules(self.search_path) + ] + + def list_plugins(self) -> Set[str]: + _pre_managers: List[PluginManager] + if self in _manager_stack: + _pre_managers = _manager_stack[:_manager_stack.index(self)] + else: + _pre_managers = _manager_stack[:] + + _search_path: Set[str] = set() + for manager in _pre_managers: + _search_path |= manager.search_path + if _search_path & self.search_path: + raise RuntimeError("Duplicate plugin search path!") + + _search_plugins = self.search_plugins() + c = Counter([*_search_plugins, *self.plugins]) + conflict = [name for name, num in c.items() if num > 1] + if conflict: + raise RuntimeError( + f"More than one plugin named {' / '.join(conflict)}!") + return set(_search_plugins) | self.plugins + + def load_plugin(self, name) -> ModuleType: + if name in self.plugins: + return importlib.import_module(name) + + if "." in name: + raise ValueError("Plugin name cannot contain '.'") + with self: + return importlib.import_module(f"{self.namespace}.{name}") + + def load_all_plugins(self) -> List[ModuleType]: + return [self.load_plugin(name) for name in self.list_plugins()] + + def _rewrite_module_name(self, module_name) -> Optional[str]: + if module_name == self.namespace: + return self.internal_module.__name__ + elif module_name.startswith(self.namespace + "."): + path = module_name.split(".") + length = self.namespace.count(".") + 1 + return f"{self.internal_module.__name__}.{'.'.join(path[length:])}" + elif module_name in self.search_plugins(): + return f"{self.internal_module.__name__}.{module_name}" + return None + + +class PluginFinder(MetaPathFinder): + + def find_spec(self, fullname: str, path, target): + if _manager_stack: + index = -1 + while -index <= len(_manager_stack): + manager = _manager_stack[index] + newname = manager._rewrite_module_name(fullname) + if newname: + spec = PathFinder.find_spec(newname, + list(manager.search_path), + target) + if spec: + return spec + index -= 1 + return None + + +sys.meta_path.insert(0, PluginFinder()) diff --git a/tests/bot.py b/tests/bot.py index 849aee27..b263bf49 100644 --- a/tests/bot.py +++ b/tests/bot.py @@ -31,8 +31,6 @@ nonebot.load_plugin("nonebot_plugin_test") # load local plugins nonebot.load_plugins("test_plugins") -print(nonebot.require("test_export")) - # modify some config / config depends on loaded configs config = driver.config config.custom_config3 = config.custom_config1 diff --git a/tests/test_plugins/test_get_export.py b/tests/test_plugins/test_get_export.py new file mode 100644 index 00000000..ec4437d1 --- /dev/null +++ b/tests/test_plugins/test_get_export.py @@ -0,0 +1,5 @@ +import nonebot + +from .test_export import export + +print(export, nonebot.require("test_export"))