Repository: opsdroid/opsdroid

Issue:
Specify modules by Python module name
Currently to configure modules for opsdroid (connectors, databases and skills) you specify a `path` or `repo` config option which tells opsdroid where it can find that specific module. Then at run time opsdroid will pull all of those modules together into an internal location, append that to the Python path and import all the modules.

It would be sensible to also include support for using modules from the regular Python path. These could be specified with `module` key and take the form of `module.submodule` (e.g the same thing you would `import`. These would bypass the custom loader intirely and directly import the package.


Discussion:

Resolving patch:
diff --git a/opsdroid/loader.py b/opsdroid/loader.py
--- a/opsdroid/loader.py
+++ b/opsdroid/loader.py
@@ -11,7 +11,7 @@
 import sys
 import tempfile
 import urllib.request
-from collections import Mapping
+from collections.abc import Mapping
 
 import yaml
 
@@ -47,20 +47,26 @@ def import_module_from_spec(module_spec):
     @staticmethod
     def import_module(config):
         """Import module namespace as variable and return it."""
-        # Check if the module can be imported and proceed with import
-
-        # Proceed only if config.name is specified
-        # and parent module can be imported
-        if config["name"] and importlib.util.find_spec(config["module_path"]):
-            module_spec = importlib.util.find_spec(config["module_path"] +
-                                                   "." + config["name"])
-            if module_spec:
-                module = Loader.import_module_from_spec(module_spec)
-                _LOGGER.debug(_("Loaded %s: %s"), config["type"],
-                              config["module_path"])
-                return module
-
-        module_spec = importlib.util.find_spec(config["module_path"])
+        # Try to import the module from various locations, return the first
+        # successful import, or None if they all failed
+        #
+        # 1. try to import the module directly off PYTHONPATH
+        # 2. try to import a module with the given name in the module_path
+        # 3. try to import the module_path itself
+        module_spec = None
+        namespaces = [
+            config["module"],
+            config["module_path"] + '.' + config["name"],
+            config["module_path"],
+        ]
+        for namespace in namespaces:
+            try:
+                module_spec = importlib.util.find_spec(namespace)
+                if module_spec:
+                    break
+            except (ImportError, AttributeError):
+                continue
+
         if module_spec:
             module = Loader.import_module_from_spec(module_spec)
             _LOGGER.debug(_("Loaded %s: %s"),
@@ -69,7 +75,6 @@ def import_module(config):
 
         _LOGGER.error(_("Failed to load %s: %s"),
                       config["type"], config["module_path"])
-
         return None
 
     @staticmethod
@@ -310,8 +315,10 @@ def _load_modules(self, modules_type, modules):
             if not isinstance(config, Mapping):
                 config = {}
                 config["name"] = module
+                config["module"] = ''
             else:
                 config["name"] = module['name']
+                config["module"] = module.get("module", '')
             config["type"] = modules_type
             config["is_builtin"] = self.is_builtin_module(config)
             config["module_path"] = self.build_module_import_path(config)
@@ -319,7 +326,9 @@ def _load_modules(self, modules_type, modules):
             if "branch" not in config:
                 config["branch"] = DEFAULT_MODULE_BRANCH
 
-            if not config["is_builtin"]:
+            # If the module isn't builtin, or isn't already on the
+            # python path, install it
+            if not (config["is_builtin"] or config["module"]):
                 # Remove module for reinstall if no-cache set
                 self.check_cache(config)
 


Test patch:
diff --git a/tests/test_loader.py b/tests/test_loader.py
--- a/tests/test_loader.py
+++ b/tests/test_loader.py
@@ -241,6 +241,7 @@ def test_import_module(self):
         config["module_path"] = "os"
         config["name"] = "path"
         config["type"] = "system"
+        config["module"] = ""
 
         module = ld.Loader.import_module(config)
         self.assertIsInstance(module, ModuleType)
@@ -250,6 +251,7 @@ def test_import_module_new(self):
         config["module_path"] = "os"
         config["name"] = ""
         config["type"] = "system"
+        config["module"] = ""
 
         module = ld.Loader.import_module(config)
         self.assertIsInstance(module, ModuleType)
@@ -259,10 +261,22 @@ def test_import_module_failure(self):
         config["module_path"] = "nonexistant"
         config["name"] = "module"
         config["type"] = "broken"
+        config["module"] = ""
 
         module = ld.Loader.import_module(config)
         self.assertEqual(module, None)
 
+    def test_import_module_from_path(self):
+        config = {}
+        config["module_path"] = ""
+        config["name"] = "module"
+        config["type"] = ""
+        config["module"] = "os.path"
+
+        module = ld.Loader.import_module(config)
+        self.assertIsInstance(module, ModuleType)
+
+
     def test_load_config(self):
         opsdroid, loader = self.setup()
         loader._load_modules = mock.MagicMock()