Repository: opsdroid/opsdroid

Issue:
Reload skills
It should be possible for opsdroid to flush out all the loaded skills and other modules and reload them without restarting the application.


Discussion:
The web server must be stopped first and started again after as some skills will be webhooks.

Resolving patch:
diff --git a/opsdroid/__main__.py b/opsdroid/__main__.py
--- a/opsdroid/__main__.py
+++ b/opsdroid/__main__.py
@@ -93,12 +93,15 @@ def main():
 
     check_dependencies()
 
-    with OpsDroid() as opsdroid:
-        opsdroid.load()
-        configure_logging(opsdroid.config)
-        opsdroid.web_server = Web(opsdroid)
-        opsdroid.start_loop()
-        opsdroid.exit()
+    restart = True
+
+    while restart:
+        with OpsDroid() as opsdroid:
+            opsdroid.load()
+            configure_logging(opsdroid.config)
+            opsdroid.web_server = Web(opsdroid)
+            opsdroid.start_loop()
+            restart = opsdroid.should_restart
 
 
 if __name__ == "__main__":
diff --git a/opsdroid/core.py b/opsdroid/core.py
--- a/opsdroid/core.py
+++ b/opsdroid/core.py
@@ -1,5 +1,6 @@
 """Core components of OpsDroid."""
 
+import copy
 import logging
 import sys
 import weakref
@@ -44,10 +45,13 @@ def __init__(self):
             "total_responses": 0,
         }
         self.web_server = None
+        self.should_restart = False
+        self.stored_path = []
         _LOGGER.info("Created main opsdroid object")
 
     def __enter__(self):
         """Add self to existing instances."""
+        self.stored_path = copy.copy(sys.path)
         if not self.__class__.instances:
             self.__class__.instances.append(weakref.proxy(self))
         else:
@@ -56,7 +60,9 @@ def __enter__(self):
 
     def __exit__(self, exc_type, exc_value, traceback):
         """Remove self from existing instances."""
+        sys.path = self.stored_path
         self.__class__.instances = []
+        asyncio.set_event_loop(asyncio.new_event_loop())
 
     @property
     def default_connector(self):
@@ -74,8 +80,6 @@ def exit(self):
         """Exit application."""
         _LOGGER.info("Exiting application with return code " +
                      str(self.sys_status))
-        if self.eventloop.is_running():
-            self.eventloop.close()
         sys.exit(self.sys_status)
 
     def critical(self, error, code):
@@ -85,6 +89,18 @@ def critical(self, error, code):
         print("Error: " + error)
         self.exit()
 
+    def restart(self):
+        """Restart opsdroid."""
+        self.should_restart = True
+        self.stop()
+
+    def stop(self):
+        """Stop the event loop."""
+        pending = asyncio.Task.all_tasks()
+        for task in pending:
+            task.cancel()
+        self.eventloop.stop()
+
     def load(self):
         """Load configuration."""
         self.config = self.loader.load_config_file([
@@ -97,6 +113,7 @@ def start_loop(self):
         """Start the event loop."""
         connectors, databases, skills = \
             self.loader.load_modules_from_config(self.config)
+        _LOGGER.debug("Loaded %i skills", len(skills))
         if databases is not None:
             self.start_databases(databases)
         self.setup_skills(skills)
@@ -104,12 +121,17 @@ def start_loop(self):
         self.eventloop.create_task(parse_crontab(self))
         self.web_server.start()
         try:
-            self.eventloop.run_forever()
+            pending = asyncio.Task.all_tasks()
+            self.eventloop.run_until_complete(asyncio.gather(*pending))
         except (KeyboardInterrupt, EOFError):
             print('')  # Prints a character return for return to shell
+            self.stop()
             _LOGGER.info("Keyboard interrupt, exiting.")
+        except RuntimeError as error:
+            if str(error) != 'Event loop is closed':
+                raise error
         finally:
-            self.exit()
+            self.eventloop.close()
 
     def setup_skills(self, skills):
         """Call the setup function on the passed in skills."""
diff --git a/opsdroid/loader.py b/opsdroid/loader.py
--- a/opsdroid/loader.py
+++ b/opsdroid/loader.py
@@ -115,6 +115,11 @@ def create_default_config(config_path):
         shutil.copyfile(EXAMPLE_CONFIG_FILE, config_path)
         return config_path
 
+    @staticmethod
+    def _reload_modules(modules):
+        for module in modules:
+            importlib.reload(module["module"])
+
     def load_config_file(self, config_paths):
         """Load a yaml config file from path."""
         config_path = ""
@@ -169,6 +174,8 @@ def load_modules_from_config(self, config):
 
         if 'skills' in config.keys():
             skills = self._load_modules('skill', config['skills'])
+            self.opsdroid.skills = []
+            self._reload_modules(skills)
         else:
             self.opsdroid.critical(
                 "No skills in configuration, at least 1 required", 1)


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1,4 +1,5 @@
 
+import asyncio
 import unittest
 import unittest.mock as mock
 import asynctest
@@ -29,6 +30,19 @@ def test_critical(self):
         with OpsDroid() as opsdroid, self.assertRaises(SystemExit):
             opsdroid.critical("An error", 1)
 
+    def test_stop(self):
+        with OpsDroid() as opsdroid:
+            self.assertFalse(opsdroid.eventloop.is_closed())
+            opsdroid.stop()
+            self.assertFalse(opsdroid.eventloop.is_running())
+
+    def test_restart(self):
+        with OpsDroid() as opsdroid:
+            opsdroid.eventloop.create_task(asyncio.sleep(1))
+            self.assertFalse(opsdroid.should_restart)
+            opsdroid.restart()
+            self.assertTrue(opsdroid.should_restart)
+
     def test_load_config(self):
         with OpsDroid() as opsdroid:
             opsdroid.loader = mock.Mock()
@@ -47,7 +61,7 @@ def test_start_loop(self):
             opsdroid.start_connector_tasks = mock.Mock()
             opsdroid.eventloop.run_forever = mock.Mock()
 
-            with self.assertRaises(SystemExit):
+            with self.assertRaises(RuntimeError):
                 opsdroid.start_loop()
 
             self.assertTrue(opsdroid.start_databases.called)
diff --git a/tests/test_loader.py b/tests/test_loader.py
--- a/tests/test_loader.py
+++ b/tests/test_loader.py
@@ -150,7 +150,7 @@ def test_load_config(self):
         config['module-path'] = self._tmp_dir + "/opsdroid"
 
         loader.load_modules_from_config(config)
-        self.assertEqual(len(loader._load_modules.mock_calls), 3)
+        self.assertEqual(len(loader._load_modules.mock_calls), 4)
 
     def test_load_empty_config(self):
         opsdroid, loader = self.setup()
@@ -304,3 +304,10 @@ def test_install_local_module_failure(self):
             loader._install_local_module(config)
             logmock.assert_called_with(
                     "Failed to install from " + config["path"])
+
+    def test_reload_modules(self):
+        opsdroid, loader = self.setup()
+        with mock.patch('importlib.reload') as reload_mock:
+            mock_module = {"module": "fake_import"}
+            loader._reload_modules([mock_module])
+            self.assertTrue(reload_mock.called_with("fake_import"))