Repository: Rapptz/discord.py

Issue:
Allow loading extensions via relative paths
### The Problem

I would like to use relative import paths to describe my extensions, e.g., `.extensions.foo` rather than requiring an absolute path like `my_bot.extensions.foo`. I use relative paths for package-internal imports, so it seems logical that extensions would follow the same pattern.

This does not currently work. I get the following error:

```
Traceback (most recent call last):
  File "/usr/lib64/python3.7/runpy.py", line 193, in _run_module_as_main
    "__main__", mod_spec)
  File "/usr/lib64/python3.7/runpy.py", line 85, in _run_code
    exec(code, run_globals)
  File "/home/chris/Projects/discord-fate-bot/discord_fate_bot/__main__.py", line 15, in <module>
    bot.load_extension('.extensions.error_handling')
  File "/home/chris/.local/share/virtualenvs/discord-fate-bot-MUSnTVTS/lib/python3.7/site-packages/discord/ext/commands/bot.py", line 621, in load_extension
    lib = importlib.import_module(name)
  File "/home/chris/.local/share/virtualenvs/discord-fate-bot-MUSnTVTS/lib64/python3.7/importlib/__init__.py", line 122, in import_module
    raise TypeError(msg.format(name))
TypeError: the 'package' argument is required to perform a relative import for '.extensions.error_handling'
```

It would seam this library is using `importlib` internally, which can handle relative paths, but is not passing enough parameters.


### The Ideal Solution

I would like `Bot.load_extension` to take an optional second parameter, which would specify the package to resolve against. This follows the example of `importlib.import_module`, which takes a package parameter in the same way. If no package is provided, the path is looked up as if from a top-level script (the current behavior).

This would allow us to say

```python
bot.load_extension('.extensions.foo', __package__)
```

which would load the extension relative to the current package.


### The Current Solution

Currently the solution is to only use absolute paths. I use `importlib.utils.resolve_name` to resolve my relative paths to absolute ones:

```python
from importlib.util import resolve_name
# ...
bot.load_extension(resolve_name('.extensions.foo', __package__))
```


### Summary

Please allow us to load extensions using relative paths via an optional package parameter in `Bot.load_extension`.

This feature request looks similar to #1703, but that request wants something similar old Python 2.x-style relative paths (just concatenating package and name with a dot). I would like something compatible with Python 3 and `importlib`.

I would be willing to work on a merge request. I just wanted to make an issue first in case there was a conscious decision not to support relative paths. (I would still ask you to reconsider.)


Discussion:

Resolving patch:
diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py
--- a/discord/ext/commands/bot.py
+++ b/discord/ext/commands/bot.py
@@ -624,7 +624,13 @@ def _load_from_module_spec(self, spec, key):
         else:
             self.__extensions[key] = lib
 
-    def load_extension(self, name):
+    def _resolve_name(self, name, package):
+        try:
+            return importlib.util.resolve_name(name, package)
+        except ImportError:
+            raise errors.ExtensionNotFound(name)
+
+    def load_extension(self, name, *, package=None):
         """Loads an extension.
 
         An extension is a python module that contains commands, cogs, or
@@ -640,11 +646,19 @@ def load_extension(self, name):
             The extension name to load. It must be dot separated like
             regular Python imports if accessing a sub-module. e.g.
             ``foo.test`` if you want to import ``foo/test.py``.
+        package: Optional[:class:`str`]
+            The package name to resolve relative imports with.
+            This is required when loading an extension using a relative path, e.g ``.foo.test``.
+            Defaults to ``None``.
+
+            .. versionadded:: 1.7
 
         Raises
         --------
         ExtensionNotFound
             The extension could not be imported.
+            This is also raised if the name of the extension could not
+            be resolved using the provided ``package`` parameter.
         ExtensionAlreadyLoaded
             The extension is already loaded.
         NoEntryPointError
@@ -653,6 +667,7 @@ def load_extension(self, name):
             The extension or its setup function had an execution error.
         """
 
+        name = self._resolve_name(name, package)
         if name in self.__extensions:
             raise errors.ExtensionAlreadyLoaded(name)
 
@@ -662,7 +677,7 @@ def load_extension(self, name):
 
         self._load_from_module_spec(spec, name)
 
-    def unload_extension(self, name):
+    def unload_extension(self, name, *, package=None):
         """Unloads an extension.
 
         When the extension is unloaded, all commands, listeners, and cogs are
@@ -679,13 +694,23 @@ def unload_extension(self, name):
             The extension name to unload. It must be dot separated like
             regular Python imports if accessing a sub-module. e.g.
             ``foo.test`` if you want to import ``foo/test.py``.
+        package: Optional[:class:`str`]
+            The package name to resolve relative imports with.
+            This is required when unloading an extension using a relative path, e.g ``.foo.test``.
+            Defaults to ``None``.
+
+            .. versionadded:: 1.7
 
         Raises
         -------
+        ExtensionNotFound
+            The name of the extension could not
+            be resolved using the provided ``package`` parameter.
         ExtensionNotLoaded
             The extension was not loaded.
         """
 
+        name = self._resolve_name(name, package)
         lib = self.__extensions.get(name)
         if lib is None:
             raise errors.ExtensionNotLoaded(name)
@@ -693,7 +718,7 @@ def unload_extension(self, name):
         self._remove_module_references(lib.__name__)
         self._call_module_finalizers(lib, name)
 
-    def reload_extension(self, name):
+    def reload_extension(self, name, *, package=None):
         """Atomically reloads an extension.
 
         This replaces the extension with the same extension, only refreshed. This is
@@ -707,6 +732,12 @@ def reload_extension(self, name):
             The extension name to reload. It must be dot separated like
             regular Python imports if accessing a sub-module. e.g.
             ``foo.test`` if you want to import ``foo/test.py``.
+        package: Optional[:class:`str`]
+            The package name to resolve relative imports with.
+            This is required when reloading an extension using a relative path, e.g ``.foo.test``.
+            Defaults to ``None``.
+
+            .. versionadded:: 1.7
 
         Raises
         -------
@@ -714,12 +745,15 @@ def reload_extension(self, name):
             The extension was not loaded.
         ExtensionNotFound
             The extension could not be imported.
+            This is also raised if the name of the extension could not
+            be resolved using the provided ``package`` parameter.
         NoEntryPointError
             The extension does not have a setup function.
         ExtensionFailed
             The extension setup function had an execution error.
         """
 
+        name = self._resolve_name(name, package)
         lib = self.__extensions.get(name)
         if lib is None:
             raise errors.ExtensionNotLoaded(name)


Test patch: