diff --git a/.github/labeler.yml b/.github/labeler.yml index 9adf0b94d4b..aee55107411 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -239,6 +239,7 @@ - redbot/core/_settings_caches.py - redbot/core/_sharedlibdeprecation.py - redbot/core/utils/_internal_utils.py + - redbot/ext_cogs/__init__.py # Tests - redbot/pytest/__init__.py - redbot/pytest/cog_manager.py diff --git a/redbot/__init__.py b/redbot/__init__.py index 671c475b061..9de847f3746 100644 --- a/redbot/__init__.py +++ b/redbot/__init__.py @@ -371,12 +371,3 @@ def _early_init(): module="discord", message="'audioop' is deprecated and slated for removal", ) - # DEP-WARN - will need a fix before Python 3.12 support - # - # DeprecationWarning: the load_module() method is deprecated and slated for removal in Python 3.12; use exec_module() instead - _warnings.filterwarnings( - "ignore", - category=DeprecationWarning, - module="importlib", - message=r"the load_module\(\) method is deprecated and slated for removal", - ) diff --git a/redbot/cogs/downloader/downloader.py b/redbot/cogs/downloader/downloader.py index 55f49f83e83..ff8cb650c78 100644 --- a/redbot/cogs/downloader/downloader.py +++ b/redbot/cogs/downloader/downloader.py @@ -1096,25 +1096,6 @@ async def _ask_for_cog_reload( await ctx.invoke(ctx.bot.get_cog("Core").reload, *updated_cognames) - def cog_name_from_instance(self, instance: object) -> str: - """Determines the cog name that Downloader knows from the cog instance. - - Probably. - - Parameters - ---------- - instance : object - The cog instance. - - Returns - ------- - str - The name of the cog according to Downloader.. - - """ - splitted = instance.__module__.split(".") - return splitted[0] - @commands.command() async def findcog(self, ctx: commands.Context, command_name: str) -> None: """Find which cog a command comes from. @@ -1136,8 +1117,21 @@ async def findcog(self, ctx: commands.Context, command_name: str) -> None: # Check if in installed cogs cog = command.cog - if cog: - cog_pkg_name = self.cog_name_from_instance(cog) + if not cog: + await ctx.send(_("This command is not provided by a cog.")) + return + + try: + top_level_package, subpackage, cog_pkg_name, *__ = cog.__module__.split(".", 3) + if top_level_package != "redbot": + raise ValueError + except ValueError: + await ctx.send(_("The cog package for the given command could not be determined.")) + return + + cog_name = cog.__class__.__name__ + repo_branch = None + if subpackage == "ext_cogs": installed, cog_installable = await _downloader.is_installed(cog_pkg_name) if installed: made_by = ( @@ -1155,25 +1149,18 @@ async def findcog(self, ctx: commands.Context, command_name: str) -> None: if cog_installable.repo is None else cog_installable.repo.name ) + repo_branch = cog_installable.repo and cog_installable.repo.branch cog_pkg_name = cog_installable.name - elif cog.__module__.startswith("redbot."): # core commands or core cog - made_by = "Cog Creators" - repo_url = "https://github.com/Cog-Creators/Red-DiscordBot" - module_fragments = cog.__module__.split(".") - if module_fragments[1] == "core": - cog_pkg_name = "N/A - Built-in commands" - else: - cog_pkg_name = module_fragments[2] - repo_name = "Red-DiscordBot" else: # assume not installed via downloader made_by = _("Unknown") repo_url = _("None - this cog wasn't installed via downloader") repo_name = _("Unknown") - cog_name = cog.__class__.__name__ - else: - msg = _("This command is not provided by a cog.") - await ctx.send(msg) - return + else: # core commands or core cog + made_by = "Cog Creators" + repo_url = "https://github.com/Cog-Creators/Red-DiscordBot" + if subpackage == "core": + cog_pkg_name = "N/A - Built-in commands" + repo_name = "Red-DiscordBot" if await ctx.embed_requested(): embed = discord.Embed(color=(await ctx.embed_colour())) @@ -1183,10 +1170,8 @@ async def findcog(self, ctx: commands.Context, command_name: str) -> None: embed.add_field(name=_("Made by:"), value=made_by, inline=False) embed.add_field(name=_("Repo name:"), value=repo_name, inline=False) embed.add_field(name=_("Repo URL:"), value=repo_url, inline=False) - if installed and cog_installable.repo is not None and cog_installable.repo.branch: - embed.add_field( - name=_("Repo branch:"), value=cog_installable.repo.branch, inline=False - ) + if repo_branch: + embed.add_field(name=_("Repo branch:"), value=repo_branch, inline=False) await ctx.send(embed=embed) else: @@ -1205,10 +1190,8 @@ async def findcog(self, ctx: commands.Context, command_name: str) -> None: repo_url=repo_url, repo_name=repo_name, ) - if installed and cog_installable.repo is not None and cog_installable.repo.branch: - msg += _("Repo branch: {branch_name}\n").format( - branch_name=cog_installable.repo.branch - ) + if repo_branch: + msg += _("Repo branch: {branch_name}\n").format(branch_name=repo_branch) await ctx.send(box(msg)) @staticmethod diff --git a/redbot/core/_cog_manager.py b/redbot/core/_cog_manager.py index f07a07e67b1..65f5c758ba1 100644 --- a/redbot/core/_cog_manager.py +++ b/redbot/core/_cog_manager.py @@ -1,20 +1,49 @@ +""" +Cog path manager for Red. + +This module provides both the internal API and the external UI for +adding, removing or modifying extra paths for Red to be able to +discover cogs. + +By default, cogs can be imported from the install path, where +Downloader will place installed cogs; and the core cogs path. Other +arbitrary paths can be added by the user - these user-defined paths are +particularly useful for cog development. + +Internally, this modifies use of the `__path__` attribute of the +``redbot.ext_cogs`` package. When extra paths are added to a package's +`__path__` attribute, they are used to locate sub-packages. + +The precedence of paths goes: +1. Install path +2. Non-persistent (temporary) paths defined by the user (i.e. through the `--cog-path` flag) +3. Persistent paths defined by the user (i.e. through the `[p]addpath` command) +4. Core path (redbot.cogs) + +This is so users who wish to modify core cogs can do so by copying or +installing cogs into a user-defined/core path, and this modified one +will be loaded instead. +""" + import contextlib import keyword import pkgutil import sys import textwrap -from importlib import import_module, invalidate_caches -from importlib.machinery import ModuleSpec +import importlib +import itertools from pathlib import Path -from typing import Union, List, Optional +from types import ModuleType +from typing import Union, List, Optional, Set import redbot.cogs +import redbot.ext_cogs from redbot.core.commands import positive_int from redbot.core.utils import deduplicate_iterables from redbot.core.utils.views import ConfirmView import discord -from . import commands +from . import commands, errors from .config import Config from .i18n import Translator, cog_i18n from .data_manager import cog_data_path, data_path @@ -23,32 +52,29 @@ __all__ = ("CogManager", "CogManagerUI") -_TEMP_PATHS: List[Path] = [] - - -class NoSuchCog(ImportError): - """Thrown when a cog is missing. - - Different from ImportError because some ImportErrors can happen inside cogs. - """ - class CogManager: """Directory manager for Red's cogs. This module allows you to load cogs from multiple directories and even from - outside the bot directory. You may also set a directory for downloader to - install new cogs to, the default being the :code:`cogs/` folder in the root - bot directory. + outside the bot directory. You may also set a directory for Downloader to + install new cogs to, the default being the ``cogs/`` folder in + `CogManager`'s data path. """ - CORE_PATH = Path(redbot.cogs.__path__[0]).resolve() + CORE_PATH = Path(redbot.cogs.__file__).parent def __init__(self): self.config = Config.get_conf(self, 2938473984732, True) - tmp_cog_install_path = cog_data_path(self) / "cogs" - tmp_cog_install_path.mkdir(parents=True, exist_ok=True) - self.config.register_global(paths=[], install_path=str(tmp_cog_install_path)) + default_cog_install_path = cog_data_path(self) / "cogs" + default_cog_install_path.mkdir(parents=True, exist_ok=True) + self.config.register_global(paths=[], install_path=str(default_cog_install_path)) + + self._temp_paths: List[Path] = [] + + async def initialize(self): + # we want to include all paths except for the core path here, hence last entry is excluded + redbot.ext_cogs.__path__ = [str(p) for p in (await self.paths())[:-1]] async def paths(self) -> List[Path]: """Get all currently valid path directories, in order of priority @@ -64,7 +90,7 @@ async def paths(self) -> List[Path]: """ return deduplicate_iterables( [await self.install_path()], - _TEMP_PATHS, + self._temp_paths, await self.user_defined_paths(), [self.CORE_PATH], ) @@ -80,6 +106,19 @@ async def install_path(self) -> Path: """ return Path(await self.config.install_path()).resolve() + def temp_paths(self) -> List[Path]: + """Get a list of non-persistent (temporary) paths defined by the user. + + All paths will be absolute and unique, in order of priority. + + Returns + ------- + List[pathlib.Path] + A list of non-persistent (temporary) paths defined by the user. + + """ + return list(self._temp_paths) + async def user_defined_paths(self) -> List[Path]: """Get a list of user-defined cog paths. @@ -120,7 +159,10 @@ async def set_install_path(self, path: Path) -> Path: if not path.is_dir(): raise ValueError("The install path must be an existing directory.") resolved = path.resolve() - await self.config.install_path.set(str(resolved)) + to_add = str(resolved) + await self.config.install_path.set(to_add) + # install path is always first + redbot.ext_cogs.__path__[0] = to_add return resolved @staticmethod @@ -174,10 +216,11 @@ async def add_path(self, path: Union[Path, str], *, persist: bool = True) -> Non current_paths = await self.user_defined_paths() if path not in current_paths: current_paths.append(path) - await self.set_paths(current_paths) + await self._set_user_defined_paths(current_paths) + redbot.ext_cogs.__path__.append(str(path)) else: - if path not in _TEMP_PATHS: - _TEMP_PATHS.append(path) + if path not in self._temp_paths: + self._temp_paths.append(path) async def remove_path(self, path: Union[Path, str]) -> None: """Remove a path from the current paths list. @@ -192,143 +235,120 @@ async def remove_path(self, path: Union[Path, str]) -> None: paths = await self.user_defined_paths() paths.remove(path) - await self.set_paths(paths) + await self._set_user_defined_paths(paths) + redbot.ext_cogs.__path__.remove(str(path)) - async def set_paths(self, paths_: List[Path]): - """Set the current paths list. + async def reorder_path(self, path: Union[Path, str], new_index: int) -> None: + """Reorder a path in the user-defined paths list. + + The ``path`` will be removed from the paths list and + re-inserted at ``new_index``. Parameters ---------- - paths_ : `list` of `pathlib.Path` - List of paths to set. + path : `pathlib.Path` or `str` + Path to move. + new_index : `int` + The index to re-insert the path at. """ - str_paths = list(map(str, paths_)) - await self.config.paths.set(str_paths) + path = self._ensure_path_obj(path).resolve() + paths = await self.user_defined_paths() + + paths.remove(path) + paths.insert(new_index, path) + await self._set_user_defined_paths(paths) - async def _find_ext_cog(self, name: str) -> ModuleSpec: + redbot.ext_cogs.__path__[1:] = list(map(str, paths)) + + async def _set_user_defined_paths(self, paths_: List[Path]): """ - Attempts to find a spec for a third party installed cog. + Store the new list of user-defined paths. + + This doesn't update `redbot.ext_cogs.__path__`. Parameters ---------- - name : str - Name of the cog package to look for. - - Returns - ------- - importlib.machinery.ModuleSpec - Module spec to be used for cog loading. - - Raises - ------ - NoSuchCog - When no cog with the requested name was found. + paths_ : `list` of `pathlib.Path` + List of paths to set. """ - if not name.isidentifier() or keyword.iskeyword(name): - # reject package names that can't be valid python identifiers - raise NoSuchCog( - f"No 3rd party module by the name of '{name}' was found in any available path.", - name=name, - ) - - real_paths = list( - map(str, [await self.install_path()] + _TEMP_PATHS + await self.user_defined_paths()) - ) - - for finder, module_name, _ in pkgutil.iter_modules(real_paths): - if name == module_name: - spec = finder.find_spec(name) - if spec: - return spec + str_paths = list(map(str, paths_)) + await self.config.paths.set(str_paths) - raise NoSuchCog( - f"No 3rd party module by the name of '{name}' was found in any available path.", - name=name, + @staticmethod + def is_valid_module_name(name: str) -> bool: + # reject package names that: + return ( + # - can't be valid python identifiers + (name.isidentifier() and not keyword.iskeyword(name)) + # - would return a clearly invalid namespace package + and name != "__pycache__" ) - @staticmethod - async def _find_core_cog(name: str) -> ModuleSpec: + @classmethod + def load_cog_module(cls, name: str) -> ModuleType: """ - Attempts to find a spec for a core cog. + Load a cog module or package. Parameters ---------- name : str - - Returns - ------- - importlib.machinery.ModuleSpec - - Raises - ------ - RuntimeError - When no matching spec can be found. + Name of the cog package to look for. """ - real_name = ".{}".format(name) - package = "redbot.cogs" - try: - mod = import_module(real_name, package=package) - if mod.__spec__.name == "redbot.cogs.locales": - raise NoSuchCog( - "No core cog by the name of '{}' could be found.".format(name), - path=mod.__spec__.origin, - name=name, - ) - except ImportError as e: - if e.name == package + real_name: - raise NoSuchCog( - "No core cog by the name of '{}' could be found.".format(name), - path=e.path, - name=e.name, - ) from e - - raise + module = cls._load_cog_module(name) + if module is None: + raise errors.NoSuchCog( + f"No core or 3rd-party cog module by the name of '{name}' could be found.", + name=name, + ) - return mod.__spec__ + return module - # noinspection PyUnreachableCode - async def find_cog(self, name: str) -> Optional[ModuleSpec]: - """Find a cog in the list of available paths. + @classmethod + def _load_cog_module(cls, name: str) -> Optional[ModuleType]: + """ + Load a cog module or package. Parameters ---------- name : str - Name of the cog to find. - - Returns - ------- - Optional[importlib.machinery.ModuleSpec] - A module spec to be used for specialized cog loading, if found. - + Name of the cog package to look for. """ - with contextlib.suppress(NoSuchCog): - return await self._find_ext_cog(name) - with contextlib.suppress(NoSuchCog): - return await self._find_core_cog(name) - - async def available_modules(self) -> List[str]: - """Finds the names of all available modules to load.""" - paths = list(map(str, await self.paths())) - - ret = [] - for finder, module_name, _ in pkgutil.iter_modules(paths): + if not cls.is_valid_module_name(name): # reject package names that can't be valid python identifiers - if module_name.isidentifier() and not keyword.iskeyword(module_name): - ret.append(module_name) - return ret + return None - @staticmethod - def invalidate_caches(): - """Re-evaluate modules in the py cache. + for parent_package in ("redbot.ext_cogs", "redbot.cogs"): + module_name = ".".join((parent_package, name)) + if module_name == "redbot.cogs.locales": + # we don't want a clearly invalid namespace package + return None - This is an alias for an importlib internal and should be called - any time that a new module has been installed to a cog directory. - """ - invalidate_caches() + try: + module = importlib.import_module(f".{name}", package=parent_package) + except ModuleNotFoundError as e: + if e.name == module_name: + pass + else: + raise + else: + return module + + # If we get here, we failed to find the module + return None + + @classmethod + def find_available_modules(cls) -> Set[str]: + """Find the names of all available modules to load.""" + ret = set() + for package in (redbot.ext_cogs, redbot.cogs): + for finder, module_name, _ in pkgutil.iter_modules(package.__path__): + if cls.is_valid_module_name(module_name): + ret.add(module_name) + return ret _ = Translator("CogManagerUI", __file__) @@ -351,24 +371,26 @@ async def paths(self, ctx: commands.Context): cog_mgr = ctx.bot._cog_mgr install_path = await cog_mgr.install_path() core_path = cog_mgr.CORE_PATH + temp_paths = cog_mgr.temp_paths() cog_paths = await cog_mgr.user_defined_paths() - temporary_paths = [str(path) for path in _TEMP_PATHS] - - paths = [] - for index, path in enumerate(cog_paths, start=1): - paths.append(f"{index}. {path}") + formatted_temp_paths = [ + f"{index}. {path}" for index, path in enumerate(temp_paths, start=1) + ] + formatted_cog_paths = [f"{index}. {path}" for index, path in enumerate(cog_paths, start=1)] msg = _( - ( - "Install Path: {install_path}\nCore Path: {core_path}\n\n" - "Temporary Paths:{temporary_paths}\n\nCog Paths:{cog_paths}" - ) + "Install Path: {install_path}\nCore Path: {core_path}\n\n" + "Temporary Paths:{temp_paths}\n\nCog Paths:{cog_paths}" ).format( install_path=install_path, core_path=core_path, - temporary_paths=("\n" + "\n".join(temporary_paths)) if temporary_paths else _(" None"), - cog_paths=("\n" + "\n".join(paths)) if paths else _(" None"), + temp_paths=( + ("\n" + "\n".join(formatted_temp_paths)) if formatted_temp_paths else _(" None") + ), + cog_paths=( + ("\n" + "\n".join(formatted_cog_paths)) if formatted_cog_paths else _(" None") + ), ) await ctx.send(box(msg)) @@ -520,13 +542,7 @@ async def reorderpath(self, ctx: commands.Context, from_: positive_int, to: posi await ctx.send(_("Invalid 'from' index.")) return - try: - all_paths.insert(to, to_move) - except IndexError: - await ctx.send(_("Invalid 'to' index.")) - return - - await ctx.bot._cog_mgr.set_paths(all_paths) + await ctx.bot._cog_mgr.reorder_path(to_move, to) await ctx.send(_("Paths reordered.")) @commands.command() @@ -562,7 +578,7 @@ async def cogs(self, ctx: commands.Context): """ loaded = set(ctx.bot.extensions.keys()) - all_cogs = set(await ctx.bot._cog_mgr.available_modules()) + all_cogs = ctx.bot._cog_mgr.find_available_modules() unloaded = all_cogs - loaded diff --git a/redbot/core/bot.py b/redbot/core/bot.py index 84f1c2c6291..f96014ded9e 100644 --- a/redbot/core/bot.py +++ b/redbot/core/bot.py @@ -9,6 +9,7 @@ import contextlib import weakref import functools +import re from collections import namedtuple, OrderedDict from datetime import datetime from importlib.machinery import ModuleSpec @@ -31,7 +32,7 @@ overload, TYPE_CHECKING, ) -from types import MappingProxyType +from types import MappingProxyType, ModuleType import discord from discord.ext import commands as dpy_commands @@ -1158,6 +1159,7 @@ async def _pre_login(self) -> None: await super()._pre_login() await self._maybe_update_config() + await self._cog_mgr.initialize() self.description = await self._config.description() self._color = discord.Colour(await self._config.color()) @@ -1284,16 +1286,14 @@ async def _pre_connect(self) -> None: log.info("Loading packages...") for package in packages: try: - spec = await self._cog_mgr.find_cog(package) - if spec is None: - log.error( - "Failed to load package %s (package was not found in any cog path)", - package, - ) - await self.remove_loaded_package(package) - to_remove.append(package) - continue - await asyncio.wait_for(self.load_extension(spec), 30) + await asyncio.wait_for(self.load_extension(package), 30) + except errors.NoSuchCog: + log.error( + "Failed to load package %s (package was not found in any cog path)", + package, + ) + await self.remove_loaded_package(package) + to_remove.append(package) except asyncio.TimeoutError: log.exception("Failed to load package %s (timeout)", package) to_remove.append(package) @@ -1732,26 +1732,64 @@ async def remove_loaded_package(self, pkg_name: str): while pkg_name in curr_pkgs: curr_pkgs.remove(pkg_name) - async def load_extension(self, spec: ModuleSpec): - # NB: this completely bypasses `discord.ext.commands.Bot._load_from_module_spec` - name = spec.name.split(".")[-1] + # Pattern to match parent package name of cog module (redbot.cogs. or redbot.ext_cogs.) + _COG_PACKAGE_RE = re.compile(r"^redbot\.(?:ext_)?cogs\.(.+)") + + async def load_extension(self, module: Union[str, ModuleType], /): + # This implementation completely bypasses `discord.ext.commands.Bot._load_from_module_spec` + # with our own cog manager implementation. + + if isinstance(module, str): + module: ModuleType = self._cog_mgr.load_cog_module(module) + + name_match = self._COG_PACKAGE_RE.match(module.__name__) + if name_match is None: + raise errors.NoSuchCog( + f"The passed cog module ({module.__name__}) is not part of" + " redbot.cogs or redbot.ext_cogs package.", + name=module.__name__, + ) + name = name_match.group(1) if name in self.extensions: - raise errors.PackageAlreadyLoaded(spec) + raise errors.PackageAlreadyLoaded(name) - lib = spec.loader.load_module() - if not hasattr(lib, "setup"): - del lib - raise discord.ClientException(f"extension {name} does not have a setup function") + try: + setup = getattr(module, "setup") + except AttributeError: + raise commands.NoEntryPointError(name) try: - await lib.setup(self) + await setup(self) await self.tree.red_check_enabled() except Exception as e: - await self._remove_module_references(lib.__name__) - await self._call_module_finalizers(lib, name) + await self._remove_module_references(module.__name__) + await self._call_module_finalizers(module, name) raise else: - self._BotBase__extensions[name] = lib + self._BotBase__extensions[name] = module + + async def _call_module_finalizers(self, lib: ModuleType, key: str) -> None: + # Implementation identical to the base class except as noted in the comment below + try: + func = getattr(lib, "teardown") + except AttributeError: + pass + else: + try: + await func(self) + except Exception: + pass + finally: + self._BotBase__extensions.pop(key, None) + name = lib.__name__ + # This pops `lib`'s name (e.g. "redbot.cogs.general") + # rather than extension's `key` (e.g. "general") like the base class does. + # We specifically want to avoid touching anything outside + # the `redbot.cogs`/`redbot.ext_cogs` namespaces so we had to override this method. + sys.modules.pop(name, None) + for module in list(sys.modules.keys()): + if name == module.__name__ or module.__name__.startswith(f"{name}."): + del sys.modules[module] async def remove_cog( self, diff --git a/redbot/core/core_commands.py b/redbot/core/core_commands.py index 007ca853c35..7158ef533e9 100644 --- a/redbot/core/core_commands.py +++ b/redbot/core/core_commands.py @@ -164,30 +164,27 @@ async def _load(self, pkg_names: Iterable[str]) -> Dict[str, Union[List[str], Di bot = self.bot - pkg_specs = [] - - for name in pkg_names: - if not name.isidentifier() or keyword.iskeyword(name): + async for name in AsyncIter(pkg_names, steps=10): + if not bot._cog_mgr.is_valid_module_name(name): invalid_pkg_names.append(name) continue + try: - spec = await bot._cog_mgr.find_cog(name) - if spec: - pkg_specs.append((spec, name)) - else: - notfound_packages.append(name) + module = bot._cog_mgr.load_cog_module(name) + except errors.NoSuchCog: + notfound_packages.append(name) + continue except Exception as e: log.exception("Package import failed", exc_info=e) - exception_log = "Exception during import of package\n" + exception_log = "Exception during import of cog package\n" exception_log += "".join(traceback.format_exception(type(e), e, e.__traceback__)) bot._last_exception = exception_log failed_packages.append(name) + continue - async for spec, name in AsyncIter(pkg_specs, steps=10): try: - self._cleanup_and_refresh_modules(spec.name) - await bot.load_extension(spec) + await bot.load_extension(module) except errors.PackageAlreadyLoaded: alreadyloaded_packages.append(name) except errors.CogLoadError as e: @@ -237,32 +234,6 @@ async def _load(self, pkg_names: Iterable[str]) -> Dict[str, Union[List[str], Di "repos_with_shared_libs": list(repos_with_shared_libs), } - @staticmethod - def _cleanup_and_refresh_modules(module_name: str) -> None: - """Internally reloads modules so that changes are detected.""" - splitted = module_name.split(".") - - def maybe_reload(new_name): - try: - lib = sys.modules[new_name] - except KeyError: - pass - else: - importlib._bootstrap._exec(lib.__spec__, lib) - - # noinspection PyTypeChecker - modules = itertools.accumulate(splitted, "{}.{}".format) - for m in modules: - maybe_reload(m) - - children = { - name: lib - for name, lib in sys.modules.items() - if name == module_name or name.startswith(f"{module_name}.") - } - for child_name, lib in children.items(): - importlib._bootstrap._exec(lib.__spec__, lib) - async def _unload(self, pkg_names: Iterable[str]) -> Dict[str, List[str]]: """ Unloads packages with the given names. @@ -5764,13 +5735,12 @@ async def autoimmune_checkimmune( async def rpc_load(self, request): cog_name = request.params[0] - spec = await self.bot._cog_mgr.find_cog(cog_name) - if spec is None: + try: + module = self.bot._cog_mgr.load_cog_module(cog_name) + except errors.NoSuchCog: raise LookupError("No such cog found.") - self._cleanup_and_refresh_modules(spec.name) - - await self.bot.load_extension(spec) + await self.bot.load_extension(module) async def rpc_unload(self, request): cog_name = request.params[0] diff --git a/redbot/core/errors.py b/redbot/core/errors.py index 8f7a7eb8ef6..9910cf1f8b2 100644 --- a/redbot/core/errors.py +++ b/redbot/core/errors.py @@ -27,19 +27,29 @@ class RedError(Exception): class PackageAlreadyLoaded(RedError): """Raised when trying to load an already-loaded package.""" - def __init__(self, spec: importlib.machinery.ModuleSpec, *args, **kwargs): - super().__init__(*args, **kwargs) - self.spec: importlib.machinery.ModuleSpec = spec + def __init__(self, name: str, /): + self.name = name def __str__(self) -> str: - return f"There is already a package named {self.spec.name.split('.')[-1]} loaded" + return f"There is already a package named {self.name} loaded" class CogLoadError(RedError): """Raised by a cog when it cannot load itself. - The message will be sent to the user.""" - pass + The message will be sent to the user. + """ + + +class NoSuchCog(RedError, ModuleNotFoundError): + """Thrown when a cog is missing. + + Different from ImportError because some ImportErrors can happen inside cogs. + """ + + def __init__(self, message: str, /, *, name: str) -> None: + super().__init__(message) + self.name = name class BankError(RedError): diff --git a/redbot/ext_cogs/__init__.py b/redbot/ext_cogs/__init__.py new file mode 100644 index 00000000000..f5bc173167c --- /dev/null +++ b/redbot/ext_cogs/__init__.py @@ -0,0 +1,8 @@ +""" +Package for magically importing 3rd party cogs. + +In terms of this package as a directory, this `__init__.py` file should +always be the only file it contains. It exists so the cog manager can +modify its `__path__` attribute to allow importing cogs as child +packages of this package. +"""